package stringn import ( "bytes" "compress/gzip" "context" "database/sql" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "sync" "time" "golang.org/x/sync/errgroup" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/middleware" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/blobstore" "tangled.org/core/orm" "tangled.org/core/tid" "tangled.org/core/xrpc" "github.com/bluesky-social/indigo/api/agnostic" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "github.com/ipfs/go-cid" comatproto "github.com/bluesky-social/indigo/api/atproto" lexutil "github.com/bluesky-social/indigo/lex/util" ) const textPlain = "text/plain" type Strings struct { Db *db.DB OAuth *oauth.OAuth Pages *pages.Pages Dir identity.Directory BlobStore blobstore.BlobStore Logger *slog.Logger Notifier notify.Notifier } func (s *Strings) Router(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() r. Get("/", s.timeline) r. Get("/fileEdit", s.FileEditFragment) r. Route("/{user}", func(r chi.Router) { r.Get("/", s.dashboard) r.Route("/{rkey}", func(r chi.Router) { r.Use(mw.ResolveIdent()) r.Use(s.resolveString) r.Get("/", s.SingleString) r.Delete("/", s.delete) r.Get("/edit", s.edit) r.Post("/edit", s.edit) r.Get("/{cid}/{filename}", s.FileFragment) r.Get("/{cid}/{filename}/raw", s.FileRaw) // legacy endpoint r.Get("/raw", s.redirectToFirstFileRaw) }) }) r. With(middleware.AuthMiddleware(s.OAuth)). Route("/new", func(r chi.Router) { r.Get("/", s.create) r.Post("/", s.create) }) return r } func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) } func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "timeline") strings, err := db.GetStrings(s.Db, 50) if err != nil { l.Error("failed to fetch string", "err", err) w.WriteHeader(http.StatusInternalServerError) return } s.Pages.StringsTimeline(w, pages.StringTimelineParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), Strings: strings, }) } type stringCtxKey struct{} func (s *Strings) resolveString(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("middleware", "resolveString") rkey := chi.URLParam(r, "rkey") id, ok := r.Context().Value("resolvedId").(identity.Identity) if !ok { l.Error("malformed middleware") w.WriteHeader(http.StatusInternalServerError) return } str, err := db.GetString(s.Db, orm.FilterEq("did", id.DID), orm.FilterEq("rkey", rkey)) if errors.Is(err, sql.ErrNoRows) { s.Pages.Error404(w) return } else if err != nil { l.Error("failed to fetch string", "err", err) w.WriteHeader(http.StatusInternalServerError) return } ctx := context.WithValue(r.Context(), stringCtxKey{}, str) next.ServeHTTP(w, r.WithContext(ctx)) }) } func stringFromContext(ctx context.Context) (models.String, bool) { str, ok := ctx.Value(stringCtxKey{}).(models.String) return str, ok } // redirectToFirstFileRaw is a handle for legacy endpoint. // It redirects /strings/{did}/{rkey}/raw to /strings/{did}/{rkey}/{cid}/{filename}/raw func (s *Strings) redirectToFirstFileRaw(w http.ResponseWriter, r *http.Request) { str, ok := stringFromContext(r.Context()) if !ok { s.Logger.Error("malformed middleware. string missing") s.Pages.Error404(w) return } var cid syntax.CID var filename string if str.Cid != nil { cid = *str.Cid } else { var err error cid, err = s.getRecordCid(r.Context(), str.AtUri()) if err != nil { s.Pages.Error404(w) return } } if str.IsLegacySingleFile() { filename = str.FileName } else { filename = str.Files[0].Name } http.Redirect(w, r, fmt.Sprintf("/strings/%s/%s/%s/%s/raw", str.Did, str.Rkey, cid, filename), http.StatusFound) } func (s *Strings) SingleString(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "SingleString") ctx := r.Context() str, ok := stringFromContext(ctx) if !ok { l.Error("malformed middleware. string missing") s.Pages.Error404(w) return } starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, str.AtUri().String()) if err != nil { l.Error("failed to get star count", "err", err) } user := s.OAuth.GetMultiAccountUser(r) isStarred := false if user != nil { isStarred = db.GetStarStatus(s.Db, user.Did, str.AtUri().String()) } comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", str.AtUri())) if err != nil { l.Error("failed to get comments", "err", err) } var entities []syntax.ATURI for _, c := range comments { entities = append(entities, c.AtUri()) } reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20) if err != nil { l.Error("failed to get reactions", "err", err) } var userReactions map[syntax.ATURI]map[models.ReactionKind]bool if user != nil { userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Did)) if err != nil { l.Error("failed to get user reactions", "err", err) } } vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) if user != nil { var participants []syntax.DID for _, c := range comments { participants = append(participants, c.Did) } vouchRelationships, err = db.GetVouchRelationshipsBatch(s.Db, syntax.DID(user.Did), participants) if err != nil { l.Error("failed to fetch vouch relationships", "err", err) } } var files []pages.StringFileFragmentParams if str.IsLegacySingleFile() { files = []pages.StringFileFragmentParams{ s.makeFileFragmentParams(&str, str.FileName, str.FileContent, false), } } else { files = make([]pages.StringFileFragmentParams, len(str.Files)) for i, file := range str.Files { blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) if err != nil { l.Warn("failed to fetch blob", "err", err) http.NotFound(w, r) return } defer blob.Close() contentBytes, err := io.ReadAll(blob) if err != nil { l.Error("failed to read blob", "err", err) } files[i] = s.makeFileFragmentParams(&str, file.Name, string(contentBytes), false) } } err = s.Pages.SingleString(w, pages.SingleStringParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), String: &str, FileParams: files, IsStarred: isStarred, StarCount: starCount, CommentList: models.NewCommentList(comments), Reactions: reactions, UserReacted: userReactions, VouchRelationships: vouchRelationships, }) if err != nil { l.Error("failed to render", "err", err) } } func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "edit") ctx := r.Context() user := s.OAuth.GetMultiAccountUser(r) oldString, ok := stringFromContext(ctx) if !ok { l.Error("malformed middleware. string missing") s.Pages.Error404(w) return } // verify that the logged in user owns this string if user.Did != oldString.Did.String() { l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did) w.WriteHeader(http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: // return the form with prefilled fields var files []pages.StringFileEditFragmentParams if oldString.IsLegacySingleFile() { files = []pages.StringFileEditFragmentParams{ { Name: oldString.FileName, Content: oldString.FileContent, Size: uint64(len(oldString.FileContent)), }, } } else { files = make([]pages.StringFileEditFragmentParams, len(oldString.Files)) for i, file := range oldString.Files { blob, err := s.BlobStore.GetBlob(r.Context(), oldString.Did, cid.Cid(file.Content.Ref)) if err != nil { l.Warn("failed to fetch blob", "err", err) http.NotFound(w, r) return } defer blob.Close() contentBytes, err := io.ReadAll(blob) if err != nil { l.Error("failed to read blob", "err", err) } files[i] = pages.StringFileEditFragmentParams{ Name: file.Name, Content: string(contentBytes), Size: uint64(file.Content.Size), } } } err := s.Pages.EditString(w, pages.EditStringParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), String: oldString, FileParams: files, }) if err != nil { l.Error("failed to render", "err", err) } case http.MethodPost: fail := func(msg string, err error) { l.Error(msg, "err", err) s.Pages.Notice(w, "error", msg) } var title *string if val := r.FormValue("title"); val != "" { title = &val } var description *string if val := r.FormValue("description"); val != "" { description = &val } fileNames := r.Form["filename"] fileContents := r.Form["content"] if len(fileNames) != len(fileContents) { s.Pages.Notice(w, "error", "mismatched file name & content lengths") return } client, err := s.OAuth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) s.Pages.Notice(w, "error", "Failed to create string.") return } files := make([]models.String_File, len(fileNames)) var mu sync.Mutex g, gctx := errgroup.WithContext(ctx) g.SetLimit(10) for i := range len(files) { g.Go(func() error { blob, err := xrpc.RepoUploadBlob(gctx, client, strings.NewReader(fileContents[i]), textPlain) if err != nil { return err } if blob.Blob == nil { return fmt.Errorf("uploadBlob: blob is missing from response") } mu.Lock() files[i] = models.String_File{ Name: fileNames[i], Content: *blob.Blob, } mu.Unlock() return nil }) } if err := g.Wait(); err != nil { l.Warn("failed to upload blobs", "err", err) s.Pages.Notice(w, "error", "Failed to upload blobs.") return } newString := oldString newString.Title = title newString.Description = description newString.Files = files // first replace the existing record in the PDS var exCid string if newString.Cid != nil { exCid = oldString.Cid.String() } else { cid, err := s.getRecordCid(ctx, oldString.AtUri()) if err != nil { s.Pages.Error404(w) return } exCid = cid.String() } resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ Collection: tangled.StringNSID, Repo: newString.Did.String(), Rkey: newString.Rkey.String(), SwapRecord: &exCid, Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, }) if err != nil { fail("Failed to updated existing record.", err) return } l = l.With("aturi", resp.Uri) l.Info("edited string") newString.Cid = new(syntax.CID) *newString.Cid = syntax.CID(resp.Cid) // if that went okay, updated the db if err = db.AddString(s.Db, newString); err != nil { l.Error("db: failed to update string", "err", err) } s.Notifier.EditString(ctx, &newString) // if that went okay, redir to the string s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) } } func (s *Strings) create(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "create") ctx := r.Context() user := s.OAuth.GetMultiAccountUser(r) switch r.Method { case http.MethodGet: err := s.Pages.NewString(w, pages.NewStringParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), }) if err != nil { l.Error("failed to render", "err", err) } case http.MethodPost: if err := r.ParseForm(); err != nil { s.Pages.Notice(w, "error", "bad request") return } var title *string if val := r.FormValue("title"); val != "" { title = &val } var description *string if val := r.FormValue("description"); val != "" { description = &val } fileNames := r.Form["filename"] fileContents := r.Form["content"] if len(fileNames) != len(fileContents) { s.Pages.Notice(w, "error", "mismatched file name & content lengths") return } client, err := s.OAuth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) s.Pages.Notice(w, "error", "Failed to create string.") return } files := make([]models.String_File, len(fileNames)) var mu sync.Mutex g, gctx := errgroup.WithContext(ctx) g.SetLimit(10) for i := range len(files) { g.Go(func() error { blob, err := xrpc.RepoUploadBlob(gctx, client, strings.NewReader(fileContents[i]), textPlain) if err != nil { return err } if blob.Blob == nil { return fmt.Errorf("uploadBlob: blob is missing from response") } mu.Lock() files[i] = models.String_File{ Name: fileNames[i], Content: *blob.Blob, } mu.Unlock() return nil }) } if err := g.Wait(); err != nil { l.Warn("failed to upload blobs", "err", err) s.Pages.Notice(w, "error", "Failed to upload blobs.") return } newString := models.String{ Did: syntax.DID(user.Did), Rkey: syntax.RecordKey(tid.TID()), Title: title, Description: description, Files: files, Created: time.Now(), } resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ Collection: tangled.StringNSID, Repo: newString.Did.String(), Rkey: newString.Rkey.String(), Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, }) if err != nil { l.Error("failed to create string", "err", err) s.Pages.Notice(w, "error", "Failed to create string.") return } l := l.With("aturi", resp.Uri) l.Info("created record", "files", len(newString.Files)) // insert into DB if err = db.AddString(s.Db, newString); err != nil { l.Error("db: failed to insert string", "err", err) } s.Notifier.NewString(ctx, &newString) // successful s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) } } func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "delete") user := s.OAuth.GetMultiAccountUser(r) fail := func(msg string, err error) { l.Error(msg, "err", err) s.Pages.Notice(w, "error", msg) } id, ok := r.Context().Value("resolvedId").(identity.Identity) if !ok { l.Error("malformed middleware") w.WriteHeader(http.StatusInternalServerError) return } l = l.With("did", id.DID, "handle", id.Handle) rkey := chi.URLParam(r, "rkey") if rkey == "" { l.Error("malformed url, empty rkey") w.WriteHeader(http.StatusBadRequest) return } if user.Did != id.DID.String() { fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) return } client, err := s.OAuth.AuthorizedClient(r) if err != nil { fail("Failed to authorize client.", err) return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.StringNSID, Repo: user.Did, Rkey: rkey, }) if err != nil { fail("Failed to delete string record from PDS.", err) return } if err := db.DeleteString( s.Db, orm.FilterEq("did", user.Did), orm.FilterEq("rkey", rkey), ); err != nil { fail("Failed to delete string.", err) return } s.Notifier.DeleteString(r.Context(), user.Did, rkey) s.Pages.HxRedirect(w, "/strings/"+user.Did) } // FileRaw renders raw file in that specific CID. (strong cache policy) func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "FileRaw") ctx := r.Context() str, ok := stringFromContext(ctx) if !ok { l.Error("malformed middleware. string missing") s.Pages.Error404(w) return } filename := chi.URLParam(r, "filename") if str.IsLegacySingleFile() { if filename != str.FileName { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) w.Header().Set("Content-Length", strconv.Itoa(len(str.FileContent))) _, err := w.Write([]byte(str.FileContent)) if err != nil { l.Error("failed to write raw response", "err", err) } } else { file, ok := str.FileByName(filename) if !ok { http.NotFound(w, r) return } mimeType := file.Content.MimeType size := file.Content.Size blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) if err != nil { l.Warn("failed to fetch blob", "err", err) http.NotFound(w, r) return } defer blob.Close() w.Header().Set("Content-Type", mimeType) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) if _, err := io.Copy(w, blob); err != nil { l.Error("failed to write raw response", "err", err) } } } func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams { size := len(content) if size > 8*1024*1024 { // 8 MB // TODO: show "file too big" page } buf, _ := io.ReadAll(strings.NewReader(content)) var lineCount int var hasNoTrailingEOL bool if size > 0 { hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'}) lineCount = bytes.Count(buf, []byte{'\n'}) if hasNoTrailingEOL { lineCount++ } } format := markup.GetFormat(filename) isMarkup := format == markup.FormatMarkdown return pages.StringFileFragmentParams{ String: string, Name: filename, Content: content, LineCount: lineCount, Size: uint64(size), HasNoTrailingEOL: hasNoTrailingEOL, HasRenderedToggle: isMarkup, ShowingRendered: isMarkup, } } // render each string "file" html fragment func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "FileFragment") ctx := r.Context() str, ok := stringFromContext(ctx) if !ok { l.Error("malformed middleware. string missing") http.NotFound(w, r) return } filename := chi.URLParam(r, "filename") forceCode := r.URL.Query().Get("code") == "true" var params pages.StringFileFragmentParams if str.IsLegacySingleFile() { if filename != str.FileName { http.NotFound(w, r) return } params = s.makeFileFragmentParams(&str, str.FileName, str.FileContent, forceCode) } else { file, ok := str.FileByName(filename) if !ok { l.Error("malformed middleware. string missing") http.NotFound(w, r) return } blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref)) if err != nil { l.Warn("failed to fetch blob", "err", err) http.NotFound(w, r) return } defer blob.Close() contentBytes, err := io.ReadAll(blob) if err != nil { l.Error("failed to read blob", "err", err) } params = s.makeFileFragmentParams(&str, file.Name, string(contentBytes), forceCode) } s.Pages.StringFileFragment(w, params) } func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) { s.Pages.StringFileEditFragment(w) } func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { ident, err := s.Dir.Lookup(ctx, uri.Authority()) if err != nil { return "", err } xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) if err != nil { return "", err } if out.Cid == nil { return "", fmt.Errorf("record CID is empty") } cid, err := syntax.ParseCID(*out.Cid) if err != nil { return "", err } return cid, nil } func gz(s string) io.Reader { var b bytes.Buffer w := gzip.NewWriter(&b) w.Write([]byte(s)) w.Close() return &b }