Monorepo for Tangled tangled.org
10

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