Monorepo for Tangled tangled.org
9

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 ApplicationGzip = "application/gzip" 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 var content string 241 if file.Gzip != nil { 242 content = file.Gzip.Content 243 } else { 244 blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) 245 if err != nil { 246 l.Warn("failed to fetch blob", "err", err) 247 http.NotFound(w, r) 248 return 249 } 250 defer blob.Close() 251 252 contentBytes, err := io.ReadAll(blob) 253 if err != nil { 254 l.Error("failed to read blob", "err", err) 255 } 256 content = string(contentBytes) 257 } 258 259 files[i] = s.makeFileFragmentParams(&str, file.Name, content, false) 260 } 261 } 262 263 err = s.Pages.SingleString(w, pages.SingleStringParams{ 264 BaseParams: pages.BaseParamsFromContext(r.Context()), 265 String: &str, 266 FileParams: files, 267 IsStarred: isStarred, 268 StarCount: starCount, 269 CommentList: models.NewCommentList(comments), 270 271 Reactions: reactions, 272 UserReacted: userReactions, 273 VouchRelationships: vouchRelationships, 274 }) 275 if err != nil { 276 l.Error("failed to render", "err", err) 277 } 278} 279 280func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 281 l := s.Logger.With("handler", "edit") 282 ctx := r.Context() 283 284 user := s.OAuth.GetMultiAccountUser(r) 285 286 oldString, ok := stringFromContext(ctx) 287 if !ok { 288 l.Error("malformed middleware. string missing") 289 s.Pages.Error404(w) 290 return 291 } 292 293 // verify that the logged in user owns this string 294 if user.Did != oldString.Did.String() { 295 l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did) 296 w.WriteHeader(http.StatusUnauthorized) 297 return 298 } 299 300 switch r.Method { 301 case http.MethodGet: 302 // return the form with prefilled fields 303 var files []pages.StringFileEditFragmentParams 304 if oldString.IsLegacySingleFile() { 305 files = []pages.StringFileEditFragmentParams{ 306 { 307 Name: oldString.FileName, 308 Content: oldString.FileContent, 309 Size: uint64(len(oldString.FileContent)), 310 }, 311 } 312 } else { 313 files = make([]pages.StringFileEditFragmentParams, len(oldString.Files)) 314 for i, file := range oldString.Files { 315 var content string 316 if file.Gzip != nil { 317 content = file.Gzip.Content 318 } else { 319 blob, err := s.BlobStore.GetBlob(r.Context(), oldString.Did, cid.Cid(file.Content.Ref)) 320 if err != nil { 321 l.Warn("failed to fetch blob", "err", err) 322 http.NotFound(w, r) 323 return 324 } 325 defer blob.Close() 326 327 contentBytes, err := io.ReadAll(blob) 328 if err != nil { 329 l.Error("failed to read blob", "err", err) 330 } 331 content = string(contentBytes) 332 } 333 files[i] = pages.StringFileEditFragmentParams{ 334 Name: file.Name, 335 Content: content, 336 Size: uint64(file.Content.Size), 337 } 338 } 339 } 340 err := s.Pages.EditString(w, pages.EditStringParams{ 341 BaseParams: pages.BaseParamsFromContext(r.Context()), 342 String: oldString, 343 FileParams: files, 344 }) 345 if err != nil { 346 l.Error("failed to render", "err", err) 347 } 348 case http.MethodPost: 349 fail := func(msg string, err error) { 350 l.Error(msg, "err", err) 351 s.Pages.Notice(w, "error", msg) 352 } 353 354 var title *string 355 if val := r.FormValue("title"); val != "" { 356 title = &val 357 } 358 359 var description *string 360 if val := r.FormValue("description"); val != "" { 361 description = &val 362 } 363 364 filename := r.FormValue("filename") 365 if filename == "" { 366 fail("Empty filename.", nil) 367 return 368 } 369 370 content := r.FormValue("content") 371 if content == "" { 372 fail("Empty content.", nil) 373 return 374 } 375 376 client, err := s.OAuth.AuthorizedClient(r) 377 if err != nil { 378 fail("Failed to create record.", err) 379 return 380 } 381 382 blob, err := xrpc.RepoUploadBlob(ctx, client, gz(content), ApplicationGzip) 383 if err != nil { 384 fail("Failed to create record.", err) 385 return 386 } 387 388 newString := oldString 389 newString.Title = title 390 newString.Description = description 391 newString.Files = []models.String_File{ 392 { 393 Name: filename, 394 Content: *blob.Blob, 395 Gzip: &models.String_GzipInfo{ 396 String_File_Gzip: tangled.String_File_Gzip{ 397 RealMime: "text/plain", 398 RealSize: int64(len(content)), 399 }, 400 Content: content, 401 }, 402 }, 403 } 404 405 // first replace the existing record in the PDS 406 var exCid string 407 if newString.Cid != nil { 408 exCid = oldString.Cid.String() 409 } else { 410 cid, err := s.getRecordCid(ctx, oldString.AtUri()) 411 if err != nil { 412 s.Pages.Error404(w) 413 return 414 } 415 exCid = cid.String() 416 } 417 resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ 418 Collection: tangled.StringNSID, 419 Repo: newString.Did.String(), 420 Rkey: newString.Rkey.String(), 421 SwapRecord: &exCid, 422 Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 423 }) 424 if err != nil { 425 fail("Failed to updated existing record.", err) 426 return 427 } 428 l = l.With("aturi", resp.Uri) 429 l.Info("edited string") 430 431 newString.Cid = new(syntax.CID) 432 *newString.Cid = syntax.CID(resp.Cid) 433 434 // if that went okay, updated the db 435 if err = db.AddString(s.Db, newString); err != nil { 436 fail("Failed to update string.", err) 437 return 438 } 439 440 s.Notifier.EditString(ctx, &newString) 441 442 // if that went okay, redir to the string 443 s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) 444 } 445 446} 447 448func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 449 l := s.Logger.With("handler", "create") 450 ctx := r.Context() 451 user := s.OAuth.GetMultiAccountUser(r) 452 453 switch r.Method { 454 case http.MethodGet: 455 err := s.Pages.NewString(w, pages.NewStringParams{ 456 BaseParams: pages.BaseParamsFromContext(r.Context()), 457 }) 458 if err != nil { 459 l.Error("failed to render", "err", err) 460 } 461 case http.MethodPost: 462 fail := func(msg string, err error) { 463 l.Error(msg, "err", err) 464 s.Pages.Notice(w, "error", msg) 465 } 466 467 var title *string 468 if val := r.FormValue("title"); val != "" { 469 title = &val 470 } 471 472 var description *string 473 if val := r.FormValue("description"); val != "" { 474 description = &val 475 } 476 477 filename := r.FormValue("filename") 478 if filename == "" { 479 fail("Empty filename.", nil) 480 return 481 } 482 483 content := r.FormValue("content") 484 if content == "" { 485 fail("Empty content.", nil) 486 return 487 } 488 489 client, err := s.OAuth.AuthorizedClient(r) 490 if err != nil { 491 fail("Failed to create record.", err) 492 return 493 } 494 495 blob, err := xrpc.RepoUploadBlob(ctx, client, gz(content), ApplicationGzip) 496 if err != nil { 497 fail("Failed to create record.", err) 498 return 499 } 500 501 newString := models.String{ 502 Did: syntax.DID(user.Did), 503 Rkey: syntax.RecordKey(tid.TID()), 504 Title: title, 505 Description: description, 506 Files: []models.String_File{ 507 { 508 Name: filename, 509 Content: *blob.Blob, 510 Gzip: &models.String_GzipInfo{ 511 String_File_Gzip: tangled.String_File_Gzip{ 512 RealMime: "text/plain", 513 RealSize: int64(len(content)), 514 }, 515 Content: content, 516 }, 517 }, 518 }, 519 Created: time.Now(), 520 } 521 522 resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ 523 Collection: tangled.StringNSID, 524 Repo: newString.Did.String(), 525 Rkey: newString.Rkey.String(), 526 Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 527 }) 528 if err != nil { 529 fail("Failed to create record.", err) 530 return 531 } 532 l := l.With("aturi", resp.Uri) 533 l.Info("created record", "files", len(newString.Files)) 534 535 // insert into DB 536 if err = db.AddString(s.Db, newString); err != nil { 537 fail("Failed to create string.", err) 538 return 539 } 540 541 s.Notifier.NewString(ctx, &newString) 542 543 // successful 544 s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) 545 } 546} 547 548func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 549 l := s.Logger.With("handler", "delete") 550 user := s.OAuth.GetMultiAccountUser(r) 551 fail := func(msg string, err error) { 552 l.Error(msg, "err", err) 553 s.Pages.Notice(w, "error", msg) 554 } 555 556 id, ok := r.Context().Value("resolvedId").(identity.Identity) 557 if !ok { 558 l.Error("malformed middleware") 559 w.WriteHeader(http.StatusInternalServerError) 560 return 561 } 562 l = l.With("did", id.DID, "handle", id.Handle) 563 564 rkey := chi.URLParam(r, "rkey") 565 if rkey == "" { 566 l.Error("malformed url, empty rkey") 567 w.WriteHeader(http.StatusBadRequest) 568 return 569 } 570 571 if user.Did != id.DID.String() { 572 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 573 return 574 } 575 576 client, err := s.OAuth.AuthorizedClient(r) 577 if err != nil { 578 fail("Failed to authorize client.", err) 579 return 580 } 581 582 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 583 Collection: tangled.StringNSID, 584 Repo: user.Did, 585 Rkey: rkey, 586 }) 587 if err != nil { 588 fail("Failed to delete string record from PDS.", err) 589 return 590 } 591 592 if err := db.DeleteString( 593 s.Db, 594 orm.FilterEq("did", user.Did), 595 orm.FilterEq("rkey", rkey), 596 ); err != nil { 597 fail("Failed to delete string.", err) 598 return 599 } 600 601 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 602 603 s.Pages.HxRedirect(w, "/strings/"+user.Did) 604} 605 606// FileRaw renders raw file in that specific CID. (strong cache policy) 607func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) { 608 l := s.Logger.With("handler", "FileRaw") 609 ctx := r.Context() 610 611 str, ok := stringFromContext(ctx) 612 if !ok { 613 l.Error("malformed middleware. string missing") 614 s.Pages.Error404(w) 615 return 616 } 617 filename := chi.URLParam(r, "filename") 618 619 if str.IsLegacySingleFile() { 620 if filename != str.FileName { 621 http.NotFound(w, r) 622 return 623 } 624 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 625 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 626 w.Header().Set("Content-Length", strconv.Itoa(len(str.FileContent))) 627 _, err := w.Write([]byte(str.FileContent)) 628 if err != nil { 629 l.Error("failed to write raw response", "err", err) 630 } 631 } else { 632 file, ok := str.FileByName(filename) 633 if !ok { 634 http.NotFound(w, r) 635 return 636 } 637 638 mimeType := file.Content.MimeType 639 size := file.Content.Size 640 641 var reader io.Reader 642 if file.Gzip != nil { 643 reader = strings.NewReader(file.Gzip.Content) 644 } else { 645 blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) 646 if err != nil { 647 l.Warn("failed to fetch blob", "err", err) 648 http.NotFound(w, r) 649 return 650 } 651 defer blob.Close() 652 reader = blob 653 } 654 655 w.Header().Set("Content-Type", mimeType) 656 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 657 w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 658 if _, err := io.Copy(w, reader); err != nil { 659 l.Error("failed to write raw response", "err", err) 660 } 661 } 662} 663 664func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams { 665 size := len(content) 666 if size > 8*1024*1024 { // 8 MB 667 // TODO: show "file too big" page 668 } 669 670 buf, _ := io.ReadAll(strings.NewReader(content)) 671 672 var lineCount int 673 var hasNoTrailingEOL bool 674 if size > 0 { 675 hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'}) 676 lineCount = bytes.Count(buf, []byte{'\n'}) 677 if hasNoTrailingEOL { 678 lineCount++ 679 } 680 } 681 682 format := markup.GetFormat(filename) 683 isMarkup := format == markup.FormatMarkdown 684 685 return pages.StringFileFragmentParams{ 686 String: string, 687 Name: filename, 688 Content: content, 689 690 LineCount: lineCount, 691 Size: uint64(size), 692 HasNoTrailingEOL: hasNoTrailingEOL, 693 HasRenderedToggle: isMarkup, 694 ShowingRendered: isMarkup, 695 } 696} 697 698// render each string "file" html fragment 699func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) { 700 l := s.Logger.With("handler", "FileFragment") 701 ctx := r.Context() 702 703 str, ok := stringFromContext(ctx) 704 if !ok { 705 l.Error("malformed middleware. string missing") 706 http.NotFound(w, r) 707 return 708 } 709 filename := chi.URLParam(r, "filename") 710 forceCode := r.URL.Query().Get("code") == "true" 711 712 var params pages.StringFileFragmentParams 713 if str.IsLegacySingleFile() { 714 if filename != str.FileName { 715 http.NotFound(w, r) 716 return 717 } 718 params = s.makeFileFragmentParams(&str, str.FileName, str.FileContent, forceCode) 719 } else { 720 file, ok := str.FileByName(filename) 721 if !ok { 722 l.Error("malformed middleware. string missing") 723 http.NotFound(w, r) 724 return 725 } 726 727 var content string 728 if file.Gzip != nil && file.Gzip.Content != "" { 729 content = file.Gzip.Content 730 } else { 731 blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) 732 if err != nil { 733 l.Warn("failed to fetch blob", "err", err) 734 http.NotFound(w, r) 735 return 736 } 737 defer blob.Close() 738 739 contentBytes, err := io.ReadAll(blob) 740 if err != nil { 741 l.Error("failed to read blob", "err", err) 742 } 743 content = string(contentBytes) 744 } 745 746 params = s.makeFileFragmentParams(&str, file.Name, content, forceCode) 747 } 748 s.Pages.StringFileFragment(w, params) 749} 750 751func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) { 752 s.Pages.StringFileEditFragment(w) 753} 754 755func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { 756 ident, err := s.Dir.Lookup(ctx, uri.Authority()) 757 if err != nil { 758 return "", err 759 } 760 761 xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 762 out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 763 if err != nil { 764 return "", err 765 } 766 if out.Cid == nil { 767 return "", fmt.Errorf("record CID is empty") 768 } 769 770 cid, err := syntax.ParseCID(*out.Cid) 771 if err != nil { 772 return "", err 773 } 774 775 return cid, nil 776} 777 778func gz(s string) io.Reader { 779 var b bytes.Buffer 780 w := gzip.NewWriter(&b) 781 w.Write([]byte(s)) 782 w.Close() 783 return &b 784}