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