Monorepo for Tangled tangled.org
11

Configure Feed

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

1package stringn 2 3import ( 4 "bytes" 5 "compress/gzip" 6 "context" 7 "database/sql" 8 "errors" 9 "fmt" 10 "io" 11 "log/slog" 12 "net/http" 13 "strconv" 14 "strings" 15 "time" 16 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/middleware" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/notify" 22 "tangled.org/core/appview/oauth" 23 "tangled.org/core/appview/pages" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/blobstore" 26 "tangled.org/core/orm" 27 "tangled.org/core/tid" 28 "tangled.org/core/xrpc" 29 30 "github.com/bluesky-social/indigo/api/agnostic" 31 "github.com/bluesky-social/indigo/api/atproto" 32 "github.com/bluesky-social/indigo/atproto/identity" 33 "github.com/bluesky-social/indigo/atproto/syntax" 34 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 35 "github.com/go-chi/chi/v5" 36 "github.com/ipfs/go-cid" 37 38 comatproto "github.com/bluesky-social/indigo/api/atproto" 39 lexutil "github.com/bluesky-social/indigo/lex/util" 40) 41 42const textPlain = "text/plain" 43 44type Strings struct { 45 Db *db.DB 46 OAuth *oauth.OAuth 47 Pages *pages.Pages 48 Dir identity.Directory 49 BlobStore blobstore.BlobStore 50 Logger *slog.Logger 51 Notifier notify.Notifier 52} 53 54func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 55 r := chi.NewRouter() 56 57 r. 58 Get("/", s.timeline) 59 r. 60 Get("/fileEdit", s.FileEditFragment) 61 62 r. 63 Route("/{user}", func(r chi.Router) { 64 r.Get("/", s.dashboard) 65 66 r.Route("/{rkey}", func(r chi.Router) { 67 r.Use(mw.ResolveIdent()) 68 r.Use(s.resolveString) 69 70 r.Get("/", s.SingleString) 71 r.Delete("/", s.delete) 72 r.Get("/edit", s.edit) 73 r.Post("/edit", s.edit) 74 75 r.Get("/{cid}/{filename}", s.FileFragment) 76 r.Get("/{cid}/{filename}/raw", s.FileRaw) 77 78 // legacy endpoint 79 r.Get("/raw", s.redirectToFirstFileRaw) 80 }) 81 }) 82 83 r. 84 With(middleware.AuthMiddleware(s.OAuth)). 85 Route("/new", func(r chi.Router) { 86 r.Get("/", s.create) 87 r.Post("/", s.create) 88 }) 89 90 return r 91} 92 93func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 94 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 95} 96 97func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 98 l := s.Logger.With("handler", "timeline") 99 100 strings, err := db.GetStrings(s.Db, 50) 101 if err != nil { 102 l.Error("failed to fetch string", "err", err) 103 w.WriteHeader(http.StatusInternalServerError) 104 return 105 } 106 107 s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 108 BaseParams: pages.BaseParamsFromContext(r.Context()), 109 Strings: strings, 110 }) 111} 112 113type stringCtxKey struct{} 114 115func (s *Strings) resolveString(next http.Handler) http.Handler { 116 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 l := s.Logger.With("middleware", "resolveString") 118 rkey := chi.URLParam(r, "rkey") 119 120 id, ok := r.Context().Value("resolvedId").(identity.Identity) 121 if !ok { 122 l.Error("malformed middleware") 123 w.WriteHeader(http.StatusInternalServerError) 124 return 125 } 126 127 str, err := db.GetString(s.Db, orm.FilterEq("did", id.DID), orm.FilterEq("rkey", rkey)) 128 if errors.Is(err, sql.ErrNoRows) { 129 s.Pages.Error404(w) 130 return 131 } else if err != nil { 132 l.Error("failed to fetch string", "err", err) 133 w.WriteHeader(http.StatusInternalServerError) 134 return 135 } 136 137 ctx := context.WithValue(r.Context(), stringCtxKey{}, str) 138 next.ServeHTTP(w, r.WithContext(ctx)) 139 }) 140} 141 142func stringFromContext(ctx context.Context) (models.String, bool) { 143 str, ok := ctx.Value(stringCtxKey{}).(models.String) 144 return str, ok 145} 146 147// redirectToFirstFileRaw is a handle for legacy endpoint. 148// It redirects /strings/{did}/{rkey}/raw to /strings/{did}/{rkey}/{cid}/{filename}/raw 149func (s *Strings) redirectToFirstFileRaw(w http.ResponseWriter, r *http.Request) { 150 str, ok := stringFromContext(r.Context()) 151 if !ok { 152 s.Logger.Error("malformed middleware. string missing") 153 s.Pages.Error404(w) 154 return 155 } 156 var cid syntax.CID 157 var filename string 158 if str.Cid != nil { 159 cid = *str.Cid 160 } else { 161 var err error 162 cid, err = s.getRecordCid(r.Context(), str.AtUri()) 163 if err != nil { 164 s.Pages.Error404(w) 165 return 166 } 167 } 168 if str.IsLegacySingleFile() { 169 filename = str.FileName 170 } else { 171 filename = str.Files[0].Name 172 } 173 http.Redirect(w, r, fmt.Sprintf("/strings/%s/%s/%s/%s/raw", str.Did, str.Rkey, cid, filename), http.StatusFound) 174} 175 176func (s *Strings) SingleString(w http.ResponseWriter, r *http.Request) { 177 l := s.Logger.With("handler", "SingleString") 178 ctx := r.Context() 179 180 str, ok := stringFromContext(ctx) 181 if !ok { 182 l.Error("malformed middleware. string missing") 183 s.Pages.Error404(w) 184 return 185 } 186 187 starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, str.AtUri().String()) 188 if err != nil { 189 l.Error("failed to get star count", "err", err) 190 } 191 user := s.OAuth.GetMultiAccountUser(r) 192 isStarred := false 193 if user != nil { 194 isStarred = db.GetStarStatus(s.Db, user.Did, str.AtUri().String()) 195 } 196 197 comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", str.AtUri())) 198 if err != nil { 199 l.Error("failed to get comments", "err", err) 200 } 201 202 var entities []syntax.ATURI 203 for _, c := range comments { 204 entities = append(entities, c.AtUri()) 205 } 206 reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20) 207 if err != nil { 208 l.Error("failed to get reactions", "err", err) 209 } 210 211 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 212 if user != nil { 213 userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Did)) 214 if err != nil { 215 l.Error("failed to get user reactions", "err", err) 216 } 217 } 218 219 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 220 if user != nil { 221 var participants []syntax.DID 222 for _, c := range comments { 223 participants = append(participants, c.Did) 224 } 225 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.Db, syntax.DID(user.Did), participants) 226 if err != nil { 227 l.Error("failed to fetch vouch relationships", "err", err) 228 } 229 } 230 231 var files []pages.StringFileFragmentParams 232 233 if str.IsLegacySingleFile() { 234 files = []pages.StringFileFragmentParams{ 235 s.makeFileFragmentParams(&str, str.FileName, str.FileContent, false), 236 } 237 } else { 238 files = make([]pages.StringFileFragmentParams, len(str.Files)) 239 for i, file := range str.Files { 240 blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) 241 if err != nil { 242 l.Warn("failed to fetch blob", "err", err) 243 http.NotFound(w, r) 244 return 245 } 246 defer blob.Close() 247 248 contentBytes, err := io.ReadAll(blob) 249 if err != nil { 250 l.Error("failed to read blob", "err", err) 251 } 252 253 files[i] = s.makeFileFragmentParams(&str, file.Name, string(contentBytes), false) 254 } 255 } 256 257 err = s.Pages.SingleString(w, pages.SingleStringParams{ 258 BaseParams: pages.BaseParamsFromContext(r.Context()), 259 String: &str, 260 FileParams: files, 261 IsStarred: isStarred, 262 StarCount: starCount, 263 CommentList: models.NewCommentList(comments), 264 265 Reactions: reactions, 266 UserReacted: userReactions, 267 VouchRelationships: vouchRelationships, 268 }) 269 if err != nil { 270 l.Error("failed to render", "err", err) 271 } 272} 273 274func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 275 l := s.Logger.With("handler", "edit") 276 ctx := r.Context() 277 278 user := s.OAuth.GetMultiAccountUser(r) 279 280 oldString, ok := stringFromContext(ctx) 281 if !ok { 282 l.Error("malformed middleware. string missing") 283 s.Pages.Error404(w) 284 return 285 } 286 287 // verify that the logged in user owns this string 288 if user.Did != oldString.Did.String() { 289 l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did) 290 w.WriteHeader(http.StatusUnauthorized) 291 return 292 } 293 294 switch r.Method { 295 case http.MethodGet: 296 // return the form with prefilled fields 297 var files []pages.StringFileEditFragmentParams 298 if oldString.IsLegacySingleFile() { 299 files = []pages.StringFileEditFragmentParams{ 300 { 301 Name: oldString.FileName, 302 Content: oldString.FileContent, 303 Size: uint64(len(oldString.FileContent)), 304 }, 305 } 306 } else { 307 files = make([]pages.StringFileEditFragmentParams, len(oldString.Files)) 308 for i, file := range oldString.Files { 309 blob, err := s.BlobStore.GetBlob(r.Context(), oldString.Did, cid.Cid(file.Content.Ref)) 310 if err != nil { 311 l.Warn("failed to fetch blob", "err", err) 312 http.NotFound(w, r) 313 return 314 } 315 defer blob.Close() 316 317 contentBytes, err := io.ReadAll(blob) 318 if err != nil { 319 l.Error("failed to read blob", "err", err) 320 } 321 files[i] = pages.StringFileEditFragmentParams{ 322 Name: file.Name, 323 Content: string(contentBytes), 324 Size: uint64(file.Content.Size), 325 } 326 } 327 } 328 err := s.Pages.EditString(w, pages.EditStringParams{ 329 BaseParams: pages.BaseParamsFromContext(r.Context()), 330 String: oldString, 331 FileParams: files, 332 }) 333 if err != nil { 334 l.Error("failed to render", "err", err) 335 } 336 case http.MethodPost: 337 fail := func(msg string, err error) { 338 l.Error(msg, "err", err) 339 s.Pages.Notice(w, "error", msg) 340 } 341 342 var title *string 343 if val := r.FormValue("title"); val != "" { 344 title = &val 345 } 346 347 var description *string 348 if val := r.FormValue("description"); val != "" { 349 description = &val 350 } 351 352 filename := r.FormValue("filename") 353 if filename == "" { 354 fail("Empty filename.", nil) 355 return 356 } 357 358 content := r.FormValue("content") 359 if content == "" { 360 fail("Empty content.", nil) 361 return 362 } 363 364 client, err := s.OAuth.AuthorizedClient(r) 365 if err != nil { 366 fail("Failed to create record.", err) 367 return 368 } 369 370 blob, err := xrpc.RepoUploadBlob(ctx, client, strings.NewReader(content), textPlain) 371 if err != nil { 372 fail("Failed to create record.", err) 373 return 374 } 375 376 newString := oldString 377 newString.Title = title 378 newString.Description = description 379 newString.Files = []models.String_File{ 380 { 381 Name: filename, 382 Content: *blob.Blob, 383 }, 384 } 385 386 // first replace the existing record in the PDS 387 var exCid string 388 if newString.Cid != nil { 389 exCid = oldString.Cid.String() 390 } else { 391 cid, err := s.getRecordCid(ctx, oldString.AtUri()) 392 if err != nil { 393 s.Pages.Error404(w) 394 return 395 } 396 exCid = cid.String() 397 } 398 resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ 399 Collection: tangled.StringNSID, 400 Repo: newString.Did.String(), 401 Rkey: newString.Rkey.String(), 402 SwapRecord: &exCid, 403 Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 404 }) 405 if err != nil { 406 fail("Failed to updated existing record.", err) 407 return 408 } 409 l = l.With("aturi", resp.Uri) 410 l.Info("edited string") 411 412 newString.Cid = new(syntax.CID) 413 *newString.Cid = syntax.CID(resp.Cid) 414 415 // if that went okay, updated the db 416 if err = db.AddString(s.Db, newString); err != nil { 417 fail("Failed to update string.", err) 418 return 419 } 420 421 s.Notifier.EditString(ctx, &newString) 422 423 // if that went okay, redir to the string 424 s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) 425 } 426 427} 428 429func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 430 l := s.Logger.With("handler", "create") 431 ctx := r.Context() 432 user := s.OAuth.GetMultiAccountUser(r) 433 434 switch r.Method { 435 case http.MethodGet: 436 err := s.Pages.NewString(w, pages.NewStringParams{ 437 BaseParams: pages.BaseParamsFromContext(r.Context()), 438 }) 439 if err != nil { 440 l.Error("failed to render", "err", err) 441 } 442 case http.MethodPost: 443 fail := func(msg string, err error) { 444 l.Error(msg, "err", err) 445 s.Pages.Notice(w, "error", msg) 446 } 447 448 var title *string 449 if val := r.FormValue("title"); val != "" { 450 title = &val 451 } 452 453 var description *string 454 if val := r.FormValue("description"); val != "" { 455 description = &val 456 } 457 458 filename := r.FormValue("filename") 459 if filename == "" { 460 fail("Empty filename.", nil) 461 return 462 } 463 464 content := r.FormValue("content") 465 if content == "" { 466 fail("Empty content.", nil) 467 return 468 } 469 470 client, err := s.OAuth.AuthorizedClient(r) 471 if err != nil { 472 fail("Failed to create record.", err) 473 return 474 } 475 476 blob, err := xrpc.RepoUploadBlob(ctx, client, strings.NewReader(content), textPlain) 477 if err != nil { 478 fail("Failed to create record.", err) 479 return 480 } 481 482 newString := models.String{ 483 Did: syntax.DID(user.Did), 484 Rkey: syntax.RecordKey(tid.TID()), 485 Title: title, 486 Description: description, 487 Files: []models.String_File{ 488 { 489 Name: filename, 490 Content: *blob.Blob, 491 }, 492 }, 493 Created: time.Now(), 494 } 495 496 resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ 497 Collection: tangled.StringNSID, 498 Repo: newString.Did.String(), 499 Rkey: newString.Rkey.String(), 500 Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 501 }) 502 if err != nil { 503 fail("Failed to create record.", err) 504 return 505 } 506 l := l.With("aturi", resp.Uri) 507 l.Info("created record", "files", len(newString.Files)) 508 509 // insert into DB 510 if err = db.AddString(s.Db, newString); err != nil { 511 fail("Failed to create string.", err) 512 return 513 } 514 515 s.Notifier.NewString(ctx, &newString) 516 517 // successful 518 s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) 519 } 520} 521 522func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 523 l := s.Logger.With("handler", "delete") 524 user := s.OAuth.GetMultiAccountUser(r) 525 fail := func(msg string, err error) { 526 l.Error(msg, "err", err) 527 s.Pages.Notice(w, "error", msg) 528 } 529 530 id, ok := r.Context().Value("resolvedId").(identity.Identity) 531 if !ok { 532 l.Error("malformed middleware") 533 w.WriteHeader(http.StatusInternalServerError) 534 return 535 } 536 l = l.With("did", id.DID, "handle", id.Handle) 537 538 rkey := chi.URLParam(r, "rkey") 539 if rkey == "" { 540 l.Error("malformed url, empty rkey") 541 w.WriteHeader(http.StatusBadRequest) 542 return 543 } 544 545 if user.Did != id.DID.String() { 546 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 547 return 548 } 549 550 client, err := s.OAuth.AuthorizedClient(r) 551 if err != nil { 552 fail("Failed to authorize client.", err) 553 return 554 } 555 556 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 557 Collection: tangled.StringNSID, 558 Repo: user.Did, 559 Rkey: rkey, 560 }) 561 if err != nil { 562 fail("Failed to delete string record from PDS.", err) 563 return 564 } 565 566 if err := db.DeleteString( 567 s.Db, 568 orm.FilterEq("did", user.Did), 569 orm.FilterEq("rkey", rkey), 570 ); err != nil { 571 fail("Failed to delete string.", err) 572 return 573 } 574 575 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 576 577 s.Pages.HxRedirect(w, "/strings/"+user.Did) 578} 579 580// FileRaw renders raw file in that specific CID. (strong cache policy) 581func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) { 582 l := s.Logger.With("handler", "FileRaw") 583 ctx := r.Context() 584 585 str, ok := stringFromContext(ctx) 586 if !ok { 587 l.Error("malformed middleware. string missing") 588 s.Pages.Error404(w) 589 return 590 } 591 filename := chi.URLParam(r, "filename") 592 593 if str.IsLegacySingleFile() { 594 if filename != str.FileName { 595 http.NotFound(w, r) 596 return 597 } 598 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 599 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 600 w.Header().Set("Content-Length", strconv.Itoa(len(str.FileContent))) 601 _, err := w.Write([]byte(str.FileContent)) 602 if err != nil { 603 l.Error("failed to write raw response", "err", err) 604 } 605 } else { 606 file, ok := str.FileByName(filename) 607 if !ok { 608 http.NotFound(w, r) 609 return 610 } 611 612 mimeType := file.Content.MimeType 613 size := file.Content.Size 614 615 blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) 616 if err != nil { 617 l.Warn("failed to fetch blob", "err", err) 618 http.NotFound(w, r) 619 return 620 } 621 defer blob.Close() 622 623 w.Header().Set("Content-Type", mimeType) 624 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 625 w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 626 if _, err := io.Copy(w, blob); err != nil { 627 l.Error("failed to write raw response", "err", err) 628 } 629 } 630} 631 632func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams { 633 size := len(content) 634 if size > 8*1024*1024 { // 8 MB 635 // TODO: show "file too big" page 636 } 637 638 buf, _ := io.ReadAll(strings.NewReader(content)) 639 640 var lineCount int 641 var hasNoTrailingEOL bool 642 if size > 0 { 643 hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'}) 644 lineCount = bytes.Count(buf, []byte{'\n'}) 645 if hasNoTrailingEOL { 646 lineCount++ 647 } 648 } 649 650 format := markup.GetFormat(filename) 651 isMarkup := format == markup.FormatMarkdown 652 653 return pages.StringFileFragmentParams{ 654 String: string, 655 Name: filename, 656 Content: content, 657 658 LineCount: lineCount, 659 Size: uint64(size), 660 HasNoTrailingEOL: hasNoTrailingEOL, 661 HasRenderedToggle: isMarkup, 662 ShowingRendered: isMarkup, 663 } 664} 665 666// render each string "file" html fragment 667func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) { 668 l := s.Logger.With("handler", "FileFragment") 669 ctx := r.Context() 670 671 str, ok := stringFromContext(ctx) 672 if !ok { 673 l.Error("malformed middleware. string missing") 674 http.NotFound(w, r) 675 return 676 } 677 filename := chi.URLParam(r, "filename") 678 forceCode := r.URL.Query().Get("code") == "true" 679 680 var params pages.StringFileFragmentParams 681 if str.IsLegacySingleFile() { 682 if filename != str.FileName { 683 http.NotFound(w, r) 684 return 685 } 686 params = s.makeFileFragmentParams(&str, str.FileName, str.FileContent, forceCode) 687 } else { 688 file, ok := str.FileByName(filename) 689 if !ok { 690 l.Error("malformed middleware. string missing") 691 http.NotFound(w, r) 692 return 693 } 694 695 blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) 696 if err != nil { 697 l.Warn("failed to fetch blob", "err", err) 698 http.NotFound(w, r) 699 return 700 } 701 defer blob.Close() 702 703 contentBytes, err := io.ReadAll(blob) 704 if err != nil { 705 l.Error("failed to read blob", "err", err) 706 } 707 708 params = s.makeFileFragmentParams(&str, file.Name, string(contentBytes), forceCode) 709 } 710 s.Pages.StringFileFragment(w, params) 711} 712 713func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) { 714 s.Pages.StringFileEditFragment(w) 715} 716 717func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { 718 ident, err := s.Dir.Lookup(ctx, uri.Authority()) 719 if err != nil { 720 return "", err 721 } 722 723 xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 724 out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 725 if err != nil { 726 return "", err 727 } 728 if out.Cid == nil { 729 return "", fmt.Errorf("record CID is empty") 730 } 731 732 cid, err := syntax.ParseCID(*out.Cid) 733 if err != nil { 734 return "", err 735 } 736 737 return cid, nil 738} 739 740func gz(s string) io.Reader { 741 var b bytes.Buffer 742 w := gzip.NewWriter(&b) 743 w.Write([]byte(s)) 744 w.Close() 745 return &b 746}