Monorepo for Tangled tangled.org
776
fork

Configure Feed

Select the types of activity you want to include in your feed.

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