From 9e13a91f0d2bbedc2da8649189214551c6eb8810 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 5 Aug 2025 16:23:47 +0100 Subject: [PATCH] lexicons: add sh.tangled.string Change-Id: novyxoznpmpvmuoqtxrnzmnskxmquszu Signed-off-by: oppiliappan --- api/tangled/cbor_gen.go | 232 +++++++++++++++++++++++++++++++++++ api/tangled/tangledstring.go | 25 ++++ cmd/gen.go | 1 + lexicons/string/string.json | 40 ++++++ 4 files changed, 298 insertions(+) create mode 100644 api/tangled/tangledstring.go create mode 100644 lexicons/string/string.json diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index a1117f4..eba0337 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -8423,3 +8423,235 @@ func (t *SpindleMember) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *String) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{165}); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { + return err + } + + // t.Contents (string) (string) + if len("contents") > 1000000 { + return xerrors.Errorf("Value in field \"contents\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { + return err + } + if _, err := cw.WriteString(string("contents")); err != nil { + return err + } + + if len(t.Contents) > 1000000 { + return xerrors.Errorf("Value in field t.Contents was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Contents)); err != nil { + return err + } + + // t.Filename (string) (string) + if len("filename") > 1000000 { + return xerrors.Errorf("Value in field \"filename\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { + return err + } + if _, err := cw.WriteString(string("filename")); err != nil { + return err + } + + if len(t.Filename) > 1000000 { + return xerrors.Errorf("Value in field t.Filename was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Filename)); err != nil { + return err + } + + // t.CreatedAt (string) (string) + if len("createdAt") > 1000000 { + return xerrors.Errorf("Value in field \"createdAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("createdAt")); err != nil { + return err + } + + if len(t.CreatedAt) > 1000000 { + return xerrors.Errorf("Value in field t.CreatedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { + return err + } + + // t.Description (string) (string) + if len("description") > 1000000 { + return xerrors.Errorf("Value in field \"description\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { + return err + } + if _, err := cw.WriteString(string("description")); err != nil { + return err + } + + if len(t.Description) > 1000000 { + return xerrors.Errorf("Value in field t.Description was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Description)); err != nil { + return err + } + return nil +} + +func (t *String) UnmarshalCBOR(r io.Reader) (err error) { + *t = String{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("String: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 11) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Contents (string) (string) + case "contents": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Contents = string(sval) + } + // t.Filename (string) (string) + case "filename": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Filename = string(sval) + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + // t.Description (string) (string) + case "description": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Description = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/api/tangled/tangledstring.go b/api/tangled/tangledstring.go new file mode 100644 index 0000000..0644c71 --- /dev/null +++ b/api/tangled/tangledstring.go @@ -0,0 +1,25 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.string + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + StringNSID = "sh.tangled.string" +) + +func init() { + util.RegisterType("sh.tangled.string", &String{}) +} // +// RECORDTYPE: String +type String struct { + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` + Contents string `json:"contents" cborgen:"contents"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + Description string `json:"description" cborgen:"description"` + Filename string `json:"filename" cborgen:"filename"` +} diff --git a/cmd/gen.go b/cmd/gen.go index b484de7..2e07c0e 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -50,6 +50,7 @@ func main() { tangled.RepoPullStatus{}, tangled.Spindle{}, tangled.SpindleMember{}, + tangled.String{}, ); err != nil { panic(err) } diff --git a/lexicons/string/string.json b/lexicons/string/string.json new file mode 100644 index 0000000..9ac59db --- /dev/null +++ b/lexicons/string/string.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "sh.tangled.string", + "needsCbor": true, + "needsType": true, + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "filename", + "description", + "createdAt", + "contents" + ], + "properties": { + "filename": { + "type": "string", + "maxGraphemes": 140, + "minGraphemes": 1 + }, + "description": { + "type": "string", + "maxGraphemes": 280 + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "contents": { + "type": "string", + "minGraphemes": 1 + } + } + } + } + } +} -- 2.43.0 From fb35c50d96179621dedc62b0dfcb56c98fb95d9d Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 5 Aug 2025 23:06:16 +0100 Subject: [PATCH] appview/db: add DB components for strings Change-Id: sronqzwznlqkpmyowrpwkkrxuztzpyoo Signed-off-by: oppiliappan --- appview/db/db.go | 15 +++ appview/db/strings.go | 251 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 appview/db/strings.go diff --git a/appview/db/db.go b/appview/db/db.go index a444375..e18646d 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -443,6 +443,21 @@ func Make(dbPath string) (*DB, error) { created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); + create table if not exists strings ( + -- identifiers + did text not null, + rkey text not null, + + -- content + filename text not null, + description text, + content text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + edited text, + + primary key (did, rkey) + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique diff --git a/appview/db/strings.go b/appview/db/strings.go new file mode 100644 index 0000000..9d0c713 --- /dev/null +++ b/appview/db/strings.go @@ -0,0 +1,251 @@ +package db + +import ( + "bytes" + "database/sql" + "errors" + "fmt" + "io" + "strings" + "time" + "unicode/utf8" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.sh/tangled.sh/core/api/tangled" +) + +type String struct { + Did syntax.DID + Rkey string + + Filename string + Description string + Contents string + Created time.Time + Edited *time.Time +} + +func (s *String) StringAt() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) +} + +type StringStats struct { + LineCount uint64 + ByteCount uint64 +} + +func (s String) Stats() StringStats { + lineCount, err := countLines(strings.NewReader(s.Contents)) + if err != nil { + // non-fatal + // TODO: log this? + } + + return StringStats{ + LineCount: uint64(lineCount), + ByteCount: uint64(len(s.Contents)), + } +} + +func (s String) Validate() error { + var err error + + if !strings.Contains(s.Filename, ".") { + err = errors.Join(err, fmt.Errorf("missing filename extension")) + } + + if strings.HasSuffix(s.Filename, ".") { + err = errors.Join(err, fmt.Errorf("filename ends with `.`")) + } + + if utf8.RuneCountInString(s.Filename) > 140 { + err = errors.Join(err, fmt.Errorf("filename too long")) + } + + if utf8.RuneCountInString(s.Description) > 280 { + err = errors.Join(err, fmt.Errorf("description too long")) + } + + if len(s.Contents) == 0 { + err = errors.Join(err, fmt.Errorf("contents is empty")) + } + + return err +} + +func (s *String) AsRecord() tangled.String { + return tangled.String{ + Filename: s.Filename, + Description: s.Description, + Contents: s.Contents, + CreatedAt: s.Created.Format(time.RFC3339), + } +} + +func StringFromRecord(did, rkey string, record tangled.String) String { + created, err := time.Parse(record.CreatedAt, time.RFC3339) + if err != nil { + created = time.Now() + } + return String{ + Did: syntax.DID(did), + Rkey: rkey, + Filename: record.Filename, + Description: record.Description, + Contents: record.Contents, + Created: created, + } +} + +func AddString(e Execer, s String) error { + _, err := e.Exec( + `insert into strings ( + did, + rkey, + filename, + description, + content, + created, + edited + ) + values (?, ?, ?, ?, ?, ?, null) + on conflict(did, rkey) do update set + filename = excluded.filename, + description = excluded.description, + content = excluded.content, + edited = case + when + strings.content != excluded.content + or strings.filename != excluded.filename + or strings.description != excluded.description then ? + else strings.edited + end`, + s.Did, + s.Rkey, + s.Filename, + s.Description, + s.Contents, + s.Created.Format(time.RFC3339), + time.Now().Format(time.RFC3339), + ) + return err +} + +func GetStrings(e Execer, filters ...filter) ([]String, error) { + var all []String + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(`select + did, + rkey, + filename, + description, + content, + created, + edited + from strings %s`, + whereClause, + ) + + rows, err := e.Query(query, args...) + + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var s String + var createdAt string + var editedAt sql.NullString + + if err := rows.Scan( + &s.Did, + &s.Rkey, + &s.Filename, + &s.Description, + &s.Contents, + &createdAt, + &editedAt, + ); err != nil { + return nil, err + } + + s.Created, err = time.Parse(time.RFC3339, createdAt) + if err != nil { + s.Created = time.Now() + } + + if editedAt.Valid { + e, err := time.Parse(time.RFC3339, editedAt.String) + if err != nil { + e = time.Now() + } + s.Edited = &e + } + + all = append(all, s) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return all, nil +} + +func DeleteString(e Execer, filters ...filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(`delete from strings %s`, whereClause) + + _, err := e.Exec(query, args...) + return err +} + +func countLines(r io.Reader) (int, error) { + buf := make([]byte, 32*1024) + bufLen := 0 + count := 0 + nl := []byte{'\n'} + + for { + c, err := r.Read(buf) + if c > 0 { + bufLen += c + } + count += bytes.Count(buf[:c], nl) + + switch { + case err == io.EOF: + /* handle last line not having a newline at the end */ + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { + count++ + } + return count, nil + case err != nil: + return 0, err + } + } +} -- 2.43.0 From 60e00f363d7a0a54c7db4955b899e09a054bd758 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 From 146a7ba1002e6b1690f96383463a4c5887075e38 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 5 Aug 2025 23:06:16 +0100 Subject: [PATCH] appview/pages: add templates to render strings Change-Id: tlrppkxrmplnvtlnrnwxvytwuwxqsnvs Signed-off-by: oppiliappan --- appview/pages/pages.go | 74 ++++++++++++++++++ appview/pages/templates/strings/string.html | 85 +++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 appview/pages/templates/strings/string.html diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 6785bff..b3b6dde 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -31,6 +31,7 @@ import ( chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" @@ -1142,6 +1143,79 @@ func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { return p.executeRepo("repo/pipelines/workflow", w, params) } +type PutStringParams struct { + LoggedInUser *oauth.User + Action string + + // this is supplied in the case of editing an existing string + String db.String +} + +func (p *Pages) PutString(w io.Writer, params PutStringParams) error { + return p.execute("strings/put", w, params) +} + +type StringsDashboardParams struct { + LoggedInUser *oauth.User + Card ProfileCard + Strings []db.String +} + +func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { + return p.execute("strings/dashboard", w, params) +} + +type SingleStringParams struct { + LoggedInUser *oauth.User + ShowRendered bool + RenderToggle bool + RenderedContents template.HTML + String db.String + Stats db.StringStats + Owner identity.Identity +} + +func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { + var style *chroma.Style = styles.Get("catpuccin-latte") + + if params.ShowRendered { + switch markup.GetFormat(params.String.Filename) { + case markup.FormatMarkdown: + p.rctx.RendererType = markup.RendererTypeDefault + htmlString := p.rctx.RenderMarkdown(params.String.Contents) + params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) + } + } + + c := params.String.Contents + formatter := chromahtml.New( + chromahtml.InlineCode(false), + chromahtml.WithLineNumbers(true), + chromahtml.WithLinkableLineNumbers(true, "L"), + chromahtml.Standalone(false), + chromahtml.WithClasses(true), + ) + + lexer := lexers.Get(filepath.Base(params.String.Filename)) + if lexer == nil { + lexer = lexers.Fallback + } + + iterator, err := lexer.Tokenise(nil, c) + if err != nil { + return fmt.Errorf("chroma tokenize: %w", err) + } + + var code bytes.Buffer + err = formatter.Format(&code, style, iterator) + if err != nil { + return fmt.Errorf("chroma format: %w", err) + } + + params.String.Contents = code.String() + return p.execute("strings/string", w, params) +} + func (p *Pages) Static() http.Handler { if p.dev { return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) diff --git a/appview/pages/templates/strings/string.html b/appview/pages/templates/strings/string.html new file mode 100644 index 0000000..239b082 --- /dev/null +++ b/appview/pages/templates/strings/string.html @@ -0,0 +1,85 @@ +{{ define "title" }}{{ .String.Filename }} · by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} + +{{ define "extrameta" }} + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} + + + + +{{ end }} + +{{ define "topbar" }} + {{ template "layouts/topbar" $ }} +{{ end }} + +{{ define "content" }} +{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} +
+
+ + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} +
+ + {{ i "pencil" "size-4" }} + + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} + + +
+ {{ end }} +
+ + {{ with .String.Description }} + {{ . }} + + {{ end }} + + {{ with .String.Edited }} + edited {{ template "repo/fragments/shortTimeAgo" . }} + {{ else }} + {{ template "repo/fragments/shortTimeAgo" .String.Created }} + {{ end }} + +
+
+
+ {{ .String.Filename }} +
+ {{ .Stats.LineCount }} lines + + {{ byteFmt .Stats.ByteCount }} + + view raw + {{ if .RenderToggle }} + + + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} + + {{ end }} +
+
+
+ {{ if .ShowRendered }} +
{{ .RenderedContents }}
+ {{ else }} +
{{ .String.Contents | escapeHtml }}
+ {{ end }} +
+
+{{ end }} -- 2.43.0 From e42d1feb23e010d9a3dccda7439510615e73bd74 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 6 Aug 2025 15:15:45 +0100 Subject: [PATCH] appview/ingester: ingest sh.tangled.string from the firehose Change-Id: potvrpwlpwslknmymowyuyursnprxlry Signed-off-by: oppiliappan --- appview/ingester.go | 56 ++++++++++++ appview/middleware/middleware.go | 12 +-- .../templates/strings/fragments/form.html | 89 +++++++++++++++++++ appview/pages/templates/strings/put.html | 17 ++++ appview/state/router.go | 3 - appview/state/state.go | 1 + knotserver/file.go | 8 -- 7 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 appview/pages/templates/strings/fragments/form.html create mode 100644 appview/pages/templates/strings/put.html diff --git a/appview/ingester.go b/appview/ingester.go index 7a3c22a..adc2179 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -64,6 +64,8 @@ func (i *Ingester) Ingest() processFunc { err = i.ingestSpindleMember(e) case tangled.SpindleNSID: err = i.ingestSpindle(e) + case tangled.StringNSID: + err = i.ingestString(e) } l = i.Logger.With("nsid", e.Commit.Collection) } @@ -549,3 +551,57 @@ func (i *Ingester) ingestSpindle(e *models.Event) error { return nil } + +func (i *Ingester) ingestString(e *models.Event) error { + did := e.Did + rkey := e.Commit.RKey + + var err error + + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) + l.Info("ingesting record") + + ddb, ok := i.Db.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index string record, invalid db cast") + } + + switch e.Commit.Operation { + case models.CommitOperationCreate, models.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.String{} + err = json.Unmarshal(raw, &record) + if err != nil { + l.Error("invalid record", "err", err) + return err + } + + string := db.StringFromRecord(did, rkey, record) + + if err = string.Validate(); err != nil { + l.Error("invalid record", "err", err) + return err + } + + if err = db.AddString(ddb, string); err != nil { + l.Error("failed to add string", "err", err) + return err + } + + return nil + + case models.CommitOperationDelete: + if err := db.DeleteString( + ddb, + db.FilterEq("did", did), + db.FilterEq("rkey", rkey), + ); err != nil { + l.Error("failed to delete", "err", err) + return fmt.Errorf("failed to delete string record: %w", err) + } + + return nil + } + + return nil +} diff --git a/appview/middleware/middleware.go b/appview/middleware/middleware.go index e427eb2..2d63282 100644 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -167,16 +167,6 @@ func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFun } } -func StripLeadingAt(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - path := req.URL.EscapedPath() - if strings.HasPrefix(path, "/@") { - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") - } - next.ServeHTTP(w, req) - }) -} - func (mw Middleware) ResolveIdent() middlewareFunc { excluded := []string{"favicon.ico"} @@ -188,6 +178,8 @@ func (mw Middleware) ResolveIdent() middlewareFunc { return } + didOrHandle = strings.TrimPrefix(didOrHandle, "@") + id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) if err != nil { // invalid did or handle diff --git a/appview/pages/templates/strings/fragments/form.html b/appview/pages/templates/strings/fragments/form.html new file mode 100644 index 0000000..2badb23 --- /dev/null +++ b/appview/pages/templates/strings/fragments/form.html @@ -0,0 +1,89 @@ +{{ define "strings/fragments/form" }} +
+
+ + +
+ +
+
+ 0 lines + + 0 bytes +
+
+ {{ if eq .Action "edit" }} + + {{ i "x" "size-4" }} + + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} + + {{ end }} + +
+
+ +
+
+{{ end }} diff --git a/appview/pages/templates/strings/put.html b/appview/pages/templates/strings/put.html new file mode 100644 index 0000000..a086715 --- /dev/null +++ b/appview/pages/templates/strings/put.html @@ -0,0 +1,17 @@ +{{ define "title" }}publish a new string{{ end }} + +{{ define "topbar" }} + {{ template "layouts/topbar" $ }} +{{ end }} + +{{ define "content" }} +
+ {{ if eq .Action "new" }} +

Create a new string

+

Store and share code snippets with ease.

+ {{ else }} +

Edit string

+ {{ end }} +
+ {{ template "strings/fragments/form" . }} +{{ end }} diff --git a/appview/state/router.go b/appview/state/router.go index 490aeaf..af9ff99 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -68,9 +68,6 @@ func (s *State) Router() http.Handler { func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() - // strip @ from user - r.Use(middleware.StripLeadingAt) - r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { r.Get("/", s.Profile) diff --git a/appview/state/state.go b/appview/state/state.go index 4696c2c..484fcfa 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -93,6 +93,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { tangled.ActorProfileNSID, tangled.SpindleMemberNSID, tangled.SpindleNSID, + tangled.StringNSID, }, nil, slog.Default(), diff --git a/knotserver/file.go b/knotserver/file.go index 8a5c029..5da5171 100644 --- a/knotserver/file.go +++ b/knotserver/file.go @@ -10,13 +10,6 @@ import ( "tangled.sh/tangled.sh/core/types" ) -func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { - data["files"] = files - - writeJSON(w, data) - return -} - func countLines(r io.Reader) (int, error) { buf := make([]byte, 32*1024) bufLen := 0 @@ -52,5 +45,4 @@ func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l resp.Lines = lc writeJSON(w, resp) - return } -- 2.43.0 From 476adb98d83ebbf3d59a34c3d3b8713fba7f046d Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Thu, 7 Aug 2025 09:59:25 +0100 Subject: [PATCH] appview/pages: add dashboard to strings page Change-Id: wxnkzrtrmutqotnuywurqyvrnyxznyrz Signed-off-by: oppiliappan --- appview/pages/pages.go | 1 - .../pages/templates/strings/dashboard.html | 57 +++++++++++++++++++ .../templates/user/fragments/profileCard.html | 4 +- appview/state/profile.go | 16 ------ 4 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 appview/pages/templates/strings/dashboard.html diff --git a/appview/pages/pages.go b/appview/pages/pages.go index b3b6dde..c0d27e3 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -414,7 +414,6 @@ type ProfileCard struct { UserDid string UserHandle string FollowStatus db.FollowStatus - AvatarUri string Followers int Following int diff --git a/appview/pages/templates/strings/dashboard.html b/appview/pages/templates/strings/dashboard.html new file mode 100644 index 0000000..e7ab587 --- /dev/null +++ b/appview/pages/templates/strings/dashboard.html @@ -0,0 +1,57 @@ +{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} + +{{ define "extrameta" }} + + + + +{{ end }} + + +{{ define "content" }} +
+
+ {{ template "user/fragments/profileCard" .Card }} +
+
+ {{ block "allStrings" . }}{{ end }} +
+
+{{ end }} + +{{ define "allStrings" }} +

ALL STRINGS

+
+ {{ range .Strings }} + {{ template "singleString" (list $ .) }} + {{ else }} +

This user does not have any strings yet.

+ {{ end }} +
+{{ end }} + +{{ define "singleString" }} + {{ $root := index . 0 }} + {{ $s := index . 1 }} +
+ + {{ with $s.Description }} +
+ {{ . }} +
+ {{ end }} + + {{ $stat := $s.Stats }} +
+ {{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}} + + {{ with $s.Edited }} + edited {{ template "repo/fragments/shortTimeAgo" . }} + {{ else }} + {{ template "repo/fragments/shortTimeAgo" $s.Created }} + {{ end }} +
+
+{{ end }} diff --git a/appview/pages/templates/user/fragments/profileCard.html b/appview/pages/templates/user/fragments/profileCard.html index 61ef21a..4e5dd46 100644 --- a/appview/pages/templates/user/fragments/profileCard.html +++ b/appview/pages/templates/user/fragments/profileCard.html @@ -2,11 +2,9 @@
- {{ if .AvatarUri }}
- +
- {{ end }}

Date: Thu, 7 Aug 2025 11:03:23 +0100 Subject: [PATCH] appview/pages: rework topbar to incorporate strings Change-Id: nqxqwmkzuvwwstnmpqqwsxwwxosrokum Signed-off-by: oppiliappan --- appview/pages/templates/layouts/topbar.html | 23 +++++++++-- flake.lock | 44 ++++++++++----------- flake.nix | 2 +- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/appview/pages/templates/layouts/topbar.html b/appview/pages/templates/layouts/topbar.html index 36878eb..9678336 100644 --- a/appview/pages/templates/layouts/topbar.html +++ b/appview/pages/templates/layouts/topbar.html @@ -9,9 +9,7 @@

{{ with .LoggedInUser }} - - {{ i "plus" "w-4 h-4" }} - + {{ block "newButton" . }} {{ end }} {{ block "dropDown" . }} {{ end }} {{ else }} login @@ -21,6 +19,24 @@ {{ end }} +{{ define "newButton" }} +
+ + {{ i "plus" "w-4 h-4" }} new + + +
+{{ end }} + {{ define "dropDown" }}
profile repositories + strings knots spindles settings diff --git a/flake.lock b/flake.lock index 238971e..db4dc57 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ @@ -20,24 +38,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1694529238, - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "gomod2nix": { "inputs": { "flake-utils": "flake-utils", @@ -128,14 +128,14 @@ "lucide-src": { "flake": false, "locked": { - "lastModified": 1742302029, - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", + "lastModified": 1754044466, + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", "type": "tarball", - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" }, "original": { "type": "tarball", - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" } }, "nixpkgs": { diff --git a/flake.nix b/flake.nix index d01c9f7..d797e37 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ flake = false; }; lucide-src = { - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; flake = false; }; inter-fonts-src = { -- 2.43.0 From 630334f0622084f3c43da92f30b5ed08e18cdc5b Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Fri, 8 Aug 2025 11:38:39 +0300 Subject: [PATCH] appview/oauth: add to default spindle Change-Id: lyrpkknpnrusvsnrrmnzuzyzoqylxwsq Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 3 + appview/oauth/handler/handler.go | 126 +++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/appview/config/config.go b/appview/config/config.go index 3d50ba8..213e343 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -16,6 +16,9 @@ type CoreConfig struct { AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` Dev bool `env:"DEV, default=false"` DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` + + // temporarily, to add users to default spindle + AppPassword string `env:"APP_PASSWORD"` } type OAuthConfig struct { diff --git a/appview/oauth/handler/handler.go b/appview/oauth/handler/handler.go index d9d23a7..2119985 100644 --- a/appview/oauth/handler/handler.go +++ b/appview/oauth/handler/handler.go @@ -1,18 +1,22 @@ package oauth import ( + "bytes" + "context" "encoding/json" "fmt" "log" "net/http" "net/url" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/gorilla/sessions" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/posthog/posthog-go" "tangled.sh/icyphox.sh/atproto-oauth/helpers" + tangled "tangled.sh/tangled.sh/core/api/tangled" sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" "tangled.sh/tangled.sh/core/appview/config" "tangled.sh/tangled.sh/core/appview/db" @@ -23,6 +27,7 @@ import ( "tangled.sh/tangled.sh/core/idresolver" "tangled.sh/tangled.sh/core/knotclient" "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/tid" ) const ( @@ -294,6 +299,7 @@ func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { log.Println("session saved successfully") go o.addToDefaultKnot(oauthRequest.Did) + go o.addToDefaultSpindle(oauthRequest.Did) if !o.config.Core.Dev { err = o.posthog.Enqueue(posthog.Capture{ @@ -332,6 +338,126 @@ func pubKeyFromJwk(jwks string) (jwk.Key, error) { return pubKey, nil } +func (o *OAuthHandler) addToDefaultSpindle(did string) { + // use the tangled.sh app password to get an accessJwt + // and create an sh.tangled.spindle.member record with that + + defaultSpindle := "spindle.tangled.sh" + appPassword := o.config.Core.AppPassword + + // TODO: hardcoded tangled handle and did for now + tangledHandle := "tangled.sh" + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" + + if appPassword == "" { + log.Println("no app password configured, skipping spindle member addition") + return + } + + log.Printf("adding %s to default spindle", did) + + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) + if err != nil { + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) + return + } + + pdsEndpoint := resolved.PDSEndpoint() + if pdsEndpoint == "" { + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) + return + } + + sessionPayload := map[string]string{ + "identifier": tangledHandle, + "password": appPassword, + } + sessionBytes, err := json.Marshal(sessionPayload) + if err != nil { + log.Printf("failed to marshal session payload: %v", err) + return + } + + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) + if err != nil { + log.Printf("failed to create session request: %v", err) + return + } + sessionReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + sessionResp, err := client.Do(sessionReq) + if err != nil { + log.Printf("failed to create session: %v", err) + return + } + defer sessionResp.Body.Close() + + if sessionResp.StatusCode != http.StatusOK { + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) + return + } + + var session struct { + AccessJwt string `json:"accessJwt"` + } + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { + log.Printf("failed to decode session response: %v", err) + return + } + + record := tangled.SpindleMember{ + LexiconTypeID: "sh.tangled.spindle.member", + Subject: did, + Instance: defaultSpindle, + CreatedAt: time.Now().Format(time.RFC3339), + } + + recordBytes, err := json.Marshal(record) + if err != nil { + log.Printf("failed to marshal spindle member record: %v", err) + return + } + + payload := map[string]interface{}{ + "repo": tangledDid, + "collection": tangled.SpindleMemberNSID, + "rkey": tid.TID(), + "record": json.RawMessage(recordBytes), + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + log.Printf("failed to marshal request payload: %v", err) + return + } + + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) + if err != nil { + log.Printf("failed to create HTTP request: %v", err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) + + resp, err := client.Do(req) + if err != nil { + log.Printf("failed to add user to default spindle: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) + return + } + + log.Printf("successfully added %s to default spindle", did) +} + func (o *OAuthHandler) addToDefaultKnot(did string) { defaultKnot := "knot1.tangled.sh" -- 2.43.0