lexicons: add sh.tangled.string #420

closed
opened by anirudh.fi targeting master from push-lyrpkknpnrus
Changed files
+1493 -63
api
appview
config
db
middleware
oauth
handler
pages
templates
layouts
strings
user
fragments
state
strings
cmd
knotserver
lexicons
string
+232
api/tangled/cbor_gen.go
··· 8423 8423 8424 8424 return nil 8425 8425 } 8426 + func (t *String) MarshalCBOR(w io.Writer) error { 8427 + if t == nil { 8428 + _, err := w.Write(cbg.CborNull) 8429 + return err 8430 + } 8431 + 8432 + cw := cbg.NewCborWriter(w) 8433 + 8434 + if _, err := cw.Write([]byte{165}); err != nil { 8435 + return err 8436 + } 8437 + 8438 + // t.LexiconTypeID (string) (string) 8439 + if len("$type") > 1000000 { 8440 + return xerrors.Errorf("Value in field \"$type\" was too long") 8441 + } 8442 + 8443 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8444 + return err 8445 + } 8446 + if _, err := cw.WriteString(string("$type")); err != nil { 8447 + return err 8448 + } 8449 + 8450 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 8451 + return err 8452 + } 8453 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 8454 + return err 8455 + } 8456 + 8457 + // t.Contents (string) (string) 8458 + if len("contents") > 1000000 { 8459 + return xerrors.Errorf("Value in field \"contents\" was too long") 8460 + } 8461 + 8462 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 8463 + return err 8464 + } 8465 + if _, err := cw.WriteString(string("contents")); err != nil { 8466 + return err 8467 + } 8468 + 8469 + if len(t.Contents) > 1000000 { 8470 + return xerrors.Errorf("Value in field t.Contents was too long") 8471 + } 8472 + 8473 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 8474 + return err 8475 + } 8476 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 8477 + return err 8478 + } 8479 + 8480 + // t.Filename (string) (string) 8481 + if len("filename") > 1000000 { 8482 + return xerrors.Errorf("Value in field \"filename\" was too long") 8483 + } 8484 + 8485 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 8486 + return err 8487 + } 8488 + if _, err := cw.WriteString(string("filename")); err != nil { 8489 + return err 8490 + } 8491 + 8492 + if len(t.Filename) > 1000000 { 8493 + return xerrors.Errorf("Value in field t.Filename was too long") 8494 + } 8495 + 8496 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 8497 + return err 8498 + } 8499 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 8500 + return err 8501 + } 8502 + 8503 + // t.CreatedAt (string) (string) 8504 + if len("createdAt") > 1000000 { 8505 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8506 + } 8507 + 8508 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8509 + return err 8510 + } 8511 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8512 + return err 8513 + } 8514 + 8515 + if len(t.CreatedAt) > 1000000 { 8516 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8517 + } 8518 + 8519 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8520 + return err 8521 + } 8522 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8523 + return err 8524 + } 8525 + 8526 + // t.Description (string) (string) 8527 + if len("description") > 1000000 { 8528 + return xerrors.Errorf("Value in field \"description\" was too long") 8529 + } 8530 + 8531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 8532 + return err 8533 + } 8534 + if _, err := cw.WriteString(string("description")); err != nil { 8535 + return err 8536 + } 8537 + 8538 + if len(t.Description) > 1000000 { 8539 + return xerrors.Errorf("Value in field t.Description was too long") 8540 + } 8541 + 8542 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 8543 + return err 8544 + } 8545 + if _, err := cw.WriteString(string(t.Description)); err != nil { 8546 + return err 8547 + } 8548 + return nil 8549 + } 8550 + 8551 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 8552 + *t = String{} 8553 + 8554 + cr := cbg.NewCborReader(r) 8555 + 8556 + maj, extra, err := cr.ReadHeader() 8557 + if err != nil { 8558 + return err 8559 + } 8560 + defer func() { 8561 + if err == io.EOF { 8562 + err = io.ErrUnexpectedEOF 8563 + } 8564 + }() 8565 + 8566 + if maj != cbg.MajMap { 8567 + return fmt.Errorf("cbor input should be of type map") 8568 + } 8569 + 8570 + if extra > cbg.MaxLength { 8571 + return fmt.Errorf("String: map struct too large (%d)", extra) 8572 + } 8573 + 8574 + n := extra 8575 + 8576 + nameBuf := make([]byte, 11) 8577 + for i := uint64(0); i < n; i++ { 8578 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8579 + if err != nil { 8580 + return err 8581 + } 8582 + 8583 + if !ok { 8584 + // Field doesn't exist on this type, so ignore it 8585 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8586 + return err 8587 + } 8588 + continue 8589 + } 8590 + 8591 + switch string(nameBuf[:nameLen]) { 8592 + // t.LexiconTypeID (string) (string) 8593 + case "$type": 8594 + 8595 + { 8596 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8597 + if err != nil { 8598 + return err 8599 + } 8600 + 8601 + t.LexiconTypeID = string(sval) 8602 + } 8603 + // t.Contents (string) (string) 8604 + case "contents": 8605 + 8606 + { 8607 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8608 + if err != nil { 8609 + return err 8610 + } 8611 + 8612 + t.Contents = string(sval) 8613 + } 8614 + // t.Filename (string) (string) 8615 + case "filename": 8616 + 8617 + { 8618 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8619 + if err != nil { 8620 + return err 8621 + } 8622 + 8623 + t.Filename = string(sval) 8624 + } 8625 + // t.CreatedAt (string) (string) 8626 + case "createdAt": 8627 + 8628 + { 8629 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8630 + if err != nil { 8631 + return err 8632 + } 8633 + 8634 + t.CreatedAt = string(sval) 8635 + } 8636 + // t.Description (string) (string) 8637 + case "description": 8638 + 8639 + { 8640 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8641 + if err != nil { 8642 + return err 8643 + } 8644 + 8645 + t.Description = string(sval) 8646 + } 8647 + 8648 + default: 8649 + // Field doesn't exist on this type, so ignore it 8650 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8651 + return err 8652 + } 8653 + } 8654 + } 8655 + 8656 + return nil 8657 + }
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+1
cmd/gen.go
··· 50 50 tangled.RepoPullStatus{}, 51 51 tangled.Spindle{}, 52 52 tangled.SpindleMember{}, 53 + tangled.String{}, 53 54 ); err != nil { 54 55 panic(err) 55 56 }
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+15
appview/db/db.go
··· 443 443 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 444 444 ); 445 445 446 + create table if not exists strings ( 447 + -- identifiers 448 + did text not null, 449 + rkey text not null, 450 + 451 + -- content 452 + filename text not null, 453 + description text, 454 + content text not null, 455 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 456 + edited text, 457 + 458 + primary key (did, rkey) 459 + ); 460 + 446 461 create table if not exists migrations ( 447 462 id integer primary key autoincrement, 448 463 name text unique
+251
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if !strings.Contains(s.Filename, ".") { 54 + err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 + } 56 + 57 + if strings.HasSuffix(s.Filename, ".") { 58 + err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 + } 60 + 61 + if utf8.RuneCountInString(s.Filename) > 140 { 62 + err = errors.Join(err, fmt.Errorf("filename too long")) 63 + } 64 + 65 + if utf8.RuneCountInString(s.Description) > 280 { 66 + err = errors.Join(err, fmt.Errorf("description too long")) 67 + } 68 + 69 + if len(s.Contents) == 0 { 70 + err = errors.Join(err, fmt.Errorf("contents is empty")) 71 + } 72 + 73 + return err 74 + } 75 + 76 + func (s *String) AsRecord() tangled.String { 77 + return tangled.String{ 78 + Filename: s.Filename, 79 + Description: s.Description, 80 + Contents: s.Contents, 81 + CreatedAt: s.Created.Format(time.RFC3339), 82 + } 83 + } 84 + 85 + func StringFromRecord(did, rkey string, record tangled.String) String { 86 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 87 + if err != nil { 88 + created = time.Now() 89 + } 90 + return String{ 91 + Did: syntax.DID(did), 92 + Rkey: rkey, 93 + Filename: record.Filename, 94 + Description: record.Description, 95 + Contents: record.Contents, 96 + Created: created, 97 + } 98 + } 99 + 100 + func AddString(e Execer, s String) error { 101 + _, err := e.Exec( 102 + `insert into strings ( 103 + did, 104 + rkey, 105 + filename, 106 + description, 107 + content, 108 + created, 109 + edited 110 + ) 111 + values (?, ?, ?, ?, ?, ?, null) 112 + on conflict(did, rkey) do update set 113 + filename = excluded.filename, 114 + description = excluded.description, 115 + content = excluded.content, 116 + edited = case 117 + when 118 + strings.content != excluded.content 119 + or strings.filename != excluded.filename 120 + or strings.description != excluded.description then ? 121 + else strings.edited 122 + end`, 123 + s.Did, 124 + s.Rkey, 125 + s.Filename, 126 + s.Description, 127 + s.Contents, 128 + s.Created.Format(time.RFC3339), 129 + time.Now().Format(time.RFC3339), 130 + ) 131 + return err 132 + } 133 + 134 + func GetStrings(e Execer, filters ...filter) ([]String, error) { 135 + var all []String 136 + 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 142 + } 143 + 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 147 + } 148 + 149 + query := fmt.Sprintf(`select 150 + did, 151 + rkey, 152 + filename, 153 + description, 154 + content, 155 + created, 156 + edited 157 + from strings %s`, 158 + whereClause, 159 + ) 160 + 161 + rows, err := e.Query(query, args...) 162 + 163 + if err != nil { 164 + return nil, err 165 + } 166 + defer rows.Close() 167 + 168 + for rows.Next() { 169 + var s String 170 + var createdAt string 171 + var editedAt sql.NullString 172 + 173 + if err := rows.Scan( 174 + &s.Did, 175 + &s.Rkey, 176 + &s.Filename, 177 + &s.Description, 178 + &s.Contents, 179 + &createdAt, 180 + &editedAt, 181 + ); err != nil { 182 + return nil, err 183 + } 184 + 185 + s.Created, err = time.Parse(time.RFC3339, createdAt) 186 + if err != nil { 187 + s.Created = time.Now() 188 + } 189 + 190 + if editedAt.Valid { 191 + e, err := time.Parse(time.RFC3339, editedAt.String) 192 + if err != nil { 193 + e = time.Now() 194 + } 195 + s.Edited = &e 196 + } 197 + 198 + all = append(all, s) 199 + } 200 + 201 + if err := rows.Err(); err != nil { 202 + return nil, err 203 + } 204 + 205 + return all, nil 206 + } 207 + 208 + func DeleteString(e Execer, filters ...filter) error { 209 + var conditions []string 210 + var args []any 211 + for _, filter := range filters { 212 + conditions = append(conditions, filter.Condition()) 213 + args = append(args, filter.Arg()...) 214 + } 215 + 216 + whereClause := "" 217 + if conditions != nil { 218 + whereClause = " where " + strings.Join(conditions, " and ") 219 + } 220 + 221 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 222 + 223 + _, err := e.Exec(query, args...) 224 + return err 225 + } 226 + 227 + func countLines(r io.Reader) (int, error) { 228 + buf := make([]byte, 32*1024) 229 + bufLen := 0 230 + count := 0 231 + nl := []byte{'\n'} 232 + 233 + for { 234 + c, err := r.Read(buf) 235 + if c > 0 { 236 + bufLen += c 237 + } 238 + count += bytes.Count(buf[:c], nl) 239 + 240 + switch { 241 + case err == io.EOF: 242 + /* handle last line not having a newline at the end */ 243 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 244 + count++ 245 + } 246 + return count, nil 247 + case err != nil: 248 + return 0, err 249 + } 250 + } 251 + }
+449
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "slices" 9 + "strconv" 10 + "strings" 11 + "time" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/appview/config" 15 + "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/middleware" 17 + "tangled.sh/tangled.sh/core/appview/oauth" 18 + "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/pages/markup" 20 + "tangled.sh/tangled.sh/core/eventconsumer" 21 + "tangled.sh/tangled.sh/core/idresolver" 22 + "tangled.sh/tangled.sh/core/rbac" 23 + "tangled.sh/tangled.sh/core/tid" 24 + 25 + "github.com/bluesky-social/indigo/api/atproto" 26 + "github.com/bluesky-social/indigo/atproto/identity" 27 + "github.com/bluesky-social/indigo/atproto/syntax" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 + "github.com/go-chi/chi/v5" 30 + ) 31 + 32 + type Strings struct { 33 + Db *db.DB 34 + OAuth *oauth.OAuth 35 + Pages *pages.Pages 36 + Config *config.Config 37 + Enforcer *rbac.Enforcer 38 + IdResolver *idresolver.Resolver 39 + Logger *slog.Logger 40 + Knotstream *eventconsumer.Consumer 41 + } 42 + 43 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 44 + r := chi.NewRouter() 45 + 46 + r. 47 + With(mw.ResolveIdent()). 48 + Route("/{user}", func(r chi.Router) { 49 + r.Get("/", s.dashboard) 50 + 51 + r.Route("/{rkey}", func(r chi.Router) { 52 + r.Get("/", s.contents) 53 + r.Delete("/", s.delete) 54 + r.Get("/raw", s.contents) 55 + r.Get("/edit", s.edit) 56 + r.Post("/edit", s.edit) 57 + r. 58 + With(middleware.AuthMiddleware(s.OAuth)). 59 + Post("/comment", s.comment) 60 + }) 61 + }) 62 + 63 + r. 64 + With(middleware.AuthMiddleware(s.OAuth)). 65 + Route("/new", func(r chi.Router) { 66 + r.Get("/", s.create) 67 + r.Post("/", s.create) 68 + }) 69 + 70 + return r 71 + } 72 + 73 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 + l := s.Logger.With("handler", "contents") 75 + 76 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 77 + if !ok { 78 + l.Error("malformed middleware") 79 + w.WriteHeader(http.StatusInternalServerError) 80 + return 81 + } 82 + l = l.With("did", id.DID, "handle", id.Handle) 83 + 84 + rkey := chi.URLParam(r, "rkey") 85 + if rkey == "" { 86 + l.Error("malformed url, empty rkey") 87 + w.WriteHeader(http.StatusBadRequest) 88 + return 89 + } 90 + l = l.With("rkey", rkey) 91 + 92 + strings, err := db.GetStrings( 93 + s.Db, 94 + db.FilterEq("did", id.DID), 95 + db.FilterEq("rkey", rkey), 96 + ) 97 + if err != nil { 98 + l.Error("failed to fetch string", "err", err) 99 + w.WriteHeader(http.StatusInternalServerError) 100 + return 101 + } 102 + if len(strings) != 1 { 103 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 104 + w.WriteHeader(http.StatusInternalServerError) 105 + return 106 + } 107 + string := strings[0] 108 + 109 + if path.Base(r.URL.Path) == "raw" { 110 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 111 + if string.Filename != "" { 112 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 113 + } 114 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 115 + 116 + _, err = w.Write([]byte(string.Contents)) 117 + if err != nil { 118 + l.Error("failed to write raw response", "err", err) 119 + } 120 + return 121 + } 122 + 123 + var showRendered, renderToggle bool 124 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 125 + renderToggle = true 126 + showRendered = r.URL.Query().Get("code") != "true" 127 + } 128 + 129 + s.Pages.SingleString(w, pages.SingleStringParams{ 130 + LoggedInUser: s.OAuth.GetUser(r), 131 + RenderToggle: renderToggle, 132 + ShowRendered: showRendered, 133 + String: string, 134 + Stats: string.Stats(), 135 + Owner: id, 136 + }) 137 + } 138 + 139 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 140 + l := s.Logger.With("handler", "dashboard") 141 + 142 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 143 + if !ok { 144 + l.Error("malformed middleware") 145 + w.WriteHeader(http.StatusInternalServerError) 146 + return 147 + } 148 + l = l.With("did", id.DID, "handle", id.Handle) 149 + 150 + all, err := db.GetStrings( 151 + s.Db, 152 + db.FilterEq("did", id.DID), 153 + ) 154 + if err != nil { 155 + l.Error("failed to fetch strings", "err", err) 156 + w.WriteHeader(http.StatusInternalServerError) 157 + return 158 + } 159 + 160 + slices.SortFunc(all, func(a, b db.String) int { 161 + if a.Created.After(b.Created) { 162 + return -1 163 + } else { 164 + return 1 165 + } 166 + }) 167 + 168 + profile, err := db.GetProfile(s.Db, id.DID.String()) 169 + if err != nil { 170 + l.Error("failed to fetch user profile", "err", err) 171 + w.WriteHeader(http.StatusInternalServerError) 172 + return 173 + } 174 + loggedInUser := s.OAuth.GetUser(r) 175 + followStatus := db.IsNotFollowing 176 + if loggedInUser != nil { 177 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 178 + } 179 + 180 + followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 181 + if err != nil { 182 + l.Error("failed to get follow stats", "err", err) 183 + } 184 + 185 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 186 + LoggedInUser: s.OAuth.GetUser(r), 187 + Card: pages.ProfileCard{ 188 + UserDid: id.DID.String(), 189 + UserHandle: id.Handle.String(), 190 + Profile: profile, 191 + FollowStatus: followStatus, 192 + Followers: followers, 193 + Following: following, 194 + }, 195 + Strings: all, 196 + }) 197 + } 198 + 199 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 200 + l := s.Logger.With("handler", "edit") 201 + 202 + user := s.OAuth.GetUser(r) 203 + 204 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 205 + if !ok { 206 + l.Error("malformed middleware") 207 + w.WriteHeader(http.StatusInternalServerError) 208 + return 209 + } 210 + l = l.With("did", id.DID, "handle", id.Handle) 211 + 212 + rkey := chi.URLParam(r, "rkey") 213 + if rkey == "" { 214 + l.Error("malformed url, empty rkey") 215 + w.WriteHeader(http.StatusBadRequest) 216 + return 217 + } 218 + l = l.With("rkey", rkey) 219 + 220 + // get the string currently being edited 221 + all, err := db.GetStrings( 222 + s.Db, 223 + db.FilterEq("did", id.DID), 224 + db.FilterEq("rkey", rkey), 225 + ) 226 + if err != nil { 227 + l.Error("failed to fetch string", "err", err) 228 + w.WriteHeader(http.StatusInternalServerError) 229 + return 230 + } 231 + if len(all) != 1 { 232 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 233 + w.WriteHeader(http.StatusInternalServerError) 234 + return 235 + } 236 + first := all[0] 237 + 238 + // verify that the logged in user owns this string 239 + if user.Did != id.DID.String() { 240 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 241 + w.WriteHeader(http.StatusUnauthorized) 242 + return 243 + } 244 + 245 + switch r.Method { 246 + case http.MethodGet: 247 + // return the form with prefilled fields 248 + s.Pages.PutString(w, pages.PutStringParams{ 249 + LoggedInUser: s.OAuth.GetUser(r), 250 + Action: "edit", 251 + String: first, 252 + }) 253 + case http.MethodPost: 254 + fail := func(msg string, err error) { 255 + l.Error(msg, "err", err) 256 + s.Pages.Notice(w, "error", msg) 257 + } 258 + 259 + filename := r.FormValue("filename") 260 + if filename == "" { 261 + fail("Empty filename.", nil) 262 + return 263 + } 264 + if !strings.Contains(filename, ".") { 265 + // TODO: make this a htmx form validation 266 + fail("No extension provided for filename.", nil) 267 + return 268 + } 269 + 270 + content := r.FormValue("content") 271 + if content == "" { 272 + fail("Empty contents.", nil) 273 + return 274 + } 275 + 276 + description := r.FormValue("description") 277 + 278 + // construct new string from form values 279 + entry := db.String{ 280 + Did: first.Did, 281 + Rkey: first.Rkey, 282 + Filename: filename, 283 + Description: description, 284 + Contents: content, 285 + Created: first.Created, 286 + } 287 + 288 + record := entry.AsRecord() 289 + 290 + client, err := s.OAuth.AuthorizedClient(r) 291 + if err != nil { 292 + fail("Failed to create record.", err) 293 + return 294 + } 295 + 296 + // first replace the existing record in the PDS 297 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 298 + if err != nil { 299 + fail("Failed to updated existing record.", err) 300 + return 301 + } 302 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 303 + Collection: tangled.StringNSID, 304 + Repo: entry.Did.String(), 305 + Rkey: entry.Rkey, 306 + SwapRecord: ex.Cid, 307 + Record: &lexutil.LexiconTypeDecoder{ 308 + Val: &record, 309 + }, 310 + }) 311 + if err != nil { 312 + fail("Failed to updated existing record.", err) 313 + return 314 + } 315 + l := l.With("aturi", resp.Uri) 316 + l.Info("edited string") 317 + 318 + // if that went okay, updated the db 319 + if err = db.AddString(s.Db, entry); err != nil { 320 + fail("Failed to update string.", err) 321 + return 322 + } 323 + 324 + // if that went okay, redir to the string 325 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 326 + } 327 + 328 + } 329 + 330 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 331 + l := s.Logger.With("handler", "create") 332 + user := s.OAuth.GetUser(r) 333 + 334 + switch r.Method { 335 + case http.MethodGet: 336 + s.Pages.PutString(w, pages.PutStringParams{ 337 + LoggedInUser: s.OAuth.GetUser(r), 338 + Action: "new", 339 + }) 340 + case http.MethodPost: 341 + fail := func(msg string, err error) { 342 + l.Error(msg, "err", err) 343 + s.Pages.Notice(w, "error", msg) 344 + } 345 + 346 + filename := r.FormValue("filename") 347 + if filename == "" { 348 + fail("Empty filename.", nil) 349 + return 350 + } 351 + if !strings.Contains(filename, ".") { 352 + // TODO: make this a htmx form validation 353 + fail("No extension provided for filename.", nil) 354 + return 355 + } 356 + 357 + content := r.FormValue("content") 358 + if content == "" { 359 + fail("Empty contents.", nil) 360 + return 361 + } 362 + 363 + description := r.FormValue("description") 364 + 365 + string := db.String{ 366 + Did: syntax.DID(user.Did), 367 + Rkey: tid.TID(), 368 + Filename: filename, 369 + Description: description, 370 + Contents: content, 371 + Created: time.Now(), 372 + } 373 + 374 + record := string.AsRecord() 375 + 376 + client, err := s.OAuth.AuthorizedClient(r) 377 + if err != nil { 378 + fail("Failed to create record.", err) 379 + return 380 + } 381 + 382 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 383 + Collection: tangled.StringNSID, 384 + Repo: user.Did, 385 + Rkey: string.Rkey, 386 + Record: &lexutil.LexiconTypeDecoder{ 387 + Val: &record, 388 + }, 389 + }) 390 + if err != nil { 391 + fail("Failed to create record.", err) 392 + return 393 + } 394 + l := l.With("aturi", resp.Uri) 395 + l.Info("created record") 396 + 397 + // insert into DB 398 + if err = db.AddString(s.Db, string); err != nil { 399 + fail("Failed to create string.", err) 400 + return 401 + } 402 + 403 + // successful 404 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 405 + } 406 + } 407 + 408 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 409 + l := s.Logger.With("handler", "create") 410 + user := s.OAuth.GetUser(r) 411 + fail := func(msg string, err error) { 412 + l.Error(msg, "err", err) 413 + s.Pages.Notice(w, "error", msg) 414 + } 415 + 416 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 417 + if !ok { 418 + l.Error("malformed middleware") 419 + w.WriteHeader(http.StatusInternalServerError) 420 + return 421 + } 422 + l = l.With("did", id.DID, "handle", id.Handle) 423 + 424 + rkey := chi.URLParam(r, "rkey") 425 + if rkey == "" { 426 + l.Error("malformed url, empty rkey") 427 + w.WriteHeader(http.StatusBadRequest) 428 + return 429 + } 430 + 431 + if user.Did != id.DID.String() { 432 + fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 433 + return 434 + } 435 + 436 + if err := db.DeleteString( 437 + s.Db, 438 + db.FilterEq("did", user.Did), 439 + db.FilterEq("rkey", rkey), 440 + ); err != nil { 441 + fail("Failed to delete string.", err) 442 + return 443 + } 444 + 445 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 446 + } 447 + 448 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 449 + }
+85
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} · by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span class="flex items-center"> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 51 + {{ end }} 52 + 53 + {{ with .String.Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 + {{ end }} 58 + </span> 59 + </section> 60 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 62 + <span>{{ .String.Filename }}</span> 63 + <div> 64 + <span>{{ .Stats.LineCount }} lines</span> 65 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 66 + <span>{{ byteFmt .Stats.ByteCount }}</span> 67 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 68 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 69 + {{ if .RenderToggle }} 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 71 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 72 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 73 + </a> 74 + {{ end }} 75 + </div> 76 + </div> 77 + <div class="overflow-auto relative"> 78 + {{ if .ShowRendered }} 79 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 + {{ else }} 81 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 82 + {{ end }} 83 + </div> 84 + </section> 85 + {{ end }}
+56
appview/ingester.go
··· 64 64 err = i.ingestSpindleMember(e) 65 65 case tangled.SpindleNSID: 66 66 err = i.ingestSpindle(e) 67 + case tangled.StringNSID: 68 + err = i.ingestString(e) 67 69 } 68 70 l = i.Logger.With("nsid", e.Commit.Collection) 69 71 } ··· 549 551 550 552 return nil 551 553 } 554 + 555 + func (i *Ingester) ingestString(e *models.Event) error { 556 + did := e.Did 557 + rkey := e.Commit.RKey 558 + 559 + var err error 560 + 561 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 562 + l.Info("ingesting record") 563 + 564 + ddb, ok := i.Db.Execer.(*db.DB) 565 + if !ok { 566 + return fmt.Errorf("failed to index string record, invalid db cast") 567 + } 568 + 569 + switch e.Commit.Operation { 570 + case models.CommitOperationCreate, models.CommitOperationUpdate: 571 + raw := json.RawMessage(e.Commit.Record) 572 + record := tangled.String{} 573 + err = json.Unmarshal(raw, &record) 574 + if err != nil { 575 + l.Error("invalid record", "err", err) 576 + return err 577 + } 578 + 579 + string := db.StringFromRecord(did, rkey, record) 580 + 581 + if err = string.Validate(); err != nil { 582 + l.Error("invalid record", "err", err) 583 + return err 584 + } 585 + 586 + if err = db.AddString(ddb, string); err != nil { 587 + l.Error("failed to add string", "err", err) 588 + return err 589 + } 590 + 591 + return nil 592 + 593 + case models.CommitOperationDelete: 594 + if err := db.DeleteString( 595 + ddb, 596 + db.FilterEq("did", did), 597 + db.FilterEq("rkey", rkey), 598 + ); err != nil { 599 + l.Error("failed to delete", "err", err) 600 + return fmt.Errorf("failed to delete string record: %w", err) 601 + } 602 + 603 + return nil 604 + } 605 + 606 + return nil 607 + }
+2 -10
appview/middleware/middleware.go
··· 167 167 } 168 168 } 169 169 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 170 func (mw Middleware) ResolveIdent() middlewareFunc { 181 171 excluded := []string{"favicon.ico"} 182 172 ··· 188 178 return 189 179 } 190 180 181 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 182 + 191 183 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 184 if err != nil { 193 185 // invalid did or handle
+89
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename with extension" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 + rows="20" 36 + placeholder="Paste your string here!" 37 + required>{{ .String.Contents }}</textarea> 38 + <div class="flex justify-between items-center"> 39 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 40 + <span id="line-count">0 lines</span> 41 + <span class="select-none px-1 [&:before]:content-['·']"></span> 42 + <span id="byte-count">0 bytes</span> 43 + </div> 44 + <div id="actions" class="flex gap-2 items-center"> 45 + {{ if eq .Action "edit" }} 46 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 47 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 48 + {{ i "x" "size-4" }} 49 + <span class="hidden md:inline">cancel</span> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </a> 52 + {{ end }} 53 + <button 54 + type="submit" 55 + id="new-button" 56 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 57 + > 58 + <span class="inline-flex items-center gap-2"> 59 + {{ i "arrow-up" "w-4 h-4" }} 60 + publish 61 + </span> 62 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 63 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 64 + </span> 65 + </button> 66 + </div> 67 + </div> 68 + <script> 69 + (function() { 70 + const textarea = document.getElementById('content-textarea'); 71 + const lineCount = document.getElementById('line-count'); 72 + const byteCount = document.getElementById('byte-count'); 73 + function updateStats() { 74 + const content = textarea.value; 75 + const lines = content === '' ? 0 : content.split('\n').length; 76 + const bytes = new TextEncoder().encode(content).length; 77 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 78 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 79 + } 80 + textarea.addEventListener('input', updateStats); 81 + textarea.addEventListener('paste', () => { 82 + setTimeout(updateStats, 0); 83 + }); 84 + updateStats(); 85 + })(); 86 + </script> 87 + <div id="error" class="error dark:text-red-400"></div> 88 + </form> 89 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+1
appview/state/state.go
··· 93 93 tangled.ActorProfileNSID, 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 96 97 }, 97 98 nil, 98 99 slog.Default(),
-8
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['·']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+1 -3
appview/pages/templates/user/fragments/profileCard.html
··· 2 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 10 <p title="{{ didOrHandle .UserDid .UserHandle }}"
-16
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 142 139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 143 140 } 144 141 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 142 s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 143 LoggedInUser: loggedInUser, 148 144 Repos: pinnedRepos, ··· 151 147 Card: pages.ProfileCard{ 152 148 UserDid: ident.DID.String(), 153 149 UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 150 Profile: profile, 156 151 FollowStatus: followStatus, 157 152 Followers: followers, ··· 194 189 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 195 190 } 196 191 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 198 - 199 192 s.pages.ReposPage(w, pages.ReposPageParams{ 200 193 LoggedInUser: loggedInUser, 201 194 Repos: repos, ··· 203 196 Card: pages.ProfileCard{ 204 197 UserDid: ident.DID.String(), 205 198 UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 199 Profile: profile, 208 200 FollowStatus: followStatus, 209 201 Followers: followers, ··· 212 204 }) 213 205 } 214 206 215 - func (s *State) GetAvatarUri(handle string) string { 216 - secret := s.config.Avatar.SharedSecret 217 - h := hmac.New(sha256.New, []byte(secret)) 218 - h.Write([]byte(handle)) 219 - signature := hex.EncodeToString(h.Sum(nil)) 220 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 221 - } 222 - 223 207 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 224 208 user := s.oauth.GetUser(r) 225 209
+20 -3
appview/pages/templates/layouts/topbar.html
··· 9 9 10 10 <div id="right-items" class="flex items-center gap-4"> 11 11 {{ with .LoggedInUser }} 12 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 13 - {{ i "plus" "w-4 h-4" }} 14 - </a> 12 + {{ block "newButton" . }} {{ end }} 15 13 {{ block "dropDown" . }} {{ end }} 16 14 {{ else }} 17 15 <a href="/login">login</a> ··· 21 19 </nav> 22 20 {{ end }} 23 21 22 + {{ define "newButton" }} 23 + <details class="relative inline-block text-left"> 24 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 25 + {{ i "plus" "w-4 h-4" }} new 26 + </summary> 27 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 28 + <a href="/repo/new" class="flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + new repository 31 + </a> 32 + <a href="/strings/new" class="flex items-center gap-2"> 33 + {{ i "line-squiggle" "w-4 h-4" }} 34 + new string 35 + </a> 36 + </div> 37 + </details> 38 + {{ end }} 39 + 24 40 {{ define "dropDown" }} 25 41 <details class="relative inline-block text-left"> 26 42 <summary ··· 34 50 > 35 51 <a href="/{{ $user }}">profile</a> 36 52 <a href="/{{ $user }}?tab=repos">repositories</a> 53 + <a href="/strings/{{ $user }}">strings</a> 37 54 <a href="/knots">knots</a> 38 55 <a href="/spindles">spindles</a> 39 56 <a href="/settings">settings</a>
+22 -22
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1694529238, 9 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 3 21 "gitignore": { 4 22 "inputs": { 5 23 "nixpkgs": [ ··· 20 38 "type": "github" 21 39 } 22 40 }, 23 - "flake-utils": { 24 - "inputs": { 25 - "systems": "systems" 26 - }, 27 - "locked": { 28 - "lastModified": 1694529238, 29 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 30 - "owner": "numtide", 31 - "repo": "flake-utils", 32 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 33 - "type": "github" 34 - }, 35 - "original": { 36 - "owner": "numtide", 37 - "repo": "flake-utils", 38 - "type": "github" 39 - } 40 - }, 41 41 "gomod2nix": { 42 42 "inputs": { 43 43 "flake-utils": "flake-utils", ··· 128 128 "lucide-src": { 129 129 "flake": false, 130 130 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 131 + "lastModified": 1754044466, 132 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 133 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 134 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 135 }, 136 136 "original": { 137 137 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 138 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 139 } 140 140 }, 141 141 "nixpkgs": {
+1 -1
flake.nix
··· 22 22 flake = false; 23 23 }; 24 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 26 flake = false; 27 27 }; 28 28 inter-fonts-src = {
+3
appview/config/config.go
··· 16 16 AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default spindle 21 + AppPassword string `env:"APP_PASSWORD"` 19 22 } 20 23 21 24 type OAuthConfig struct {
+126
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 9 11 "strings" 12 + "time" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/gorilla/sessions" 13 16 "github.com/lestrrat-go/jwx/v2/jwk" 14 17 "github.com/posthog/posthog-go" 15 18 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 20 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 21 "tangled.sh/tangled.sh/core/appview/config" 18 22 "tangled.sh/tangled.sh/core/appview/db" ··· 23 27 "tangled.sh/tangled.sh/core/idresolver" 24 28 "tangled.sh/tangled.sh/core/knotclient" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 294 299 295 300 log.Println("session saved successfully") 296 301 go o.addToDefaultKnot(oauthRequest.Did) 302 + go o.addToDefaultSpindle(oauthRequest.Did) 297 303 298 304 if !o.config.Core.Dev { 299 305 err = o.posthog.Enqueue(posthog.Capture{ ··· 332 338 return pubKey, nil 333 339 } 334 340 341 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 342 + // use the tangled.sh app password to get an accessJwt 343 + // and create an sh.tangled.spindle.member record with that 344 + 345 + defaultSpindle := "spindle.tangled.sh" 346 + appPassword := o.config.Core.AppPassword 347 + 348 + // TODO: hardcoded tangled handle and did for now 349 + tangledHandle := "tangled.sh" 350 + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 351 + 352 + if appPassword == "" { 353 + log.Println("no app password configured, skipping spindle member addition") 354 + return 355 + } 356 + 357 + log.Printf("adding %s to default spindle", did) 358 + 359 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 360 + if err != nil { 361 + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 362 + return 363 + } 364 + 365 + pdsEndpoint := resolved.PDSEndpoint() 366 + if pdsEndpoint == "" { 367 + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 368 + return 369 + } 370 + 371 + sessionPayload := map[string]string{ 372 + "identifier": tangledHandle, 373 + "password": appPassword, 374 + } 375 + sessionBytes, err := json.Marshal(sessionPayload) 376 + if err != nil { 377 + log.Printf("failed to marshal session payload: %v", err) 378 + return 379 + } 380 + 381 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 382 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 383 + if err != nil { 384 + log.Printf("failed to create session request: %v", err) 385 + return 386 + } 387 + sessionReq.Header.Set("Content-Type", "application/json") 388 + 389 + client := &http.Client{Timeout: 30 * time.Second} 390 + sessionResp, err := client.Do(sessionReq) 391 + if err != nil { 392 + log.Printf("failed to create session: %v", err) 393 + return 394 + } 395 + defer sessionResp.Body.Close() 396 + 397 + if sessionResp.StatusCode != http.StatusOK { 398 + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 399 + return 400 + } 401 + 402 + var session struct { 403 + AccessJwt string `json:"accessJwt"` 404 + } 405 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 406 + log.Printf("failed to decode session response: %v", err) 407 + return 408 + } 409 + 410 + record := tangled.SpindleMember{ 411 + LexiconTypeID: "sh.tangled.spindle.member", 412 + Subject: did, 413 + Instance: defaultSpindle, 414 + CreatedAt: time.Now().Format(time.RFC3339), 415 + } 416 + 417 + recordBytes, err := json.Marshal(record) 418 + if err != nil { 419 + log.Printf("failed to marshal spindle member record: %v", err) 420 + return 421 + } 422 + 423 + payload := map[string]interface{}{ 424 + "repo": tangledDid, 425 + "collection": tangled.SpindleMemberNSID, 426 + "rkey": tid.TID(), 427 + "record": json.RawMessage(recordBytes), 428 + } 429 + 430 + payloadBytes, err := json.Marshal(payload) 431 + if err != nil { 432 + log.Printf("failed to marshal request payload: %v", err) 433 + return 434 + } 435 + 436 + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 437 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 438 + if err != nil { 439 + log.Printf("failed to create HTTP request: %v", err) 440 + return 441 + } 442 + 443 + req.Header.Set("Content-Type", "application/json") 444 + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 445 + 446 + resp, err := client.Do(req) 447 + if err != nil { 448 + log.Printf("failed to add user to default spindle: %v", err) 449 + return 450 + } 451 + defer resp.Body.Close() 452 + 453 + if resp.StatusCode != http.StatusOK { 454 + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 455 + return 456 + } 457 + 458 + log.Printf("successfully added %s to default spindle", did) 459 + } 460 + 335 461 func (o *OAuthHandler) addToDefaultKnot(did string) { 336 462 defaultKnot := "knot1.tangled.sh" 337 463