A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
at main 5.0 kB view raw
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}