Monorepo for Tangled
tangled.org
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}