Monorepo for Tangled
Something went wrong. Try again.
Go
at sl/md-editor
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778package 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}/rawfunc (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 fragmentfunc (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}