From 80f5ba4d80adc5fe6ed3040db27bce3fb2c6da39 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 5 Aug 2025 23:06:16 +0100 Subject: [PATCH] appview/strings: init strings router with basic CRUD routes Change-Id: nvtwnurywozqkswltmkvoqknpkktuxtt Signed-off-by: oppiliappan --- appview/state/router.go | 19 ++ appview/strings/strings.go | 449 +++++++++++++++++++++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 appview/strings/strings.go diff --git a/appview/state/router.go b/appview/state/router.go index 6ed6300..490aeaf 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -17,6 +17,7 @@ import ( "tangled.sh/tangled.sh/core/appview/signup" "tangled.sh/tangled.sh/core/appview/spindles" "tangled.sh/tangled.sh/core/appview/state/userutil" + avstrings "tangled.sh/tangled.sh/core/appview/strings" "tangled.sh/tangled.sh/core/log" ) @@ -136,6 +137,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { }) r.Mount("/settings", s.SettingsRouter()) + r.Mount("/strings", s.StringsRouter(mw)) r.Mount("/knots", s.KnotsRouter(mw)) r.Mount("/spindles", s.SpindlesRouter()) r.Mount("/signup", s.SignupRouter()) @@ -201,6 +203,23 @@ func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { return knots.Router(mw) } +func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { + logger := log.New("strings") + + strs := &avstrings.Strings{ + Db: s.db, + OAuth: s.oauth, + Pages: s.pages, + Config: s.config, + Enforcer: s.enforcer, + IdResolver: s.idResolver, + Knotstream: s.knotstream, + Logger: logger, + } + + return strs.Router(mw) +} + func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) return issues.Router(mw) diff --git a/appview/strings/strings.go b/appview/strings/strings.go new file mode 100644 index 0000000..130329f --- /dev/null +++ b/appview/strings/strings.go @@ -0,0 +1,449 @@ +package strings + +import ( + "fmt" + "log/slog" + "net/http" + "path" + "slices" + "strconv" + "strings" + "time" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/appview/config" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/middleware" + "tangled.sh/tangled.sh/core/appview/oauth" + "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/pages/markup" + "tangled.sh/tangled.sh/core/eventconsumer" + "tangled.sh/tangled.sh/core/idresolver" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/tid" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/go-chi/chi/v5" +) + +type Strings struct { + Db *db.DB + OAuth *oauth.OAuth + Pages *pages.Pages + Config *config.Config + Enforcer *rbac.Enforcer + IdResolver *idresolver.Resolver + Logger *slog.Logger + Knotstream *eventconsumer.Consumer +} + +func (s *Strings) Router(mw *middleware.Middleware) http.Handler { + r := chi.NewRouter() + + r. + With(mw.ResolveIdent()). + Route("/{user}", func(r chi.Router) { + r.Get("/", s.dashboard) + + r.Route("/{rkey}", func(r chi.Router) { + r.Get("/", s.contents) + r.Delete("/", s.delete) + r.Get("/raw", s.contents) + r.Get("/edit", s.edit) + r.Post("/edit", s.edit) + r. + With(middleware.AuthMiddleware(s.OAuth)). + Post("/comment", s.comment) + }) + }) + + 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) contents(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "contents") + + 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 + } + l = l.With("rkey", rkey) + + strings, err := db.GetStrings( + s.Db, + db.FilterEq("did", id.DID), + db.FilterEq("rkey", rkey), + ) + if err != nil { + l.Error("failed to fetch string", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if len(strings) != 1 { + l.Error("incorrect number of records returned", "len(strings)", len(strings)) + w.WriteHeader(http.StatusInternalServerError) + return + } + string := strings[0] + + if path.Base(r.URL.Path) == "raw" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if string.Filename != "" { + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) + } + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) + + _, err = w.Write([]byte(string.Contents)) + if err != nil { + l.Error("failed to write raw response", "err", err) + } + return + } + + var showRendered, renderToggle bool + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { + renderToggle = true + showRendered = r.URL.Query().Get("code") != "true" + } + + s.Pages.SingleString(w, pages.SingleStringParams{ + LoggedInUser: s.OAuth.GetUser(r), + RenderToggle: renderToggle, + ShowRendered: showRendered, + String: string, + Stats: string.Stats(), + Owner: id, + }) +} + +func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "dashboard") + + 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) + + all, err := db.GetStrings( + s.Db, + db.FilterEq("did", id.DID), + ) + if err != nil { + l.Error("failed to fetch strings", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + slices.SortFunc(all, func(a, b db.String) int { + if a.Created.After(b.Created) { + return -1 + } else { + return 1 + } + }) + + profile, err := db.GetProfile(s.Db, id.DID.String()) + if err != nil { + l.Error("failed to fetch user profile", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + loggedInUser := s.OAuth.GetUser(r) + followStatus := db.IsNotFollowing + if loggedInUser != nil { + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) + } + + followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) + if err != nil { + l.Error("failed to get follow stats", "err", err) + } + + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ + LoggedInUser: s.OAuth.GetUser(r), + Card: pages.ProfileCard{ + UserDid: id.DID.String(), + UserHandle: id.Handle.String(), + Profile: profile, + FollowStatus: followStatus, + Followers: followers, + Following: following, + }, + Strings: all, + }) +} + +func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "edit") + + user := s.OAuth.GetUser(r) + + 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 + } + l = l.With("rkey", rkey) + + // get the string currently being edited + all, err := db.GetStrings( + s.Db, + db.FilterEq("did", id.DID), + db.FilterEq("rkey", rkey), + ) + if err != nil { + l.Error("failed to fetch string", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if len(all) != 1 { + l.Error("incorrect number of records returned", "len(strings)", len(all)) + w.WriteHeader(http.StatusInternalServerError) + return + } + first := all[0] + + // verify that the logged in user owns this string + if user.Did != id.DID.String() { + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) + w.WriteHeader(http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + // return the form with prefilled fields + s.Pages.PutString(w, pages.PutStringParams{ + LoggedInUser: s.OAuth.GetUser(r), + Action: "edit", + String: first, + }) + case http.MethodPost: + fail := func(msg string, err error) { + l.Error(msg, "err", err) + s.Pages.Notice(w, "error", msg) + } + + filename := r.FormValue("filename") + if filename == "" { + fail("Empty filename.", nil) + return + } + if !strings.Contains(filename, ".") { + // TODO: make this a htmx form validation + fail("No extension provided for filename.", nil) + return + } + + content := r.FormValue("content") + if content == "" { + fail("Empty contents.", nil) + return + } + + description := r.FormValue("description") + + // construct new string from form values + entry := db.String{ + Did: first.Did, + Rkey: first.Rkey, + Filename: filename, + Description: description, + Contents: content, + Created: first.Created, + } + + record := entry.AsRecord() + + client, err := s.OAuth.AuthorizedClient(r) + if err != nil { + fail("Failed to create record.", err) + return + } + + // first replace the existing record in the PDS + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) + if err != nil { + fail("Failed to updated existing record.", err) + return + } + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ + Collection: tangled.StringNSID, + Repo: entry.Did.String(), + Rkey: entry.Rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + if err != nil { + fail("Failed to updated existing record.", err) + return + } + l := l.With("aturi", resp.Uri) + l.Info("edited string") + + // if that went okay, updated the db + if err = db.AddString(s.Db, entry); err != nil { + fail("Failed to update string.", err) + return + } + + // if that went okay, redir to the string + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) + } + +} + +func (s *Strings) create(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "create") + user := s.OAuth.GetUser(r) + + switch r.Method { + case http.MethodGet: + s.Pages.PutString(w, pages.PutStringParams{ + LoggedInUser: s.OAuth.GetUser(r), + Action: "new", + }) + case http.MethodPost: + fail := func(msg string, err error) { + l.Error(msg, "err", err) + s.Pages.Notice(w, "error", msg) + } + + filename := r.FormValue("filename") + if filename == "" { + fail("Empty filename.", nil) + return + } + if !strings.Contains(filename, ".") { + // TODO: make this a htmx form validation + fail("No extension provided for filename.", nil) + return + } + + content := r.FormValue("content") + if content == "" { + fail("Empty contents.", nil) + return + } + + description := r.FormValue("description") + + string := db.String{ + Did: syntax.DID(user.Did), + Rkey: tid.TID(), + Filename: filename, + Description: description, + Contents: content, + Created: time.Now(), + } + + record := string.AsRecord() + + client, err := s.OAuth.AuthorizedClient(r) + if err != nil { + fail("Failed to create record.", err) + return + } + + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ + Collection: tangled.StringNSID, + Repo: user.Did, + Rkey: string.Rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + if err != nil { + fail("Failed to create record.", err) + return + } + l := l.With("aturi", resp.Uri) + l.Info("created record") + + // insert into DB + if err = db.AddString(s.Db, string); err != nil { + fail("Failed to create string.", err) + return + } + + // successful + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) + } +} + +func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "create") + user := s.OAuth.GetUser(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 gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) + return + } + + if err := db.DeleteString( + s.Db, + db.FilterEq("did", user.Did), + db.FilterEq("rkey", rkey), + ); err != nil { + fail("Failed to delete string.", err) + return + } + + s.Pages.HxRedirect(w, "/strings/"+user.Handle) +} + +func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { +} -- 2.43.0