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 "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}