A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
1package atshorter
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "time"
10
11 "github.com/bluesky-social/indigo/atproto/client"
12)
13
14type HomeData struct {
15 UsersShortURLs []ShortURL
16}
17
18type ShortURL struct {
19 ID string
20 URL string
21 Did string
22 OriginHost string
23}
24
25func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) {
26 id := r.PathValue("id")
27 if id == "" {
28 http.Redirect(w, r, "/", http.StatusSeeOther)
29 return
30 }
31 shortURL, err := s.store.GetURLByID(id)
32 if err != nil {
33 if errors.Is(err, ErrorNotFound) {
34 slog.Error("url with ID not found", "id", id)
35 http.Error(w, "not found", http.StatusNotFound)
36 return
37 }
38 slog.Error("getting URL by id", "id", id, "error", err)
39 http.Error(w, "error fetching URL for redirect", http.StatusInternalServerError)
40 return
41 }
42
43 http.Redirect(w, r, shortURL.URL, http.StatusSeeOther)
44 return
45}
46
47func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) {
48 tmpl := s.getTemplate("home.html")
49
50 did, _ := s.currentSessionDID(r)
51 if did == nil {
52 http.Redirect(w, r, "/login", http.StatusFound)
53 return
54 }
55
56 data := HomeData{}
57
58 usersURLs, err := s.store.GetURLs(did.String())
59 if err != nil {
60 slog.Error("fetching URLs", "error", err)
61 tmpl.Execute(w, data)
62 return
63 }
64 data.UsersShortURLs = usersURLs
65
66 tmpl.Execute(w, data)
67}
68
69func (s *Server) HandleDeleteURL(w http.ResponseWriter, r *http.Request) {
70 id := r.PathValue("id")
71 if id == "" {
72 http.Redirect(w, r, "/", http.StatusSeeOther)
73 return
74 }
75
76 did, sessionID := s.currentSessionDID(r)
77 if did == nil {
78 http.Redirect(w, r, "/login", http.StatusFound)
79 return
80 }
81
82 shortURL, err := s.store.GetURLByID(id)
83 if err != nil {
84 slog.Error("looking up short URL", "error", err)
85 http.Redirect(w, r, "/", http.StatusSeeOther)
86 return
87 }
88
89 if shortURL.Did != did.String() {
90 slog.Error("tried to delete record that doesn't belong to user")
91 http.Error(w, "not authenticated", http.StatusUnauthorized)
92 return
93 }
94
95 session, err := s.oauthClient.ResumeSession(r.Context(), *did, sessionID)
96 if err != nil {
97 http.Error(w, "not authenticated", http.StatusUnauthorized)
98 return
99 }
100
101 api := session.APIClient()
102
103 bodyReq := map[string]any{
104 "repo": shortURL.Did,
105 "collection": "com.atshorter.shorturl",
106 "rkey": id,
107 }
108 err = api.Post(r.Context(), "com.atproto.repo.deleteRecord", bodyReq, nil)
109 if err != nil {
110 slog.Error("failed to delete short URL record", "error", err)
111 http.Redirect(w, r, "/", http.StatusFound)
112 return
113 }
114
115 err = s.store.DeleteURL(id, did.String())
116 if err != nil {
117 slog.Error("deleting URL from store", "error", err, "id", id, "did", did.String())
118 http.Redirect(w, r, "/", http.StatusSeeOther)
119 return
120 }
121
122 http.Redirect(w, r, "/", http.StatusSeeOther)
123 return
124}
125
126func (s *Server) HandleCreateShortURL(w http.ResponseWriter, r *http.Request) {
127 err := r.ParseForm()
128 if err != nil {
129 slog.Error("parsing form", "error", err)
130 http.Error(w, "parsing form", http.StatusBadRequest)
131 return
132 }
133
134 url := r.Form.Get("newURL")
135 if url == "" {
136 slog.Error("newURL not provided")
137 http.Error(w, "missing newURL", http.StatusBadRequest)
138 return
139 }
140
141 did, sessionID := s.currentSessionDID(r)
142 if did == nil {
143 http.Redirect(w, r, "/login", http.StatusFound)
144 return
145 }
146
147 session, err := s.oauthClient.ResumeSession(r.Context(), *did, sessionID)
148 if err != nil {
149 http.Error(w, "not authenticated", http.StatusUnauthorized)
150 return
151 }
152
153 rkey := TID()
154 createdAt := time.Now()
155 api := session.APIClient()
156
157 record := ShortURLRecord{
158 URL: url,
159 CreatedAt: createdAt,
160 Origin: s.host,
161 }
162
163 bodyReq := map[string]any{
164 "repo": api.AccountDID.String(),
165 "collection": "com.atshorter.shorturl",
166 "rkey": rkey,
167 "record": record,
168 }
169 err = api.Post(r.Context(), "com.atproto.repo.createRecord", bodyReq, nil)
170 if err != nil {
171 slog.Error("failed to create new short URL record", "error", err)
172 http.Redirect(w, r, "/", http.StatusFound)
173 return
174 }
175
176 err = s.store.CreateURL(rkey, url, did.String(), s.host, createdAt.UnixMilli())
177 if err != nil {
178 slog.Error("store in local database", "error", err)
179 }
180
181 http.Redirect(w, r, "/", http.StatusFound)
182}
183
184type GetRecordResult struct {
185 URI string `json:"uri"`
186 CID string `json:"cid"`
187 Value ShortURLRecord `json:"value"`
188}
189
190func (s *Server) getUrlRecord(ctx context.Context, didStr, rkey string) (ShortURLRecord, error) {
191 host, err := s.lookupDidHost(ctx, didStr)
192 if err != nil {
193 return ShortURLRecord{}, fmt.Errorf("looking up did host: %w", err)
194 }
195
196 atClient := client.APIClient{
197 Client: s.httpClient,
198 Host: host,
199 }
200
201 params := map[string]any{
202 "repo": didStr,
203 "collection": "com.atshorter.shorturl",
204 "rkey": rkey,
205 }
206
207 var res GetRecordResult
208 err = atClient.Get(ctx, "com.atproto.repo.getRecord", params, &res)
209 if err != nil {
210 return ShortURLRecord{}, fmt.Errorf("calling getRecord: %w", err)
211 }
212
213 return res.Value, nil
214}