forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package strings 2 3import ( 4 "fmt" 5 "log/slog" 6 "net/http" 7 "path" 8 "strconv" 9 "time" 10 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/middleware" 14 "tangled.sh/tangled.sh/core/appview/notify" 15 "tangled.sh/tangled.sh/core/appview/oauth" 16 "tangled.sh/tangled.sh/core/appview/pages" 17 "tangled.sh/tangled.sh/core/appview/pages/markup" 18 "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/tid" 20 21 "github.com/bluesky-social/indigo/api/atproto" 22 "github.com/bluesky-social/indigo/atproto/identity" 23 "github.com/bluesky-social/indigo/atproto/syntax" 24 lexutil "github.com/bluesky-social/indigo/lex/util" 25 "github.com/go-chi/chi/v5" 26) 27 28type Strings struct { 29 Db *db.DB 30 OAuth *oauth.OAuth 31 Pages *pages.Pages 32 IdResolver *idresolver.Resolver 33 Logger *slog.Logger 34 Notifier notify.Notifier 35} 36 37func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 38 r := chi.NewRouter() 39 40 r. 41 Get("/", s.timeline) 42 43 r. 44 With(mw.ResolveIdent()). 45 Route("/{user}", func(r chi.Router) { 46 r.Get("/", s.dashboard) 47 48 r.Route("/{rkey}", func(r chi.Router) { 49 r.Get("/", s.contents) 50 r.Delete("/", s.delete) 51 r.Get("/raw", s.contents) 52 r.Get("/edit", s.edit) 53 r.Post("/edit", s.edit) 54 r. 55 With(middleware.AuthMiddleware(s.OAuth)). 56 Post("/comment", s.comment) 57 }) 58 }) 59 60 r. 61 With(middleware.AuthMiddleware(s.OAuth)). 62 Route("/new", func(r chi.Router) { 63 r.Get("/", s.create) 64 r.Post("/", s.create) 65 }) 66 67 return r 68} 69 70func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 71 l := s.Logger.With("handler", "timeline") 72 73 strings, err := db.GetStrings(s.Db, 50) 74 if err != nil { 75 l.Error("failed to fetch string", "err", err) 76 w.WriteHeader(http.StatusInternalServerError) 77 return 78 } 79 80 s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 81 LoggedInUser: s.OAuth.GetUser(r), 82 Strings: strings, 83 }) 84} 85 86func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 87 l := s.Logger.With("handler", "contents") 88 89 id, ok := r.Context().Value("resolvedId").(identity.Identity) 90 if !ok { 91 l.Error("malformed middleware") 92 w.WriteHeader(http.StatusInternalServerError) 93 return 94 } 95 l = l.With("did", id.DID, "handle", id.Handle) 96 97 rkey := chi.URLParam(r, "rkey") 98 if rkey == "" { 99 l.Error("malformed url, empty rkey") 100 w.WriteHeader(http.StatusBadRequest) 101 return 102 } 103 l = l.With("rkey", rkey) 104 105 strings, err := db.GetStrings( 106 s.Db, 107 0, 108 db.FilterEq("did", id.DID), 109 db.FilterEq("rkey", rkey), 110 ) 111 if err != nil { 112 l.Error("failed to fetch string", "err", err) 113 w.WriteHeader(http.StatusInternalServerError) 114 return 115 } 116 if len(strings) < 1 { 117 l.Error("string not found") 118 s.Pages.Error404(w) 119 return 120 } 121 if len(strings) != 1 { 122 l.Error("incorrect number of records returned", "len(strings)", len(strings)) 123 w.WriteHeader(http.StatusInternalServerError) 124 return 125 } 126 string := strings[0] 127 128 if path.Base(r.URL.Path) == "raw" { 129 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 130 if string.Filename != "" { 131 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 132 } 133 w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 134 135 _, err = w.Write([]byte(string.Contents)) 136 if err != nil { 137 l.Error("failed to write raw response", "err", err) 138 } 139 return 140 } 141 142 var showRendered, renderToggle bool 143 if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 144 renderToggle = true 145 showRendered = r.URL.Query().Get("code") != "true" 146 } 147 148 s.Pages.SingleString(w, pages.SingleStringParams{ 149 LoggedInUser: s.OAuth.GetUser(r), 150 RenderToggle: renderToggle, 151 ShowRendered: showRendered, 152 String: string, 153 Stats: string.Stats(), 154 Owner: id, 155 }) 156} 157 158func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 159 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 160} 161 162func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 163 l := s.Logger.With("handler", "edit") 164 165 user := s.OAuth.GetUser(r) 166 167 id, ok := r.Context().Value("resolvedId").(identity.Identity) 168 if !ok { 169 l.Error("malformed middleware") 170 w.WriteHeader(http.StatusInternalServerError) 171 return 172 } 173 l = l.With("did", id.DID, "handle", id.Handle) 174 175 rkey := chi.URLParam(r, "rkey") 176 if rkey == "" { 177 l.Error("malformed url, empty rkey") 178 w.WriteHeader(http.StatusBadRequest) 179 return 180 } 181 l = l.With("rkey", rkey) 182 183 // get the string currently being edited 184 all, err := db.GetStrings( 185 s.Db, 186 0, 187 db.FilterEq("did", id.DID), 188 db.FilterEq("rkey", rkey), 189 ) 190 if err != nil { 191 l.Error("failed to fetch string", "err", err) 192 w.WriteHeader(http.StatusInternalServerError) 193 return 194 } 195 if len(all) != 1 { 196 l.Error("incorrect number of records returned", "len(strings)", len(all)) 197 w.WriteHeader(http.StatusInternalServerError) 198 return 199 } 200 first := all[0] 201 202 // verify that the logged in user owns this string 203 if user.Did != id.DID.String() { 204 l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 205 w.WriteHeader(http.StatusUnauthorized) 206 return 207 } 208 209 switch r.Method { 210 case http.MethodGet: 211 // return the form with prefilled fields 212 s.Pages.PutString(w, pages.PutStringParams{ 213 LoggedInUser: s.OAuth.GetUser(r), 214 Action: "edit", 215 String: first, 216 }) 217 case http.MethodPost: 218 fail := func(msg string, err error) { 219 l.Error(msg, "err", err) 220 s.Pages.Notice(w, "error", msg) 221 } 222 223 filename := r.FormValue("filename") 224 if filename == "" { 225 fail("Empty filename.", nil) 226 return 227 } 228 229 content := r.FormValue("content") 230 if content == "" { 231 fail("Empty contents.", nil) 232 return 233 } 234 235 description := r.FormValue("description") 236 237 // construct new string from form values 238 entry := db.String{ 239 Did: first.Did, 240 Rkey: first.Rkey, 241 Filename: filename, 242 Description: description, 243 Contents: content, 244 Created: first.Created, 245 } 246 247 record := entry.AsRecord() 248 249 client, err := s.OAuth.AuthorizedClient(r) 250 if err != nil { 251 fail("Failed to create record.", err) 252 return 253 } 254 255 // first replace the existing record in the PDS 256 ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 257 if err != nil { 258 fail("Failed to updated existing record.", err) 259 return 260 } 261 resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 262 Collection: tangled.StringNSID, 263 Repo: entry.Did.String(), 264 Rkey: entry.Rkey, 265 SwapRecord: ex.Cid, 266 Record: &lexutil.LexiconTypeDecoder{ 267 Val: &record, 268 }, 269 }) 270 if err != nil { 271 fail("Failed to updated existing record.", err) 272 return 273 } 274 l := l.With("aturi", resp.Uri) 275 l.Info("edited string") 276 277 // if that went okay, updated the db 278 if err = db.AddString(s.Db, entry); err != nil { 279 fail("Failed to update string.", err) 280 return 281 } 282 283 s.Notifier.EditString(r.Context(), &entry) 284 285 // if that went okay, redir to the string 286 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 287 } 288 289} 290 291func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 292 l := s.Logger.With("handler", "create") 293 user := s.OAuth.GetUser(r) 294 295 switch r.Method { 296 case http.MethodGet: 297 s.Pages.PutString(w, pages.PutStringParams{ 298 LoggedInUser: s.OAuth.GetUser(r), 299 Action: "new", 300 }) 301 case http.MethodPost: 302 fail := func(msg string, err error) { 303 l.Error(msg, "err", err) 304 s.Pages.Notice(w, "error", msg) 305 } 306 307 filename := r.FormValue("filename") 308 if filename == "" { 309 fail("Empty filename.", nil) 310 return 311 } 312 313 content := r.FormValue("content") 314 if content == "" { 315 fail("Empty contents.", nil) 316 return 317 } 318 319 description := r.FormValue("description") 320 321 string := db.String{ 322 Did: syntax.DID(user.Did), 323 Rkey: tid.TID(), 324 Filename: filename, 325 Description: description, 326 Contents: content, 327 Created: time.Now(), 328 } 329 330 record := string.AsRecord() 331 332 client, err := s.OAuth.AuthorizedClient(r) 333 if err != nil { 334 fail("Failed to create record.", err) 335 return 336 } 337 338 resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 339 Collection: tangled.StringNSID, 340 Repo: user.Did, 341 Rkey: string.Rkey, 342 Record: &lexutil.LexiconTypeDecoder{ 343 Val: &record, 344 }, 345 }) 346 if err != nil { 347 fail("Failed to create record.", err) 348 return 349 } 350 l := l.With("aturi", resp.Uri) 351 l.Info("created record") 352 353 // insert into DB 354 if err = db.AddString(s.Db, string); err != nil { 355 fail("Failed to create string.", err) 356 return 357 } 358 359 s.Notifier.NewString(r.Context(), &string) 360 361 // successful 362 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 363 } 364} 365 366func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 367 l := s.Logger.With("handler", "create") 368 user := s.OAuth.GetUser(r) 369 fail := func(msg string, err error) { 370 l.Error(msg, "err", err) 371 s.Pages.Notice(w, "error", msg) 372 } 373 374 id, ok := r.Context().Value("resolvedId").(identity.Identity) 375 if !ok { 376 l.Error("malformed middleware") 377 w.WriteHeader(http.StatusInternalServerError) 378 return 379 } 380 l = l.With("did", id.DID, "handle", id.Handle) 381 382 rkey := chi.URLParam(r, "rkey") 383 if rkey == "" { 384 l.Error("malformed url, empty rkey") 385 w.WriteHeader(http.StatusBadRequest) 386 return 387 } 388 389 if user.Did != id.DID.String() { 390 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 391 return 392 } 393 394 if err := db.DeleteString( 395 s.Db, 396 db.FilterEq("did", user.Did), 397 db.FilterEq("rkey", rkey), 398 ); err != nil { 399 fail("Failed to delete string.", err) 400 return 401 } 402 403 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 404 405 s.Pages.HxRedirect(w, "/strings/"+user.Handle) 406} 407 408func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 409}