Monorepo for Tangled tangled.org
2

Configure Feed

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

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 BaseParams: pages.BaseParamsFromContext(r.Context()), 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 stringUri := string.AtUri().String() 150 starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, stringUri) 151 if err != nil { 152 l.Error("failed to get star count", "err", err) 153 } 154 user := s.OAuth.GetMultiAccountUser(r) 155 isStarred := false 156 if user != nil { 157 isStarred = db.GetStarStatus(s.Db, user.Did, stringUri) 158 } 159 160 comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri())) 161 if err != nil { 162 l.Error("failed to get comments", "err", err) 163 } 164 165 var entities []syntax.ATURI 166 for _, c := range comments { 167 entities = append(entities, c.AtUri()) 168 } 169 reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20) 170 if err != nil { 171 l.Error("failed to get reactions", "err", err) 172 } 173 174 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 175 if user != nil { 176 userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Did)) 177 if err != nil { 178 l.Error("failed to get user reactions", "err", err) 179 } 180 } 181 182 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 183 if user != nil { 184 var participants []syntax.DID 185 for _, c := range comments { 186 participants = append(participants, c.Did) 187 } 188 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.Db, syntax.DID(user.Did), participants) 189 if err != nil { 190 l.Error("failed to fetch vouch relationships", "err", err) 191 } 192 } 193 194 err = s.Pages.SingleString(w, pages.SingleStringParams{ 195 BaseParams: pages.BaseParamsFromContext(r.Context()), 196 RenderToggle: renderToggle, 197 ShowRendered: showRendered, 198 String: &string, 199 Stats: string.Stats(), 200 IsStarred: isStarred, 201 StarCount: starCount, 202 Owner: id, 203 CommentList: models.NewCommentList(comments), 204 205 Reactions: reactions, 206 UserReacted: userReactions, 207 VouchRelationships: vouchRelationships, 208 }) 209} 210 211func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 212 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 213} 214 215func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 216 l := s.Logger.With("handler", "edit") 217 218 user := s.OAuth.GetMultiAccountUser(r) 219 220 id, ok := r.Context().Value("resolvedId").(identity.Identity) 221 if !ok { 222 l.Error("malformed middleware") 223 w.WriteHeader(http.StatusInternalServerError) 224 return 225 } 226 l = l.With("did", id.DID, "handle", id.Handle) 227 228 rkey := chi.URLParam(r, "rkey") 229 if rkey == "" { 230 l.Error("malformed url, empty rkey") 231 w.WriteHeader(http.StatusBadRequest) 232 return 233 } 234 l = l.With("rkey", rkey) 235 236 // get the string currently being edited 237 all, err := db.GetStrings( 238 s.Db, 239 0, 240 orm.FilterEq("did", id.DID), 241 orm.FilterEq("rkey", rkey), 242 ) 243 if err != nil { 244 l.Error("failed to fetch string", "err", err) 245 w.WriteHeader(http.StatusInternalServerError) 246 return 247 } 248 if len(all) != 1 { 249 l.Error("incorrect number of records returned", "len(strings)", len(all)) 250 w.WriteHeader(http.StatusInternalServerError) 251 return 252 } 253 first := all[0] 254 255 // verify that the logged in user owns this string 256 if user.Did != id.DID.String() { 257 l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 258 w.WriteHeader(http.StatusUnauthorized) 259 return 260 } 261 262 switch r.Method { 263 case http.MethodGet: 264 // return the form with prefilled fields 265 s.Pages.PutString(w, pages.PutStringParams{ 266 BaseParams: pages.BaseParamsFromContext(r.Context()), 267 Action: "edit", 268 String: first, 269 }) 270 case http.MethodPost: 271 fail := func(msg string, err error) { 272 l.Error(msg, "err", err) 273 s.Pages.Notice(w, "error", msg) 274 } 275 276 filename := r.FormValue("filename") 277 if filename == "" { 278 fail("Empty filename.", nil) 279 return 280 } 281 282 content := r.FormValue("content") 283 if content == "" { 284 fail("Empty contents.", nil) 285 return 286 } 287 288 description := r.FormValue("description") 289 290 // construct new string from form values 291 entry := models.String{ 292 Did: first.Did, 293 Rkey: first.Rkey, 294 Filename: filename, 295 Description: description, 296 Contents: content, 297 Created: first.Created, 298 } 299 300 record := entry.AsRecord() 301 302 client, err := s.OAuth.AuthorizedClient(r) 303 if err != nil { 304 fail("Failed to create record.", err) 305 return 306 } 307 308 // first replace the existing record in the PDS 309 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 310 if err != nil { 311 fail("Failed to updated existing record.", err) 312 return 313 } 314 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 315 Collection: tangled.StringNSID, 316 Repo: entry.Did.String(), 317 Rkey: entry.Rkey, 318 SwapRecord: ex.Cid, 319 Record: &lexutil.LexiconTypeDecoder{ 320 Val: &record, 321 }, 322 }) 323 if err != nil { 324 fail("Failed to updated existing record.", err) 325 return 326 } 327 l := l.With("aturi", resp.Uri) 328 l.Info("edited string") 329 330 // if that went okay, updated the db 331 if err = db.AddString(s.Db, entry); err != nil { 332 fail("Failed to update string.", err) 333 return 334 } 335 336 s.Notifier.EditString(r.Context(), &entry) 337 338 // if that went okay, redir to the string 339 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 340 } 341 342} 343 344func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 345 l := s.Logger.With("handler", "create") 346 user := s.OAuth.GetMultiAccountUser(r) 347 348 switch r.Method { 349 case http.MethodGet: 350 s.Pages.PutString(w, pages.PutStringParams{ 351 BaseParams: pages.BaseParamsFromContext(r.Context()), 352 Action: "new", 353 }) 354 case http.MethodPost: 355 fail := func(msg string, err error) { 356 l.Error(msg, "err", err) 357 s.Pages.Notice(w, "error", msg) 358 } 359 360 filename := r.FormValue("filename") 361 if filename == "" { 362 fail("Empty filename.", nil) 363 return 364 } 365 366 content := r.FormValue("content") 367 if content == "" { 368 fail("Empty contents.", nil) 369 return 370 } 371 372 description := r.FormValue("description") 373 374 string := models.String{ 375 Did: syntax.DID(user.Did), 376 Rkey: tid.TID(), 377 Filename: filename, 378 Description: description, 379 Contents: content, 380 Created: time.Now(), 381 } 382 383 record := string.AsRecord() 384 385 client, err := s.OAuth.AuthorizedClient(r) 386 if err != nil { 387 fail("Failed to create record.", err) 388 return 389 } 390 391 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 392 Collection: tangled.StringNSID, 393 Repo: user.Did, 394 Rkey: string.Rkey, 395 Record: &lexutil.LexiconTypeDecoder{ 396 Val: &record, 397 }, 398 }) 399 if err != nil { 400 fail("Failed to create record.", err) 401 return 402 } 403 l := l.With("aturi", resp.Uri) 404 l.Info("created record") 405 406 // insert into DB 407 if err = db.AddString(s.Db, string); err != nil { 408 fail("Failed to create string.", err) 409 return 410 } 411 412 s.Notifier.NewString(r.Context(), &string) 413 414 // successful 415 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 416 } 417} 418 419func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 420 l := s.Logger.With("handler", "create") 421 user := s.OAuth.GetMultiAccountUser(r) 422 fail := func(msg string, err error) { 423 l.Error(msg, "err", err) 424 s.Pages.Notice(w, "error", msg) 425 } 426 427 id, ok := r.Context().Value("resolvedId").(identity.Identity) 428 if !ok { 429 l.Error("malformed middleware") 430 w.WriteHeader(http.StatusInternalServerError) 431 return 432 } 433 l = l.With("did", id.DID, "handle", id.Handle) 434 435 rkey := chi.URLParam(r, "rkey") 436 if rkey == "" { 437 l.Error("malformed url, empty rkey") 438 w.WriteHeader(http.StatusBadRequest) 439 return 440 } 441 442 if user.Did != id.DID.String() { 443 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 444 return 445 } 446 447 client, err := s.OAuth.AuthorizedClient(r) 448 if err != nil { 449 fail("Failed to authorize client.", err) 450 return 451 } 452 453 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 454 Collection: tangled.StringNSID, 455 Repo: user.Did, 456 Rkey: rkey, 457 }) 458 if err != nil { 459 fail("Failed to delete string record from PDS.", err) 460 return 461 } 462 463 if err := db.DeleteString( 464 s.Db, 465 orm.FilterEq("did", user.Did), 466 orm.FilterEq("rkey", rkey), 467 ); err != nil { 468 fail("Failed to delete string.", err) 469 return 470 } 471 472 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 473 474 s.Pages.HxRedirect(w, "/strings/"+user.Did) 475}