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}