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