forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+5361 -12046
.tangled
api
appview
config
db
dns
indexer
issues
knots
labels
middleware
models
notifications
notify
oauth
ogcard
pages
pagination
pipelines
pulls
repo
reporesolver
settings
signup
spindles
state
strings
validator
xrpcclient
cmd
appview
cborgen
genjwks
knot
punchcardPopulate
spindle
docs
guard
idresolver
jetstream
knotserver
lexicons
log
nix
patchutil
scripts
spindle
types
workflow
xrpc
serviceauth
-13
.editorconfig
··· 1 - root = true 2 - 3 - [*.html] 4 - indent_size = 2 5 - 6 - [*.json] 7 - indent_size = 2 8 - 9 - [*.nix] 10 - indent_size = 2 11 - 12 - [*.yml] 13 - indent_size = 2
-1
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 - **/*.bleve 19 18 # Created if following hacking.md 20 19 genjwks.out 21 20 /nix/vm-data
+1 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: master 3 + branch: ["master"] 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: master 3 + branch: ["master"] 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: master 3 + branch: ["master"] 4 4 5 5 engine: nixery 6 6
+1 -3
api/tangled/actorprofile.go
··· 27 27 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 28 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 29 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 - // pronouns: Preferred gender pronouns. 31 - Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 32 - Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 30 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 33 31 }
+2 -196
api/tangled/cbor_gen.go
··· 26 26 } 27 27 28 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 8 29 + fieldCount := 7 30 30 31 31 if t.Description == nil { 32 32 fieldCount-- ··· 41 41 } 42 42 43 43 if t.PinnedRepositories == nil { 44 - fieldCount-- 45 - } 46 - 47 - if t.Pronouns == nil { 48 44 fieldCount-- 49 45 } 50 46 ··· 190 186 return err 191 187 } 192 188 if _, err := cw.WriteString(string(*t.Location)); err != nil { 193 - return err 194 - } 195 - } 196 - } 197 - 198 - // t.Pronouns (string) (string) 199 - if t.Pronouns != nil { 200 - 201 - if len("pronouns") > 1000000 { 202 - return xerrors.Errorf("Value in field \"pronouns\" was too long") 203 - } 204 - 205 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil { 206 - return err 207 - } 208 - if _, err := cw.WriteString(string("pronouns")); err != nil { 209 - return err 210 - } 211 - 212 - if t.Pronouns == nil { 213 - if _, err := cw.Write(cbg.CborNull); err != nil { 214 - return err 215 - } 216 - } else { 217 - if len(*t.Pronouns) > 1000000 { 218 - return xerrors.Errorf("Value in field t.Pronouns was too long") 219 - } 220 - 221 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil { 222 - return err 223 - } 224 - if _, err := cw.WriteString(string(*t.Pronouns)); err != nil { 225 189 return err 226 190 } 227 191 } ··· 466 430 } 467 431 468 432 t.Location = (*string)(&sval) 469 - } 470 - } 471 - // t.Pronouns (string) (string) 472 - case "pronouns": 473 - 474 - { 475 - b, err := cr.ReadByte() 476 - if err != nil { 477 - return err 478 - } 479 - if b != cbg.CborNull[0] { 480 - if err := cr.UnreadByte(); err != nil { 481 - return err 482 - } 483 - 484 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 485 - if err != nil { 486 - return err 487 - } 488 - 489 - t.Pronouns = (*string)(&sval) 490 433 } 491 434 } 492 435 // t.Description (string) (string) ··· 5863 5806 } 5864 5807 5865 5808 cw := cbg.NewCborWriter(w) 5866 - fieldCount := 10 5809 + fieldCount := 8 5867 5810 5868 5811 if t.Description == nil { 5869 5812 fieldCount-- ··· 5878 5821 } 5879 5822 5880 5823 if t.Spindle == nil { 5881 - fieldCount-- 5882 - } 5883 - 5884 - if t.Topics == nil { 5885 - fieldCount-- 5886 - } 5887 - 5888 - if t.Website == nil { 5889 5824 fieldCount-- 5890 5825 } 5891 5826 ··· 6026 5961 } 6027 5962 } 6028 5963 6029 - // t.Topics ([]string) (slice) 6030 - if t.Topics != nil { 6031 - 6032 - if len("topics") > 1000000 { 6033 - return xerrors.Errorf("Value in field \"topics\" was too long") 6034 - } 6035 - 6036 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil { 6037 - return err 6038 - } 6039 - if _, err := cw.WriteString(string("topics")); err != nil { 6040 - return err 6041 - } 6042 - 6043 - if len(t.Topics) > 8192 { 6044 - return xerrors.Errorf("Slice value in field t.Topics was too long") 6045 - } 6046 - 6047 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil { 6048 - return err 6049 - } 6050 - for _, v := range t.Topics { 6051 - if len(v) > 1000000 { 6052 - return xerrors.Errorf("Value in field v was too long") 6053 - } 6054 - 6055 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 6056 - return err 6057 - } 6058 - if _, err := cw.WriteString(string(v)); err != nil { 6059 - return err 6060 - } 6061 - 6062 - } 6063 - } 6064 - 6065 5964 // t.Spindle (string) (string) 6066 5965 if t.Spindle != nil { 6067 5966 ··· 6094 5993 } 6095 5994 } 6096 5995 6097 - // t.Website (string) (string) 6098 - if t.Website != nil { 6099 - 6100 - if len("website") > 1000000 { 6101 - return xerrors.Errorf("Value in field \"website\" was too long") 6102 - } 6103 - 6104 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil { 6105 - return err 6106 - } 6107 - if _, err := cw.WriteString(string("website")); err != nil { 6108 - return err 6109 - } 6110 - 6111 - if t.Website == nil { 6112 - if _, err := cw.Write(cbg.CborNull); err != nil { 6113 - return err 6114 - } 6115 - } else { 6116 - if len(*t.Website) > 1000000 { 6117 - return xerrors.Errorf("Value in field t.Website was too long") 6118 - } 6119 - 6120 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil { 6121 - return err 6122 - } 6123 - if _, err := cw.WriteString(string(*t.Website)); err != nil { 6124 - return err 6125 - } 6126 - } 6127 - } 6128 - 6129 5996 // t.CreatedAt (string) (string) 6130 5997 if len("createdAt") > 1000000 { 6131 5998 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6318 6185 t.Source = (*string)(&sval) 6319 6186 } 6320 6187 } 6321 - // t.Topics ([]string) (slice) 6322 - case "topics": 6323 - 6324 - maj, extra, err = cr.ReadHeader() 6325 - if err != nil { 6326 - return err 6327 - } 6328 - 6329 - if extra > 8192 { 6330 - return fmt.Errorf("t.Topics: array too large (%d)", extra) 6331 - } 6332 - 6333 - if maj != cbg.MajArray { 6334 - return fmt.Errorf("expected cbor array") 6335 - } 6336 - 6337 - if extra > 0 { 6338 - t.Topics = make([]string, extra) 6339 - } 6340 - 6341 - for i := 0; i < int(extra); i++ { 6342 - { 6343 - var maj byte 6344 - var extra uint64 6345 - var err error 6346 - _ = maj 6347 - _ = extra 6348 - _ = err 6349 - 6350 - { 6351 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6352 - if err != nil { 6353 - return err 6354 - } 6355 - 6356 - t.Topics[i] = string(sval) 6357 - } 6358 - 6359 - } 6360 - } 6361 6188 // t.Spindle (string) (string) 6362 6189 case "spindle": 6363 6190 ··· 6377 6204 } 6378 6205 6379 6206 t.Spindle = (*string)(&sval) 6380 - } 6381 - } 6382 - // t.Website (string) (string) 6383 - case "website": 6384 - 6385 - { 6386 - b, err := cr.ReadByte() 6387 - if err != nil { 6388 - return err 6389 - } 6390 - if b != cbg.CborNull[0] { 6391 - if err := cr.UnreadByte(); err != nil { 6392 - return err 6393 - } 6394 - 6395 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6396 - if err != nil { 6397 - return err 6398 - } 6399 - 6400 - t.Website = (*string)(&sval) 6401 6207 } 6402 6208 } 6403 6209 // t.CreatedAt (string) (string)
+1 -13
api/tangled/repoblob.go
··· 30 30 // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 31 type RepoBlob_Output struct { 32 32 // content: File content (base64 encoded for binary files) 33 - Content *string `json:"content,omitempty" cborgen:"content,omitempty"` 33 + Content string `json:"content" cborgen:"content"` 34 34 // encoding: Content encoding 35 35 Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 36 // isBinary: Whether the file is binary ··· 44 44 Ref string `json:"ref" cborgen:"ref"` 45 45 // size: File size in bytes 46 46 Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 - // submodule: Submodule information if path is a submodule 48 - Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"` 49 47 } 50 48 51 49 // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. ··· 56 54 Name string `json:"name" cborgen:"name"` 57 55 // when: Author timestamp 58 56 When string `json:"when" cborgen:"when"` 59 - } 60 - 61 - // RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema. 62 - type RepoBlob_Submodule struct { 63 - // branch: Branch to track in the submodule 64 - Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"` 65 - // name: Submodule name 66 - Name string `json:"name" cborgen:"name"` 67 - // url: Submodule repository URL 68 - Url string `json:"url" cborgen:"url"` 69 57 } 70 58 71 59 // RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-30
api/tangled/repodeleteBranch.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.deleteBranch 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 - ) 16 - 17 - // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 - type RepoDeleteBranch_Input struct { 19 - Branch string `json:"branch" cborgen:"branch"` 20 - Repo string `json:"repo" cborgen:"repo"` 21 - } 22 - 23 - // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 - func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 - return err 27 - } 28 - 29 - return nil 30 - }
+4
api/tangled/repotree.go
··· 47 47 48 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 49 type RepoTree_TreeEntry struct { 50 + // is_file: Whether this entry is a file 51 + Is_file bool `json:"is_file" cborgen:"is_file"` 52 + // is_subtree: Whether this entry is a directory/subtree 53 + Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 50 54 Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 51 55 // mode: File mode 52 56 Mode string `json:"mode" cborgen:"mode"`
-4
api/tangled/tangledrepo.go
··· 30 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 31 // spindle: CI runner to send jobs to and receive results from 32 32 Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` 33 - // topics: Topics related to the repo 34 - Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"` 35 - // website: Any URI related to the repo 36 - Website *string `json:"website,omitempty" cborgen:"website,omitempty"` 37 33 }
+2 -15
appview/config/config.go
··· 13 13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 14 DbPath string `env:"DB_PATH, default=appview.db"` 15 15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"` 17 - AppviewName string `env:"APPVIEW_Name, default=Tangled"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 18 17 Dev bool `env:"DEV, default=false"` 19 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 20 19 ··· 26 25 } 27 26 28 27 type OAuthConfig struct { 29 - ClientSecret string `env:"CLIENT_SECRET"` 30 - ClientKid string `env:"CLIENT_KID"` 31 - } 32 - 33 - type PlcConfig struct { 34 - PLCURL string `env:"URL, default=https://plc.directory"` 28 + Jwks string `env:"JWKS"` 35 29 } 36 30 37 31 type JetstreamConfig struct { ··· 84 78 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 85 79 } 86 80 87 - type LabelConfig struct { 88 - DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=, 89 - GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 90 - } 91 - 92 81 func (cfg RedisConfig) ToURL() string { 93 82 u := &url.URL{ 94 83 Scheme: "redis", ··· 114 103 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 115 104 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 116 105 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 - Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 118 106 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 119 107 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 - Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 121 108 } 122 109 123 110 func LoadConfig(ctx context.Context) (*Config, error) {
+1
appview/db/artifact.go
··· 67 67 ) 68 68 69 69 rows, err := e.Query(query, args...) 70 + 70 71 if err != nil { 71 72 return nil, err 72 73 }
-53
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 - "time" 7 6 8 7 "tangled.org/core/appview/models" 9 8 ) ··· 60 59 61 60 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 62 61 } 63 - 64 - func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 - var collaborators []models.Collaborator 66 - var conditions []string 67 - var args []any 68 - for _, filter := range filters { 69 - conditions = append(conditions, filter.Condition()) 70 - args = append(args, filter.Arg()...) 71 - } 72 - whereClause := "" 73 - if conditions != nil { 74 - whereClause = " where " + strings.Join(conditions, " and ") 75 - } 76 - query := fmt.Sprintf(`select 77 - id, 78 - did, 79 - rkey, 80 - subject_did, 81 - repo_at, 82 - created 83 - from collaborators %s`, 84 - whereClause, 85 - ) 86 - rows, err := e.Query(query, args...) 87 - if err != nil { 88 - return nil, err 89 - } 90 - defer rows.Close() 91 - for rows.Next() { 92 - var collaborator models.Collaborator 93 - var createdAt string 94 - if err := rows.Scan( 95 - &collaborator.Id, 96 - &collaborator.Did, 97 - &collaborator.Rkey, 98 - &collaborator.SubjectDid, 99 - &collaborator.RepoAt, 100 - &createdAt, 101 - ); err != nil { 102 - return nil, err 103 - } 104 - collaborator.Created, err = time.Parse(time.RFC3339, createdAt) 105 - if err != nil { 106 - collaborator.Created = time.Now() 107 - } 108 - collaborators = append(collaborators, collaborator) 109 - } 110 - if err := rows.Err(); err != nil { 111 - return nil, err 112 - } 113 - return collaborators, nil 114 - }
+26 -205
appview/db/db.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "log/slog" 7 + "log" 8 8 "reflect" 9 9 "strings" 10 10 11 11 _ "github.com/mattn/go-sqlite3" 12 - "tangled.org/core/log" 13 12 ) 14 13 15 14 type DB struct { 16 15 *sql.DB 17 - logger *slog.Logger 18 16 } 19 17 20 18 type Execer interface { ··· 28 26 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 29 27 } 30 28 31 - func Make(ctx context.Context, dbPath string) (*DB, error) { 29 + func Make(dbPath string) (*DB, error) { 32 30 // https://github.com/mattn/go-sqlite3#connection-string 33 31 opts := []string{ 34 32 "_foreign_keys=1", ··· 37 35 "_auto_vacuum=incremental", 38 36 } 39 37 40 - logger := log.FromContext(ctx) 41 - logger = log.SubLogger(logger, "db") 42 - 43 38 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 44 39 if err != nil { 45 40 return nil, err 46 41 } 42 + 43 + ctx := context.Background() 47 44 48 45 conn, err := db.Conn(ctx) 49 46 if err != nil { ··· 577 574 } 578 575 579 576 // run migrations 580 - runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 577 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 581 578 tx.Exec(` 582 579 alter table repos add column description text check (length(description) <= 200); 583 580 `) 584 581 return nil 585 582 }) 586 583 587 - runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 584 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 588 585 // add unconstrained column 589 586 _, err := tx.Exec(` 590 587 alter table public_keys ··· 607 604 return nil 608 605 }) 609 606 610 - runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 607 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 611 608 _, err := tx.Exec(` 612 609 alter table comments drop column comment_at; 613 610 alter table comments add column rkey text; ··· 615 612 return err 616 613 }) 617 614 618 - runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 615 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 619 616 _, err := tx.Exec(` 620 617 alter table comments add column deleted text; -- timestamp 621 618 alter table comments add column edited text; -- timestamp ··· 623 620 return err 624 621 }) 625 622 626 - runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 623 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 627 624 _, err := tx.Exec(` 628 625 alter table pulls add column source_branch text; 629 626 alter table pulls add column source_repo_at text; ··· 632 629 return err 633 630 }) 634 631 635 - runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 632 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 636 633 _, err := tx.Exec(` 637 634 alter table repos add column source text; 638 635 `) ··· 644 641 // 645 642 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 646 643 conn.ExecContext(ctx, "pragma foreign_keys = off;") 647 - runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 644 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 648 645 _, err := tx.Exec(` 649 646 create table pulls_new ( 650 647 -- identifiers ··· 701 698 }) 702 699 conn.ExecContext(ctx, "pragma foreign_keys = on;") 703 700 704 - runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 701 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 705 702 tx.Exec(` 706 703 alter table repos add column spindle text; 707 704 `) ··· 711 708 // drop all knot secrets, add unique constraint to knots 712 709 // 713 710 // knots will henceforth use service auth for signed requests 714 - runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 711 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 715 712 _, err := tx.Exec(` 716 713 create table registrations_new ( 717 714 id integer primary key autoincrement, ··· 734 731 }) 735 732 736 733 // recreate and add rkey + created columns with default constraint 737 - runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 734 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 738 735 // create new table 739 736 // - repo_at instead of repo integer 740 737 // - rkey field ··· 788 785 return err 789 786 }) 790 787 791 - runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 788 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 792 789 _, err := tx.Exec(` 793 790 alter table issues add column rkey text not null default ''; 794 791 ··· 800 797 }) 801 798 802 799 // repurpose the read-only column to "needs-upgrade" 803 - runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 800 + runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 804 801 _, err := tx.Exec(` 805 802 alter table registrations rename column read_only to needs_upgrade; 806 803 `) ··· 808 805 }) 809 806 810 807 // require all knots to upgrade after the release of total xrpc 811 - runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 808 + runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 812 809 _, err := tx.Exec(` 813 810 update registrations set needs_upgrade = 1; 814 811 `) ··· 816 813 }) 817 814 818 815 // require all knots to upgrade after the release of total xrpc 819 - runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 816 + runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 820 817 _, err := tx.Exec(` 821 818 alter table spindles add column needs_upgrade integer not null default 0; 822 819 `) ··· 834 831 // 835 832 // disable foreign-keys for the next migration 836 833 conn.ExecContext(ctx, "pragma foreign_keys = off;") 837 - runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 834 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 838 835 _, err := tx.Exec(` 839 836 create table if not exists issues_new ( 840 837 -- identifiers ··· 904 901 // - new columns 905 902 // * column "reply_to" which can be any other comment 906 903 // * column "at-uri" which is a generated column 907 - runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 904 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 908 905 _, err := tx.Exec(` 909 906 create table if not exists issue_comments ( 910 907 -- identifiers ··· 957 954 return err 958 955 }) 959 956 960 - // add generated at_uri column to pulls table 961 - // 962 - // this requires a full table recreation because stored columns 963 - // cannot be added via alter 964 - // 965 - // disable foreign-keys for the next migration 966 - conn.ExecContext(ctx, "pragma foreign_keys = off;") 967 - runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 968 - _, err := tx.Exec(` 969 - create table if not exists pulls_new ( 970 - -- identifiers 971 - id integer primary key autoincrement, 972 - pull_id integer not null, 973 - at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 974 - 975 - -- at identifiers 976 - repo_at text not null, 977 - owner_did text not null, 978 - rkey text not null, 979 - 980 - -- content 981 - title text not null, 982 - body text not null, 983 - target_branch text not null, 984 - state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 985 - 986 - -- source info 987 - source_branch text, 988 - source_repo_at text, 989 - 990 - -- stacking 991 - stack_id text, 992 - change_id text, 993 - parent_change_id text, 994 - 995 - -- meta 996 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 997 - 998 - -- constraints 999 - unique(repo_at, pull_id), 1000 - unique(at_uri), 1001 - foreign key (repo_at) references repos(at_uri) on delete cascade 1002 - ); 1003 - `) 1004 - if err != nil { 1005 - return err 1006 - } 1007 - 1008 - // transfer data 1009 - _, err = tx.Exec(` 1010 - insert into pulls_new ( 1011 - id, pull_id, repo_at, owner_did, rkey, 1012 - title, body, target_branch, state, 1013 - source_branch, source_repo_at, 1014 - stack_id, change_id, parent_change_id, 1015 - created 1016 - ) 1017 - select 1018 - id, pull_id, repo_at, owner_did, rkey, 1019 - title, body, target_branch, state, 1020 - source_branch, source_repo_at, 1021 - stack_id, change_id, parent_change_id, 1022 - created 1023 - from pulls; 1024 - `) 1025 - if err != nil { 1026 - return err 1027 - } 1028 - 1029 - // drop old table 1030 - _, err = tx.Exec(`drop table pulls`) 1031 - if err != nil { 1032 - return err 1033 - } 1034 - 1035 - // rename new table 1036 - _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1037 - return err 1038 - }) 1039 - conn.ExecContext(ctx, "pragma foreign_keys = on;") 1040 - 1041 - // remove repo_at and pull_id from pull_submissions and replace with pull_at 1042 - // 1043 - // this requires a full table recreation because stored columns 1044 - // cannot be added via alter 1045 - // 1046 - // disable foreign-keys for the next migration 1047 - conn.ExecContext(ctx, "pragma foreign_keys = off;") 1048 - runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1049 - _, err := tx.Exec(` 1050 - create table if not exists pull_submissions_new ( 1051 - -- identifiers 1052 - id integer primary key autoincrement, 1053 - pull_at text not null, 1054 - 1055 - -- content, these are immutable, and require a resubmission to update 1056 - round_number integer not null default 0, 1057 - patch text, 1058 - source_rev text, 1059 - 1060 - -- meta 1061 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1062 - 1063 - -- constraints 1064 - unique(pull_at, round_number), 1065 - foreign key (pull_at) references pulls(at_uri) on delete cascade 1066 - ); 1067 - `) 1068 - if err != nil { 1069 - return err 1070 - } 1071 - 1072 - // transfer data, constructing pull_at from pulls table 1073 - _, err = tx.Exec(` 1074 - insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1075 - select 1076 - ps.id, 1077 - 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1078 - ps.round_number, 1079 - ps.patch, 1080 - ps.created 1081 - from pull_submissions ps 1082 - join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1083 - `) 1084 - if err != nil { 1085 - return err 1086 - } 1087 - 1088 - // drop old table 1089 - _, err = tx.Exec(`drop table pull_submissions`) 1090 - if err != nil { 1091 - return err 1092 - } 1093 - 1094 - // rename new table 1095 - _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1096 - return err 1097 - }) 1098 - conn.ExecContext(ctx, "pragma foreign_keys = on;") 1099 - 1100 - // knots may report the combined patch for a comparison, we can store that on the appview side 1101 - // (but not on the pds record), because calculating the combined patch requires a git index 1102 - runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1103 - _, err := tx.Exec(` 1104 - alter table pull_submissions add column combined text; 1105 - `) 1106 - return err 1107 - }) 1108 - 1109 - runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1110 - _, err := tx.Exec(` 1111 - alter table profile add column pronouns text; 1112 - `) 1113 - return err 1114 - }) 1115 - 1116 - runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1117 - _, err := tx.Exec(` 1118 - alter table repos add column website text; 1119 - alter table repos add column topics text; 1120 - `) 1121 - return err 1122 - }) 1123 - 1124 - runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1125 - _, err := tx.Exec(` 1126 - alter table notification_preferences add column user_mentioned integer not null default 1; 1127 - `) 1128 - return err 1129 - }) 1130 - 1131 - return &DB{ 1132 - db, 1133 - logger, 1134 - }, nil 957 + return &DB{db}, nil 1135 958 } 1136 959 1137 960 type migrationFn = func(*sql.Tx) error 1138 961 1139 - func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1140 - logger = logger.With("migration", name) 1141 - 962 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1142 963 tx, err := c.BeginTx(context.Background(), nil) 1143 964 if err != nil { 1144 965 return err ··· 1155 976 // run migration 1156 977 err = migrationFn(tx) 1157 978 if err != nil { 1158 - logger.Error("failed to run migration", "err", err) 979 + log.Printf("Failed to run migration %s: %v", name, err) 1159 980 return err 1160 981 } 1161 982 1162 983 // mark migration as complete 1163 984 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1164 985 if err != nil { 1165 - logger.Error("failed to mark migration as complete", "err", err) 986 + log.Printf("Failed to mark migration %s as complete: %v", name, err) 1166 987 return err 1167 988 } 1168 989 ··· 1171 992 return err 1172 993 } 1173 994 1174 - logger.Info("migration applied successfully") 995 + log.Printf("migration %s applied successfully", name) 1175 996 } else { 1176 - logger.Warn("skipped migration, already applied") 997 + log.Printf("skipped migration %s, already applied", name) 1177 998 } 1178 999 1179 1000 return nil
+9 -13
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(emails) == 0 { 74 + func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(ems) == 0 { 76 76 return make(map[string]string), nil 77 77 } 78 78 ··· 80 80 if isVerifiedFilter { 81 81 verifiedFilter = 1 82 82 } 83 - 84 - assoc := make(map[string]string) 85 83 86 84 // Create placeholders for the IN clause 87 - placeholders := make([]string, 0, len(emails)) 88 - args := make([]any, 1, len(emails)+1) 85 + placeholders := make([]string, len(ems)) 86 + args := make([]any, len(ems)+1) 89 87 90 88 args[0] = verifiedFilter 91 - for _, email := range emails { 92 - if strings.HasPrefix(email, "did:") { 93 - assoc[email] = email 94 - continue 95 - } 96 - placeholders = append(placeholders, "?") 97 - args = append(args, email) 89 + for i, em := range ems { 90 + placeholders[i] = "?" 91 + args[i+1] = em 98 92 } 99 93 100 94 query := ` ··· 110 104 return nil, err 111 105 } 112 106 defer rows.Close() 107 + 108 + assoc := make(map[string]string) 113 109 114 110 for rows.Next() { 115 111 var email, did string
+16 -72
appview/db/issues.go
··· 101 101 pLower := FilterGte("row_num", page.Offset+1) 102 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 103 104 - pageClause := "" 105 - if page.Limit > 0 { 106 - args = append(args, pLower.Arg()...) 107 - args = append(args, pUpper.Arg()...) 108 - pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 109 - } 104 + args = append(args, pLower.Arg()...) 105 + args = append(args, pUpper.Arg()...) 106 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 110 107 111 108 query := fmt.Sprintf( 112 109 ` ··· 131 128 %s 132 129 `, 133 130 whereClause, 134 - pageClause, 131 + pagination, 135 132 ) 136 133 137 134 rows, err := e.Query(query, args...) ··· 246 243 return issues, nil 247 244 } 248 245 249 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 250 - issues, err := GetIssuesPaginated( 251 - e, 252 - pagination.Page{}, 253 - FilterEq("repo_at", repoAt), 254 - FilterEq("issue_id", issueId), 255 - ) 256 - if err != nil { 257 - return nil, err 258 - } 259 - if len(issues) != 1 { 260 - return nil, sql.ErrNoRows 261 - } 262 - 263 - return &issues[0], nil 264 - } 265 - 266 246 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 267 - return GetIssuesPaginated(e, pagination.Page{}, filters...) 247 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 268 248 } 269 249 270 - // GetIssueIDs gets list of all existing issue's IDs 271 - func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 272 - var ids []int64 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 + row := e.QueryRow(query, repoAt, issueId) 273 253 274 - var filters []filter 275 - openValue := 0 276 - if opts.IsOpen { 277 - openValue = 1 278 - } 279 - filters = append(filters, FilterEq("open", openValue)) 280 - if opts.RepoAt != "" { 281 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 254 + var issue models.Issue 255 + var createdAt string 256 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 + if err != nil { 258 + return nil, err 282 259 } 283 260 284 - var conditions []string 285 - var args []any 286 - 287 - for _, filter := range filters { 288 - conditions = append(conditions, filter.Condition()) 289 - args = append(args, filter.Arg()...) 290 - } 291 - 292 - whereClause := "" 293 - if conditions != nil { 294 - whereClause = " where " + strings.Join(conditions, " and ") 295 - } 296 - query := fmt.Sprintf( 297 - ` 298 - select 299 - id 300 - from 301 - issues 302 - %s 303 - limit ? offset ?`, 304 - whereClause, 305 - ) 306 - args = append(args, opts.Page.Limit, opts.Page.Offset) 307 - rows, err := e.Query(query, args...) 261 + createdTime, err := time.Parse(time.RFC3339, createdAt) 308 262 if err != nil { 309 263 return nil, err 310 264 } 311 - defer rows.Close() 265 + issue.Created = createdTime 312 266 313 - for rows.Next() { 314 - var id int64 315 - err := rows.Scan(&id) 316 - if err != nil { 317 - return nil, err 318 - } 319 - 320 - ids = append(ids, id) 321 - } 322 - 323 - return ids, nil 267 + return &issue, nil 324 268 } 325 269 326 270 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
-34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 - "database/sql" 5 4 "fmt" 6 5 "strings" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 7 "tangled.org/core/appview/models" 10 8 ) 11 9 ··· 84 82 85 83 return nil 86 84 } 87 - 88 - func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 - var conditions []string 90 - var args []any 91 - for _, filter := range filters { 92 - conditions = append(conditions, filter.Condition()) 93 - args = append(args, filter.Arg()...) 94 - } 95 - 96 - whereClause := "" 97 - if conditions != nil { 98 - whereClause = " where " + strings.Join(conditions, " and ") 99 - } 100 - 101 - query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 - 103 - _, err := e.Exec(query, args...) 104 - return err 105 - } 106 - 107 - func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 - err := DeleteRepoLanguages( 109 - tx, 110 - FilterEq("repo_at", repoAt), 111 - FilterEq("ref", ref), 112 - ) 113 - if err != nil { 114 - return fmt.Errorf("failed to delete existing languages: %w", err) 115 - } 116 - 117 - return InsertRepoLanguages(tx, langs) 118 - }
+54 -103
appview/db/notifications.go
··· 8 8 "strings" 9 9 "time" 10 10 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 11 "tangled.org/core/appview/models" 13 12 "tangled.org/core/appview/pagination" 14 13 ) 15 14 16 - func CreateNotification(e Execer, notification *models.Notification) error { 15 + func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 17 16 query := ` 18 17 INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 19 18 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 20 19 ` 21 20 22 - result, err := e.Exec(query, 21 + result, err := d.DB.ExecContext(ctx, query, 23 22 notification.RecipientDid, 24 23 notification.ActorDid, 25 24 string(notification.Type), ··· 59 58 for _, condition := range conditions[1:] { 60 59 whereClause += " AND " + condition 61 60 } 62 - } 63 - pageClause := "" 64 - if page.Limit > 0 { 65 - pageClause = " limit ? offset ? " 66 - args = append(args, page.Limit, page.Offset) 67 61 } 68 62 69 63 query := fmt.Sprintf(` ··· 71 65 from notifications 72 66 %s 73 67 order by created desc 74 - %s 75 - `, whereClause, pageClause) 68 + limit ? offset ? 69 + `, whereClause) 70 + 71 + args = append(args, page.Limit, page.Offset) 76 72 77 73 rows, err := e.QueryContext(context.Background(), query, args...) 78 74 if err != nil { ··· 134 130 select 135 131 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 136 132 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 137 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 133 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 138 134 i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 139 135 p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 140 136 from notifications n ··· 163 159 var issue models.Issue 164 160 var pull models.Pull 165 161 var rId, iId, pId sql.NullInt64 166 - var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 162 + var rDid, rName, rDescription sql.NullString 167 163 var iDid sql.NullString 168 164 var iIssueId sql.NullInt64 169 165 var iTitle sql.NullString ··· 176 172 err := rows.Scan( 177 173 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 178 174 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 179 - &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 175 + &rId, &rDid, &rName, &rDescription, 180 176 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 181 177 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 182 178 ) ··· 203 199 } 204 200 if rDescription.Valid { 205 201 repo.Description = rDescription.String 206 - } 207 - if rWebsite.Valid { 208 - repo.Website = rWebsite.String 209 - } 210 - if rTopicStr.Valid { 211 - repo.Topics = strings.Fields(rTopicStr.String) 212 202 } 213 203 nwe.Repo = &repo 214 204 } ··· 284 274 return count, nil 285 275 } 286 276 287 - func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 277 + func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 288 278 idFilter := FilterEq("id", notificationID) 289 279 recipientFilter := FilterEq("recipient_did", userDID) 290 280 ··· 296 286 297 287 args := append(idFilter.Arg(), recipientFilter.Arg()...) 298 288 299 - result, err := e.Exec(query, args...) 289 + result, err := d.DB.ExecContext(ctx, query, args...) 300 290 if err != nil { 301 291 return fmt.Errorf("failed to mark notification as read: %w", err) 302 292 } ··· 313 303 return nil 314 304 } 315 305 316 - func MarkAllNotificationsRead(e Execer, userDID string) error { 306 + func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 317 307 recipientFilter := FilterEq("recipient_did", userDID) 318 308 readFilter := FilterEq("read", 0) 319 309 ··· 325 315 326 316 args := append(recipientFilter.Arg(), readFilter.Arg()...) 327 317 328 - _, err := e.Exec(query, args...) 318 + _, err := d.DB.ExecContext(ctx, query, args...) 329 319 if err != nil { 330 320 return fmt.Errorf("failed to mark all notifications as read: %w", err) 331 321 } ··· 333 323 return nil 334 324 } 335 325 336 - func DeleteNotification(e Execer, notificationID int64, userDID string) error { 326 + func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 337 327 idFilter := FilterEq("id", notificationID) 338 328 recipientFilter := FilterEq("recipient_did", userDID) 339 329 ··· 344 334 345 335 args := append(idFilter.Arg(), recipientFilter.Arg()...) 346 336 347 - result, err := e.Exec(query, args...) 337 + result, err := d.DB.ExecContext(ctx, query, args...) 348 338 if err != nil { 349 339 return fmt.Errorf("failed to delete notification: %w", err) 350 340 } ··· 361 351 return nil 362 352 } 363 353 364 - func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 365 - prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 366 - if err != nil { 367 - return nil, err 368 - } 354 + func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 + userFilter := FilterEq("user_did", userDID) 369 356 370 - p, ok := prefs[syntax.DID(userDid)] 371 - if !ok { 372 - return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 373 - } 357 + query := fmt.Sprintf(` 358 + SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 + pull_commented, followed, pull_merged, issue_closed, email_notifications 360 + FROM notification_preferences 361 + WHERE %s 362 + `, userFilter.Condition()) 374 363 375 - return p, nil 376 - } 364 + var prefs models.NotificationPreferences 365 + err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 + &prefs.ID, 367 + &prefs.UserDid, 368 + &prefs.RepoStarred, 369 + &prefs.IssueCreated, 370 + &prefs.IssueCommented, 371 + &prefs.PullCreated, 372 + &prefs.PullCommented, 373 + &prefs.Followed, 374 + &prefs.PullMerged, 375 + &prefs.IssueClosed, 376 + &prefs.EmailNotifications, 377 + ) 377 378 378 - func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 379 - prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 380 - 381 - var conditions []string 382 - var args []any 383 - for _, filter := range filters { 384 - conditions = append(conditions, filter.Condition()) 385 - args = append(args, filter.Arg()...) 386 - } 387 - 388 - whereClause := "" 389 - if conditions != nil { 390 - whereClause = " where " + strings.Join(conditions, " and ") 391 - } 392 - 393 - query := fmt.Sprintf(` 394 - select 395 - id, 396 - user_did, 397 - repo_starred, 398 - issue_created, 399 - issue_commented, 400 - pull_created, 401 - pull_commented, 402 - followed, 403 - user_mentioned, 404 - pull_merged, 405 - issue_closed, 406 - email_notifications 407 - from 408 - notification_preferences 409 - %s 410 - `, whereClause) 411 - 412 - rows, err := e.Query(query, args...) 413 379 if err != nil { 414 - return nil, err 415 - } 416 - defer rows.Close() 417 - 418 - for rows.Next() { 419 - var prefs models.NotificationPreferences 420 - if err := rows.Scan( 421 - &prefs.ID, 422 - &prefs.UserDid, 423 - &prefs.RepoStarred, 424 - &prefs.IssueCreated, 425 - &prefs.IssueCommented, 426 - &prefs.PullCreated, 427 - &prefs.PullCommented, 428 - &prefs.Followed, 429 - &prefs.UserMentioned, 430 - &prefs.PullMerged, 431 - &prefs.IssueClosed, 432 - &prefs.EmailNotifications, 433 - ); err != nil { 434 - return nil, err 380 + if err == sql.ErrNoRows { 381 + return &models.NotificationPreferences{ 382 + UserDid: userDID, 383 + RepoStarred: true, 384 + IssueCreated: true, 385 + IssueCommented: true, 386 + PullCreated: true, 387 + PullCommented: true, 388 + Followed: true, 389 + PullMerged: true, 390 + IssueClosed: true, 391 + EmailNotifications: false, 392 + }, nil 435 393 } 436 - 437 - prefsMap[prefs.UserDid] = &prefs 394 + return nil, fmt.Errorf("failed to get notification preferences: %w", err) 438 395 } 439 396 440 - if err := rows.Err(); err != nil { 441 - return nil, err 442 - } 443 - 444 - return prefsMap, nil 397 + return &prefs, nil 445 398 } 446 399 447 400 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 448 401 query := ` 449 402 INSERT OR REPLACE INTO notification_preferences 450 403 (user_did, repo_starred, issue_created, issue_commented, pull_created, 451 - pull_commented, followed, user_mentioned, pull_merged, issue_closed, 452 - email_notifications) 453 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 404 + pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 454 406 ` 455 407 456 408 result, err := d.DB.ExecContext(ctx, query, ··· 461 413 prefs.PullCreated, 462 414 prefs.PullCommented, 463 415 prefs.Followed, 464 - prefs.UserMentioned, 465 416 prefs.PullMerged, 466 417 prefs.IssueClosed, 467 418 prefs.EmailNotifications,
+6 -26
appview/db/profile.go
··· 129 129 did, 130 130 description, 131 131 include_bluesky, 132 - location, 133 - pronouns 132 + location 134 133 ) 135 - values (?, ?, ?, ?, ?)`, 134 + values (?, ?, ?, ?)`, 136 135 profile.Did, 137 136 profile.Description, 138 137 includeBskyValue, 139 138 profile.Location, 140 - profile.Pronouns, 141 139 ) 142 140 143 141 if err != nil { ··· 218 216 did, 219 217 description, 220 218 include_bluesky, 221 - location, 222 - pronouns 219 + location 223 220 from 224 221 profile 225 222 %s`, ··· 234 231 for rows.Next() { 235 232 var profile models.Profile 236 233 var includeBluesky int 237 - var pronouns sql.Null[string] 238 234 239 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 235 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 240 236 if err != nil { 241 237 return nil, err 242 238 } 243 239 244 240 if includeBluesky != 0 { 245 241 profile.IncludeBluesky = true 246 - } 247 - 248 - if pronouns.Valid { 249 - profile.Pronouns = pronouns.V 250 242 } 251 243 252 244 profileMap[profile.Did] = &profile ··· 310 302 311 303 func GetProfile(e Execer, did string) (*models.Profile, error) { 312 304 var profile models.Profile 313 - var pronouns sql.Null[string] 314 - 315 305 profile.Did = did 316 306 317 307 includeBluesky := 0 318 - 319 308 err := e.QueryRow( 320 - `select description, include_bluesky, location, pronouns from profile where did = ?`, 309 + `select description, include_bluesky, location from profile where did = ?`, 321 310 did, 322 - ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 311 + ).Scan(&profile.Description, &includeBluesky, &profile.Location) 323 312 if err == sql.ErrNoRows { 324 313 profile := models.Profile{} 325 314 profile.Did = did ··· 332 321 333 322 if includeBluesky != 0 { 334 323 profile.IncludeBluesky = true 335 - } 336 - 337 - if pronouns.Valid { 338 - profile.Pronouns = pronouns.V 339 324 } 340 325 341 326 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 427 412 // ensure description is not too long 428 413 if len(profile.Location) > 40 { 429 414 return fmt.Errorf("Entered location is too long.") 430 - } 431 - 432 - // ensure pronouns are not too long 433 - if len(profile.Pronouns) > 40 { 434 - return fmt.Errorf("Entered pronouns are too long.") 435 415 } 436 416 437 417 // ensure links are in order
+221 -201
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 - "cmp" 5 4 "database/sql" 6 - "errors" 7 5 "fmt" 8 - "maps" 9 - "slices" 6 + "log" 10 7 "sort" 11 8 "strings" 12 9 "time" ··· 90 87 pull.ID = int(id) 91 88 92 89 _, err = tx.Exec(` 93 - insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 90 + insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 94 91 values (?, ?, ?, ?, ?) 95 - `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 92 + `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 96 93 return err 97 94 } 98 95 ··· 101 98 if err != nil { 102 99 return "", err 103 100 } 104 - return pull.AtUri(), err 101 + return pull.PullAt(), err 105 102 } 106 103 107 104 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 111 108 } 112 109 113 110 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 - pulls := make(map[syntax.ATURI]*models.Pull) 111 + pulls := make(map[int]*models.Pull) 115 112 116 113 var conditions []string 117 114 var args []any ··· 214 211 pull.ParentChangeId = parentChangeId.String 215 212 } 216 213 217 - pulls[pull.AtUri()] = &pull 214 + pulls[pull.PullId] = &pull 218 215 } 219 216 220 - var pullAts []syntax.ATURI 217 + // get latest round no. for each pull 218 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 219 + submissionsQuery := fmt.Sprintf(` 220 + select 221 + id, pull_id, round_number, patch, created, source_rev 222 + from 223 + pull_submissions 224 + where 225 + repo_at in (%s) and pull_id in (%s) 226 + `, inClause, inClause) 227 + 228 + args = make([]any, len(pulls)*2) 229 + idx := 0 230 + for _, p := range pulls { 231 + args[idx] = p.RepoAt 232 + idx += 1 233 + } 221 234 for _, p := range pulls { 222 - pullAts = append(pullAts, p.AtUri()) 235 + args[idx] = p.PullId 236 + idx += 1 223 237 } 224 - submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 238 + submissionsRows, err := e.Query(submissionsQuery, args...) 225 239 if err != nil { 226 - return nil, fmt.Errorf("failed to get submissions: %w", err) 240 + return nil, err 227 241 } 242 + defer submissionsRows.Close() 228 243 229 - for pullAt, submissions := range submissionsMap { 230 - if p, ok := pulls[pullAt]; ok { 231 - p.Submissions = submissions 244 + for submissionsRows.Next() { 245 + var s models.PullSubmission 246 + var sourceRev sql.NullString 247 + var createdAt string 248 + err := submissionsRows.Scan( 249 + &s.ID, 250 + &s.PullId, 251 + &s.RoundNumber, 252 + &s.Patch, 253 + &createdAt, 254 + &sourceRev, 255 + ) 256 + if err != nil { 257 + return nil, err 232 258 } 233 - } 259 + 260 + createdTime, err := time.Parse(time.RFC3339, createdAt) 261 + if err != nil { 262 + return nil, err 263 + } 264 + s.Created = createdTime 265 + 266 + if sourceRev.Valid { 267 + s.SourceRev = sourceRev.String 268 + } 234 269 235 - // collect allLabels for each issue 236 - allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 - if err != nil { 238 - return nil, fmt.Errorf("failed to query labels: %w", err) 270 + if p, ok := pulls[s.PullId]; ok { 271 + p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 272 + p.Submissions[s.RoundNumber] = &s 273 + } 239 274 } 240 - for pullAt, labels := range allLabels { 241 - if p, ok := pulls[pullAt]; ok { 242 - p.Labels = labels 243 - } 275 + if err := rows.Err(); err != nil { 276 + return nil, err 244 277 } 245 278 246 - // collect pull source for all pulls that need it 247 - var sourceAts []syntax.ATURI 279 + // get comment count on latest submission on each pull 280 + inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 281 + commentsQuery := fmt.Sprintf(` 282 + select 283 + count(id), pull_id 284 + from 285 + pull_comments 286 + where 287 + submission_id in (%s) 288 + group by 289 + submission_id 290 + `, inClause) 291 + 292 + args = []any{} 248 293 for _, p := range pulls { 249 - if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 - sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 - } 294 + args = append(args, p.Submissions[p.LastRoundNumber()].ID) 252 295 } 253 - sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 - return nil, fmt.Errorf("failed to get source repos: %w", err) 296 + commentsRows, err := e.Query(commentsQuery, args...) 297 + if err != nil { 298 + return nil, err 256 299 } 257 - sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 - for _, r := range sourceRepos { 259 - sourceRepoMap[r.RepoAt()] = &r 300 + defer commentsRows.Close() 301 + 302 + for commentsRows.Next() { 303 + var commentCount, pullId int 304 + err := commentsRows.Scan( 305 + &commentCount, 306 + &pullId, 307 + ) 308 + if err != nil { 309 + return nil, err 310 + } 311 + if p, ok := pulls[pullId]; ok { 312 + p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 313 + } 260 314 } 261 - for _, p := range pulls { 262 - if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 - if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 - p.PullSource.Repo = sourceRepo 265 - } 266 - } 315 + if err := rows.Err(); err != nil { 316 + return nil, err 267 317 } 268 318 269 319 orderedByPullId := []*models.Pull{} ··· 281 331 return GetPullsWithLimit(e, 0, filters...) 282 332 } 283 333 284 - func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 285 - var ids []int64 286 - 287 - var filters []filter 288 - filters = append(filters, FilterEq("state", opts.State)) 289 - if opts.RepoAt != "" { 290 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 291 - } 292 - 293 - var conditions []string 294 - var args []any 295 - 296 - for _, filter := range filters { 297 - conditions = append(conditions, filter.Condition()) 298 - args = append(args, filter.Arg()...) 299 - } 300 - 301 - whereClause := "" 302 - if conditions != nil { 303 - whereClause = " where " + strings.Join(conditions, " and ") 304 - } 305 - pageClause := "" 306 - if opts.Page.Limit != 0 { 307 - pageClause = fmt.Sprintf( 308 - " limit %d offset %d ", 309 - opts.Page.Limit, 310 - opts.Page.Offset, 311 - ) 312 - } 313 - 314 - query := fmt.Sprintf( 315 - ` 334 + func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 335 + query := ` 316 336 select 317 - id 337 + id, 338 + owner_did, 339 + pull_id, 340 + created, 341 + title, 342 + state, 343 + target_branch, 344 + repo_at, 345 + body, 346 + rkey, 347 + source_branch, 348 + source_repo_at, 349 + stack_id, 350 + change_id, 351 + parent_change_id 318 352 from 319 353 pulls 320 - %s 321 - %s`, 322 - whereClause, 323 - pageClause, 354 + where 355 + repo_at = ? and pull_id = ? 356 + ` 357 + row := e.QueryRow(query, repoAt, pullId) 358 + 359 + var pull models.Pull 360 + var createdAt string 361 + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 362 + err := row.Scan( 363 + &pull.ID, 364 + &pull.OwnerDid, 365 + &pull.PullId, 366 + &createdAt, 367 + &pull.Title, 368 + &pull.State, 369 + &pull.TargetBranch, 370 + &pull.RepoAt, 371 + &pull.Body, 372 + &pull.Rkey, 373 + &sourceBranch, 374 + &sourceRepoAt, 375 + &stackId, 376 + &changeId, 377 + &parentChangeId, 324 378 ) 325 - args = append(args, opts.Page.Limit, opts.Page.Offset) 326 - rows, err := e.Query(query, args...) 327 379 if err != nil { 328 380 return nil, err 329 381 } 330 - defer rows.Close() 331 382 332 - for rows.Next() { 333 - var id int64 334 - err := rows.Scan(&id) 335 - if err != nil { 336 - return nil, err 337 - } 338 - 339 - ids = append(ids, id) 340 - } 341 - 342 - return ids, nil 343 - } 344 - 345 - func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 346 - pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 383 + createdTime, err := time.Parse(time.RFC3339, createdAt) 347 384 if err != nil { 348 385 return nil, err 349 386 } 350 - if len(pulls) == 0 { 351 - return nil, sql.ErrNoRows 352 - } 387 + pull.Created = createdTime 353 388 354 - return pulls[0], nil 355 - } 356 - 357 - // mapping from pull -> pull submissions 358 - func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 359 - var conditions []string 360 - var args []any 361 - for _, filter := range filters { 362 - conditions = append(conditions, filter.Condition()) 363 - args = append(args, filter.Arg()...) 389 + // populate source 390 + if sourceBranch.Valid { 391 + pull.PullSource = &models.PullSource{ 392 + Branch: sourceBranch.String, 393 + } 394 + if sourceRepoAt.Valid { 395 + sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 396 + if err != nil { 397 + return nil, err 398 + } 399 + pull.PullSource.RepoAt = &sourceRepoAtParsed 400 + } 364 401 } 365 402 366 - whereClause := "" 367 - if conditions != nil { 368 - whereClause = " where " + strings.Join(conditions, " and ") 403 + if stackId.Valid { 404 + pull.StackId = stackId.String 405 + } 406 + if changeId.Valid { 407 + pull.ChangeId = changeId.String 408 + } 409 + if parentChangeId.Valid { 410 + pull.ParentChangeId = parentChangeId.String 369 411 } 370 412 371 - query := fmt.Sprintf(` 413 + submissionsQuery := ` 372 414 select 373 - id, 374 - pull_at, 375 - round_number, 376 - patch, 377 - combined, 378 - created, 379 - source_rev 415 + id, pull_id, repo_at, round_number, patch, created, source_rev 380 416 from 381 417 pull_submissions 382 - %s 383 - order by 384 - round_number asc 385 - `, whereClause) 386 - 387 - rows, err := e.Query(query, args...) 418 + where 419 + repo_at = ? and pull_id = ? 420 + ` 421 + submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 388 422 if err != nil { 389 423 return nil, err 390 424 } 391 - defer rows.Close() 425 + defer submissionsRows.Close() 392 426 393 - submissionMap := make(map[int]*models.PullSubmission) 427 + submissionsMap := make(map[int]*models.PullSubmission) 394 428 395 - for rows.Next() { 429 + for submissionsRows.Next() { 396 430 var submission models.PullSubmission 397 431 var submissionCreatedStr string 398 - var submissionSourceRev, submissionCombined sql.NullString 399 - err := rows.Scan( 432 + var submissionSourceRev sql.NullString 433 + err := submissionsRows.Scan( 400 434 &submission.ID, 401 - &submission.PullAt, 435 + &submission.PullId, 436 + &submission.RepoAt, 402 437 &submission.RoundNumber, 403 438 &submission.Patch, 404 - &submissionCombined, 405 439 &submissionCreatedStr, 406 440 &submissionSourceRev, 407 441 ) ··· 409 443 return nil, err 410 444 } 411 445 412 - if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 413 - submission.Created = t 446 + submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 447 + if err != nil { 448 + return nil, err 414 449 } 450 + submission.Created = submissionCreatedTime 415 451 416 452 if submissionSourceRev.Valid { 417 453 submission.SourceRev = submissionSourceRev.String 418 454 } 419 455 420 - if submissionCombined.Valid { 421 - submission.Combined = submissionCombined.String 422 - } 423 - 424 - submissionMap[submission.ID] = &submission 456 + submissionsMap[submission.ID] = &submission 425 457 } 426 - 427 - if err := rows.Err(); err != nil { 458 + if err = submissionsRows.Close(); err != nil { 428 459 return nil, err 429 460 } 430 - 431 - // Get comments for all submissions using GetPullComments 432 - submissionIds := slices.Collect(maps.Keys(submissionMap)) 433 - comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 434 - if err != nil { 435 - return nil, err 436 - } 437 - for _, comment := range comments { 438 - if submission, ok := submissionMap[comment.SubmissionId]; ok { 439 - submission.Comments = append(submission.Comments, comment) 440 - } 461 + if len(submissionsMap) == 0 { 462 + return &pull, nil 441 463 } 442 464 443 - // group the submissions by pull_at 444 - m := make(map[syntax.ATURI][]*models.PullSubmission) 445 - for _, s := range submissionMap { 446 - m[s.PullAt] = append(m[s.PullAt], s) 447 - } 448 - 449 - // sort each one by round number 450 - for _, s := range m { 451 - slices.SortFunc(s, func(a, b *models.PullSubmission) int { 452 - return cmp.Compare(a.RoundNumber, b.RoundNumber) 453 - }) 454 - } 455 - 456 - return m, nil 457 - } 458 - 459 - func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 460 - var conditions []string 461 465 var args []any 462 - for _, filter := range filters { 463 - conditions = append(conditions, filter.Condition()) 464 - args = append(args, filter.Arg()...) 465 - } 466 - 467 - whereClause := "" 468 - if conditions != nil { 469 - whereClause = " where " + strings.Join(conditions, " and ") 466 + for k := range submissionsMap { 467 + args = append(args, k) 470 468 } 471 - 472 - query := fmt.Sprintf(` 469 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 470 + commentsQuery := fmt.Sprintf(` 473 471 select 474 472 id, 475 473 pull_id, ··· 481 479 created 482 480 from 483 481 pull_comments 484 - %s 482 + where 483 + submission_id IN (%s) 485 484 order by 486 485 created asc 487 - `, whereClause) 488 - 489 - rows, err := e.Query(query, args...) 486 + `, inClause) 487 + commentsRows, err := e.Query(commentsQuery, args...) 490 488 if err != nil { 491 489 return nil, err 492 490 } 493 - defer rows.Close() 491 + defer commentsRows.Close() 494 492 495 - var comments []models.PullComment 496 - for rows.Next() { 493 + for commentsRows.Next() { 497 494 var comment models.PullComment 498 - var createdAt string 499 - err := rows.Scan( 495 + var commentCreatedStr string 496 + err := commentsRows.Scan( 500 497 &comment.ID, 501 498 &comment.PullId, 502 499 &comment.SubmissionId, ··· 504 501 &comment.OwnerDid, 505 502 &comment.CommentAt, 506 503 &comment.Body, 507 - &createdAt, 504 + &commentCreatedStr, 508 505 ) 509 506 if err != nil { 510 507 return nil, err 511 508 } 512 509 513 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 514 - comment.Created = t 510 + commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 511 + if err != nil { 512 + return nil, err 513 + } 514 + comment.Created = commentCreatedTime 515 + 516 + // Add the comment to its submission 517 + if submission, ok := submissionsMap[comment.SubmissionId]; ok { 518 + submission.Comments = append(submission.Comments, comment) 515 519 } 516 520 517 - comments = append(comments, comment) 518 521 } 519 - 520 - if err := rows.Err(); err != nil { 522 + if err = commentsRows.Err(); err != nil { 521 523 return nil, err 522 524 } 523 525 524 - return comments, nil 526 + var pullSourceRepo *models.Repo 527 + if pull.PullSource != nil { 528 + if pull.PullSource.RepoAt != nil { 529 + pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 530 + if err != nil { 531 + log.Printf("failed to get repo by at uri: %v", err) 532 + } else { 533 + pull.PullSource.Repo = pullSourceRepo 534 + } 535 + } 536 + } 537 + 538 + pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 539 + for _, submission := range submissionsMap { 540 + pull.Submissions[submission.RoundNumber] = submission 541 + } 542 + 543 + return &pull, nil 525 544 } 526 545 527 546 // timeframe here is directly passed into the sql query filter, and any ··· 655 674 return err 656 675 } 657 676 658 - func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 677 + func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 678 + newRoundNumber := len(pull.Submissions) 659 679 _, err := e.Exec(` 660 - insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 680 + insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 661 681 values (?, ?, ?, ?, ?) 662 - `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 682 + `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 663 683 664 684 return err 665 685 }
+7 -34
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 - query := ` 67 - select kind, reacted_by_did, 68 - row_number() over (partition by kind order by created asc) as rn, 69 - count(*) over (partition by kind) as total 70 - from reactions 71 - where thread_at = ? 72 - order by kind, created asc` 73 - 74 - rows, err := e.Query(query, threadAt) 75 - if err != nil { 76 - return nil, err 77 - } 78 - defer rows.Close() 79 - 80 - reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 65 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 + countMap := map[models.ReactionKind]int{} 81 67 for _, kind := range models.OrderedReactionKinds { 82 - reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 - } 84 - 85 - for rows.Next() { 86 - var kind models.ReactionKind 87 - var did string 88 - var rn, total int 89 - if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 - return nil, err 68 + count, err := GetReactionCount(e, threadAt, kind) 69 + if err != nil { 70 + return map[models.ReactionKind]int{}, nil 91 71 } 92 - 93 - data := reactionMap[kind] 94 - data.Count = total 95 - if userLimit > 0 && rn <= userLimit { 96 - data.Users = append(data.Users, did) 97 - } 98 - reactionMap[kind] = data 72 + countMap[kind] = count 99 73 } 100 - 101 - return reactionMap, rows.Err() 74 + return countMap, nil 102 75 } 103 76 104 77 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+12 -50
appview/db/repos.go
··· 70 70 rkey, 71 71 created, 72 72 description, 73 - website, 74 - topics, 75 73 source, 76 74 spindle 77 75 from ··· 91 89 for rows.Next() { 92 90 var repo models.Repo 93 91 var createdAt string 94 - var description, website, topicStr, source, spindle sql.NullString 92 + var description, source, spindle sql.NullString 95 93 96 94 err := rows.Scan( 97 95 &repo.Id, ··· 101 99 &repo.Rkey, 102 100 &createdAt, 103 101 &description, 104 - &website, 105 - &topicStr, 106 102 &source, 107 103 &spindle, 108 104 ) ··· 115 111 } 116 112 if description.Valid { 117 113 repo.Description = description.String 118 - } 119 - if website.Valid { 120 - repo.Website = website.String 121 - } 122 - if topicStr.Valid { 123 - repo.Topics = strings.Fields(topicStr.String) 124 114 } 125 115 if source.Valid { 126 116 repo.Source = source.String ··· 366 356 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 367 357 var repo models.Repo 368 358 var nullableDescription sql.NullString 369 - var nullableWebsite sql.NullString 370 - var nullableTopicStr sql.NullString 371 359 372 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 373 361 374 362 var createdAt string 375 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 376 364 return nil, err 377 365 } 378 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 380 368 381 369 if nullableDescription.Valid { 382 370 repo.Description = nullableDescription.String 383 - } 384 - if nullableWebsite.Valid { 385 - repo.Website = nullableWebsite.String 386 - } 387 - if nullableTopicStr.Valid { 388 - repo.Topics = strings.Fields(nullableTopicStr.String) 371 + } else { 372 + repo.Description = "" 389 373 } 390 374 391 375 return &repo, nil 392 - } 393 - 394 - func PutRepo(tx *sql.Tx, repo models.Repo) error { 395 - _, err := tx.Exec( 396 - `update repos 397 - set knot = ?, description = ?, website = ?, topics = ? 398 - where did = ? and rkey = ? 399 - `, 400 - repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey, 401 - ) 402 - return err 403 376 } 404 377 405 378 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 406 379 _, err := tx.Exec( 407 380 `insert into repos 408 - (did, name, knot, rkey, at_uri, description, website, topics, source) 409 - values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 410 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 381 + (did, name, knot, rkey, at_uri, description, source) 382 + values (?, ?, ?, ?, ?, ?, ?)`, 383 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 411 384 ) 412 385 if err != nil { 413 386 return fmt.Errorf("failed to insert repo: %w", err) ··· 443 416 var repos []models.Repo 444 417 445 418 rows, err := e.Query( 446 - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 447 420 from repos r 448 421 left join collaborators c on r.at_uri = c.repo_at 449 422 where (r.did = ? or c.subject_did = ?) ··· 461 434 var repo models.Repo 462 435 var createdAt string 463 436 var nullableDescription sql.NullString 464 - var nullableWebsite sql.NullString 465 437 var nullableSource sql.NullString 466 438 467 - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 468 440 if err != nil { 469 441 return nil, err 470 442 } ··· 498 470 var repo models.Repo 499 471 var createdAt string 500 472 var nullableDescription sql.NullString 501 - var nullableWebsite sql.NullString 502 - var nullableTopicStr sql.NullString 503 473 var nullableSource sql.NullString 504 474 505 475 row := e.QueryRow( 506 - `select id, did, name, knot, rkey, description, website, topics, created, source 476 + `select id, did, name, knot, rkey, description, created, source 507 477 from repos 508 478 where did = ? and name = ? and source is not null and source != ''`, 509 479 did, name, 510 480 ) 511 481 512 - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 513 483 if err != nil { 514 484 return nil, err 515 485 } 516 486 517 487 if nullableDescription.Valid { 518 488 repo.Description = nullableDescription.String 519 - } 520 - 521 - if nullableWebsite.Valid { 522 - repo.Website = nullableWebsite.String 523 - } 524 - 525 - if nullableTopicStr.Valid { 526 - repo.Topics = strings.Fields(nullableTopicStr.String) 527 489 } 528 490 529 491 if nullableSource.Valid {
+10 -38
appview/db/timeline.go
··· 9 9 10 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 13 var events []models.TimelineEvent 14 14 15 - var userIsFollowing []string 16 - if limitToUsersIsFollowing { 17 - following, err := GetFollowing(e, loggedInUserDid) 18 - if err != nil { 19 - return nil, err 20 - } 21 - 22 - userIsFollowing = make([]string, 0, len(following)) 23 - for _, follow := range following { 24 - userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 - } 26 - } 27 - 28 - repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 15 + repos, err := getTimelineRepos(e, limit, loggedInUserDid) 29 16 if err != nil { 30 17 return nil, err 31 18 } 32 19 33 - stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 20 + stars, err := getTimelineStars(e, limit, loggedInUserDid) 34 21 if err != nil { 35 22 return nil, err 36 23 } 37 24 38 - follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 25 + follows, err := getTimelineFollows(e, limit, loggedInUserDid) 39 26 if err != nil { 40 27 return nil, err 41 28 } ··· 83 70 return isStarred, starCount 84 71 } 85 72 86 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 - filters := make([]filter, 0) 88 - if userIsFollowing != nil { 89 - filters = append(filters, FilterIn("did", userIsFollowing)) 90 - } 91 - 92 - repos, err := GetRepos(e, limit, filters...) 73 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 + repos, err := GetRepos(e, limit) 93 75 if err != nil { 94 76 return nil, err 95 77 } ··· 143 125 return events, nil 144 126 } 145 127 146 - func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 - filters := make([]filter, 0) 148 - if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 - } 151 - 152 - stars, err := GetStars(e, limit, filters...) 128 + func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 + stars, err := GetStars(e, limit) 153 130 if err != nil { 154 131 return nil, err 155 132 } ··· 189 166 return events, nil 190 167 } 191 168 192 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 - filters := make([]filter, 0) 194 - if userIsFollowing != nil { 195 - filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 - } 197 - 198 - follows, err := GetFollows(e, limit, filters...) 169 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 + follows, err := GetFollows(e, limit) 199 171 if err != nil { 200 172 return nil, err 201 173 }
+4 -4
appview/dns/cloudflare.go
··· 30 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 31 } 32 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 - result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 35 Type: record.Type, 36 36 Name: record.Name, 37 37 Content: record.Content, ··· 39 39 Proxied: &record.Proxied, 40 40 }) 41 41 if err != nil { 42 - return "", fmt.Errorf("failed to create DNS record: %w", err) 42 + return fmt.Errorf("failed to create DNS record: %w", err) 43 43 } 44 - return result.ID, nil 44 + return nil 45 45 } 46 46 47 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
-20
appview/indexer/base36/base36.go
··· 1 - // mostly copied from gitea/modules/indexer/internal/base32 2 - 3 - package base36 4 - 5 - import ( 6 - "fmt" 7 - "strconv" 8 - ) 9 - 10 - func Encode(i int64) string { 11 - return strconv.FormatInt(i, 36) 12 - } 13 - 14 - func Decode(s string) (int64, error) { 15 - i, err := strconv.ParseInt(s, 36, 64) 16 - if err != nil { 17 - return 0, fmt.Errorf("invalid base36 integer %q: %w", s, err) 18 - } 19 - return i, nil 20 - }
-58
appview/indexer/bleve/batch.go
··· 1 - // Copyright 2021 The Gitea Authors. All rights reserved. 2 - // SPDX-License-Identifier: MIT 3 - 4 - package bleveutil 5 - 6 - import ( 7 - "github.com/blevesearch/bleve/v2" 8 - ) 9 - 10 - // FlushingBatch is a batch of operations that automatically flushes to the 11 - // underlying index once it reaches a certain size. 12 - type FlushingBatch struct { 13 - maxBatchSize int 14 - batch *bleve.Batch 15 - index bleve.Index 16 - } 17 - 18 - // NewFlushingBatch creates a new flushing batch for the specified index. Once 19 - // the number of operations in the batch reaches the specified limit, the batch 20 - // automatically flushes its operations to the index. 21 - func NewFlushingBatch(index bleve.Index, maxBatchSize int) *FlushingBatch { 22 - return &FlushingBatch{ 23 - maxBatchSize: maxBatchSize, 24 - batch: index.NewBatch(), 25 - index: index, 26 - } 27 - } 28 - 29 - // Index add a new index to batch 30 - func (b *FlushingBatch) Index(id string, data any) error { 31 - if err := b.batch.Index(id, data); err != nil { 32 - return err 33 - } 34 - return b.flushIfFull() 35 - } 36 - 37 - // Delete add a delete index to batch 38 - func (b *FlushingBatch) Delete(id string) error { 39 - b.batch.Delete(id) 40 - return b.flushIfFull() 41 - } 42 - 43 - func (b *FlushingBatch) flushIfFull() error { 44 - if b.batch.Size() < b.maxBatchSize { 45 - return nil 46 - } 47 - return b.Flush() 48 - } 49 - 50 - // Flush submit the batch and create a new one 51 - func (b *FlushingBatch) Flush() error { 52 - err := b.index.Batch(b.batch) 53 - if err != nil { 54 - return err 55 - } 56 - b.batch = b.index.NewBatch() 57 - return nil 58 - }
-26
appview/indexer/bleve/query.go
··· 1 - package bleveutil 2 - 3 - import ( 4 - "github.com/blevesearch/bleve/v2" 5 - "github.com/blevesearch/bleve/v2/search/query" 6 - ) 7 - 8 - func MatchAndQuery(field, keyword, analyzer string, fuzziness int) query.Query { 9 - q := bleve.NewMatchQuery(keyword) 10 - q.FieldVal = field 11 - q.Analyzer = analyzer 12 - q.Fuzziness = fuzziness 13 - return q 14 - } 15 - 16 - func BoolFieldQuery(field string, val bool) query.Query { 17 - q := bleve.NewBoolFieldQuery(val) 18 - q.FieldVal = field 19 - return q 20 - } 21 - 22 - func KeywordFieldQuery(field, keyword string) query.Query { 23 - q := bleve.NewTermQuery(keyword) 24 - q.FieldVal = field 25 - return q 26 - }
-36
appview/indexer/indexer.go
··· 1 - package indexer 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - 7 - "tangled.org/core/appview/db" 8 - issues_indexer "tangled.org/core/appview/indexer/issues" 9 - pulls_indexer "tangled.org/core/appview/indexer/pulls" 10 - "tangled.org/core/appview/notify" 11 - tlog "tangled.org/core/log" 12 - ) 13 - 14 - type Indexer struct { 15 - Issues *issues_indexer.Indexer 16 - Pulls *pulls_indexer.Indexer 17 - logger *slog.Logger 18 - notify.BaseNotifier 19 - } 20 - 21 - func New(logger *slog.Logger) *Indexer { 22 - return &Indexer{ 23 - issues_indexer.NewIndexer("indexes/issues.bleve"), 24 - pulls_indexer.NewIndexer("indexes/pulls.bleve"), 25 - logger, 26 - notify.BaseNotifier{}, 27 - } 28 - } 29 - 30 - // Init initializes all indexers 31 - func (ix *Indexer) Init(ctx context.Context, db *db.DB) error { 32 - ctx = tlog.IntoContext(ctx, ix.logger) 33 - ix.Issues.Init(ctx, db) 34 - ix.Pulls.Init(ctx, db) 35 - return nil 36 - }
-255
appview/indexer/issues/indexer.go
··· 1 - // heavily inspired by gitea's model (basically copy-pasted) 2 - package issues_indexer 3 - 4 - import ( 5 - "context" 6 - "errors" 7 - "log" 8 - "os" 9 - 10 - "github.com/blevesearch/bleve/v2" 11 - "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" 12 - "github.com/blevesearch/bleve/v2/analysis/token/camelcase" 13 - "github.com/blevesearch/bleve/v2/analysis/token/lowercase" 14 - "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" 15 - "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" 16 - "github.com/blevesearch/bleve/v2/index/upsidedown" 17 - "github.com/blevesearch/bleve/v2/mapping" 18 - "github.com/blevesearch/bleve/v2/search/query" 19 - "tangled.org/core/appview/db" 20 - "tangled.org/core/appview/indexer/base36" 21 - "tangled.org/core/appview/indexer/bleve" 22 - "tangled.org/core/appview/models" 23 - "tangled.org/core/appview/pagination" 24 - tlog "tangled.org/core/log" 25 - ) 26 - 27 - const ( 28 - issueIndexerAnalyzer = "issueIndexer" 29 - issueIndexerDocType = "issueIndexerDocType" 30 - 31 - unicodeNormalizeName = "uicodeNormalize" 32 - ) 33 - 34 - type Indexer struct { 35 - indexer bleve.Index 36 - path string 37 - } 38 - 39 - func NewIndexer(indexDir string) *Indexer { 40 - return &Indexer{ 41 - path: indexDir, 42 - } 43 - } 44 - 45 - // Init initializes the indexer 46 - func (ix *Indexer) Init(ctx context.Context, e db.Execer) { 47 - l := tlog.FromContext(ctx) 48 - existed, err := ix.intialize(ctx) 49 - if err != nil { 50 - log.Fatalln("failed to initialize issue indexer", err) 51 - } 52 - if !existed { 53 - l.Debug("Populating the issue indexer") 54 - err := PopulateIndexer(ctx, ix, e) 55 - if err != nil { 56 - log.Fatalln("failed to populate issue indexer", err) 57 - } 58 - } 59 - l.Info("Initialized the issue indexer") 60 - } 61 - 62 - func generateIssueIndexMapping() (mapping.IndexMapping, error) { 63 - mapping := bleve.NewIndexMapping() 64 - docMapping := bleve.NewDocumentMapping() 65 - 66 - textFieldMapping := bleve.NewTextFieldMapping() 67 - textFieldMapping.Store = false 68 - textFieldMapping.IncludeInAll = false 69 - 70 - boolFieldMapping := bleve.NewBooleanFieldMapping() 71 - boolFieldMapping.Store = false 72 - boolFieldMapping.IncludeInAll = false 73 - 74 - keywordFieldMapping := bleve.NewKeywordFieldMapping() 75 - keywordFieldMapping.Store = false 76 - keywordFieldMapping.IncludeInAll = false 77 - 78 - // numericFieldMapping := bleve.NewNumericFieldMapping() 79 - 80 - docMapping.AddFieldMappingsAt("title", textFieldMapping) 81 - docMapping.AddFieldMappingsAt("body", textFieldMapping) 82 - 83 - docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 84 - docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 85 - 86 - err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 87 - "type": unicodenorm.Name, 88 - "form": unicodenorm.NFC, 89 - }) 90 - if err != nil { 91 - return nil, err 92 - } 93 - 94 - err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{ 95 - "type": custom.Name, 96 - "char_filters": []string{}, 97 - "tokenizer": unicode.Name, 98 - "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, 99 - }) 100 - if err != nil { 101 - return nil, err 102 - } 103 - 104 - mapping.DefaultAnalyzer = issueIndexerAnalyzer 105 - mapping.AddDocumentMapping(issueIndexerDocType, docMapping) 106 - mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) 107 - mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() 108 - 109 - return mapping, nil 110 - } 111 - 112 - func (ix *Indexer) intialize(ctx context.Context) (bool, error) { 113 - if ix.indexer != nil { 114 - return false, errors.New("indexer is already initialized") 115 - } 116 - 117 - indexer, err := openIndexer(ctx, ix.path) 118 - if err != nil { 119 - return false, err 120 - } 121 - if indexer != nil { 122 - ix.indexer = indexer 123 - return true, nil 124 - } 125 - 126 - mapping, err := generateIssueIndexMapping() 127 - if err != nil { 128 - return false, err 129 - } 130 - indexer, err = bleve.New(ix.path, mapping) 131 - if err != nil { 132 - return false, err 133 - } 134 - 135 - ix.indexer = indexer 136 - 137 - return false, nil 138 - } 139 - 140 - func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 141 - l := tlog.FromContext(ctx) 142 - indexer, err := bleve.Open(path) 143 - if err != nil { 144 - if errors.Is(err, upsidedown.IncompatibleVersion) { 145 - l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding") 146 - return nil, os.RemoveAll(path) 147 - } 148 - return nil, nil 149 - } 150 - return indexer, nil 151 - } 152 - 153 - func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 154 - l := tlog.FromContext(ctx) 155 - count := 0 156 - err := pagination.IterateAll( 157 - func(page pagination.Page) ([]models.Issue, error) { 158 - return db.GetIssuesPaginated(e, page) 159 - }, 160 - func(issues []models.Issue) error { 161 - count += len(issues) 162 - return ix.Index(ctx, issues...) 163 - }, 164 - ) 165 - l.Info("issues indexed", "count", count) 166 - return err 167 - } 168 - 169 - // issueData data stored and will be indexed 170 - type issueData struct { 171 - ID int64 `json:"id"` 172 - RepoAt string `json:"repo_at"` 173 - IssueID int `json:"issue_id"` 174 - Title string `json:"title"` 175 - Body string `json:"body"` 176 - 177 - IsOpen bool `json:"is_open"` 178 - Comments []IssueCommentData `json:"comments"` 179 - } 180 - 181 - func makeIssueData(issue *models.Issue) *issueData { 182 - return &issueData{ 183 - ID: issue.Id, 184 - RepoAt: issue.RepoAt.String(), 185 - IssueID: issue.IssueId, 186 - Title: issue.Title, 187 - Body: issue.Body, 188 - IsOpen: issue.Open, 189 - } 190 - } 191 - 192 - // Type returns the document type, for bleve's mapping.Classifier interface. 193 - func (i *issueData) Type() string { 194 - return issueIndexerDocType 195 - } 196 - 197 - type IssueCommentData struct { 198 - Body string `json:"body"` 199 - } 200 - 201 - type SearchResult struct { 202 - Hits []int64 203 - Total uint64 204 - } 205 - 206 - const maxBatchSize = 20 207 - 208 - func (ix *Indexer) Index(ctx context.Context, issues ...models.Issue) error { 209 - batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize) 210 - for _, issue := range issues { 211 - issueData := makeIssueData(&issue) 212 - if err := batch.Index(base36.Encode(issue.Id), issueData); err != nil { 213 - return err 214 - } 215 - } 216 - return batch.Flush() 217 - } 218 - 219 - func (ix *Indexer) Delete(ctx context.Context, issueId int64) error { 220 - return ix.indexer.Delete(base36.Encode(issueId)) 221 - } 222 - 223 - // Search searches for issues 224 - func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { 225 - var queries []query.Query 226 - 227 - if opts.Keyword != "" { 228 - queries = append(queries, bleve.NewDisjunctionQuery( 229 - bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0), 230 - bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0), 231 - )) 232 - } 233 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 234 - queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen)) 235 - // TODO: append more queries 236 - 237 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 238 - searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) 239 - res, err := ix.indexer.SearchInContext(ctx, searchReq) 240 - if err != nil { 241 - return nil, nil 242 - } 243 - ret := &SearchResult{ 244 - Total: res.Total, 245 - Hits: make([]int64, len(res.Hits)), 246 - } 247 - for i, hit := range res.Hits { 248 - id, err := base36.Decode(hit.ID) 249 - if err != nil { 250 - return nil, err 251 - } 252 - ret.Hits[i] = id 253 - } 254 - return ret, nil 255 - }
-57
appview/indexer/notifier.go
··· 1 - package indexer 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 - "tangled.org/core/appview/models" 8 - "tangled.org/core/appview/notify" 9 - "tangled.org/core/log" 10 - ) 11 - 12 - var _ notify.Notifier = &Indexer{} 13 - 14 - func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 15 - l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 16 - l.Debug("indexing new issue") 17 - err := ix.Issues.Index(ctx, *issue) 18 - if err != nil { 19 - l.Error("failed to index an issue", "err", err) 20 - } 21 - } 22 - 23 - func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 24 - l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 25 - l.Debug("updating an issue") 26 - err := ix.Issues.Index(ctx, *issue) 27 - if err != nil { 28 - l.Error("failed to index an issue", "err", err) 29 - } 30 - } 31 - 32 - func (ix *Indexer) DeleteIssue(ctx context.Context, issue *models.Issue) { 33 - l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 34 - l.Debug("deleting an issue") 35 - err := ix.Issues.Delete(ctx, issue.Id) 36 - if err != nil { 37 - l.Error("failed to delete an issue", "err", err) 38 - } 39 - } 40 - 41 - func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) { 42 - l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 43 - l.Debug("indexing new pr") 44 - err := ix.Pulls.Index(ctx, pull) 45 - if err != nil { 46 - l.Error("failed to index a pr", "err", err) 47 - } 48 - } 49 - 50 - func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 51 - l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 52 - l.Debug("updating a pr") 53 - err := ix.Pulls.Index(ctx, pull) 54 - if err != nil { 55 - l.Error("failed to index a pr", "err", err) 56 - } 57 - }
-255
appview/indexer/pulls/indexer.go
··· 1 - // heavily inspired by gitea's model (basically copy-pasted) 2 - package pulls_indexer 3 - 4 - import ( 5 - "context" 6 - "errors" 7 - "log" 8 - "os" 9 - 10 - "github.com/blevesearch/bleve/v2" 11 - "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" 12 - "github.com/blevesearch/bleve/v2/analysis/token/camelcase" 13 - "github.com/blevesearch/bleve/v2/analysis/token/lowercase" 14 - "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" 15 - "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" 16 - "github.com/blevesearch/bleve/v2/index/upsidedown" 17 - "github.com/blevesearch/bleve/v2/mapping" 18 - "github.com/blevesearch/bleve/v2/search/query" 19 - "tangled.org/core/appview/db" 20 - "tangled.org/core/appview/indexer/base36" 21 - "tangled.org/core/appview/indexer/bleve" 22 - "tangled.org/core/appview/models" 23 - tlog "tangled.org/core/log" 24 - ) 25 - 26 - const ( 27 - pullIndexerAnalyzer = "pullIndexer" 28 - pullIndexerDocType = "pullIndexerDocType" 29 - 30 - unicodeNormalizeName = "uicodeNormalize" 31 - ) 32 - 33 - type Indexer struct { 34 - indexer bleve.Index 35 - path string 36 - } 37 - 38 - func NewIndexer(indexDir string) *Indexer { 39 - return &Indexer{ 40 - path: indexDir, 41 - } 42 - } 43 - 44 - // Init initializes the indexer 45 - func (ix *Indexer) Init(ctx context.Context, e db.Execer) { 46 - l := tlog.FromContext(ctx) 47 - existed, err := ix.intialize(ctx) 48 - if err != nil { 49 - log.Fatalln("failed to initialize pull indexer", err) 50 - } 51 - if !existed { 52 - l.Debug("Populating the pull indexer") 53 - err := PopulateIndexer(ctx, ix, e) 54 - if err != nil { 55 - log.Fatalln("failed to populate pull indexer", err) 56 - } 57 - } 58 - l.Info("Initialized the pull indexer") 59 - } 60 - 61 - func generatePullIndexMapping() (mapping.IndexMapping, error) { 62 - mapping := bleve.NewIndexMapping() 63 - docMapping := bleve.NewDocumentMapping() 64 - 65 - textFieldMapping := bleve.NewTextFieldMapping() 66 - textFieldMapping.Store = false 67 - textFieldMapping.IncludeInAll = false 68 - 69 - keywordFieldMapping := bleve.NewKeywordFieldMapping() 70 - keywordFieldMapping.Store = false 71 - keywordFieldMapping.IncludeInAll = false 72 - 73 - // numericFieldMapping := bleve.NewNumericFieldMapping() 74 - 75 - docMapping.AddFieldMappingsAt("title", textFieldMapping) 76 - docMapping.AddFieldMappingsAt("body", textFieldMapping) 77 - 78 - docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 79 - docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 80 - 81 - err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 82 - "type": unicodenorm.Name, 83 - "form": unicodenorm.NFC, 84 - }) 85 - if err != nil { 86 - return nil, err 87 - } 88 - 89 - err = mapping.AddCustomAnalyzer(pullIndexerAnalyzer, map[string]any{ 90 - "type": custom.Name, 91 - "char_filters": []string{}, 92 - "tokenizer": unicode.Name, 93 - "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, 94 - }) 95 - if err != nil { 96 - return nil, err 97 - } 98 - 99 - mapping.DefaultAnalyzer = pullIndexerAnalyzer 100 - mapping.AddDocumentMapping(pullIndexerDocType, docMapping) 101 - mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) 102 - mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() 103 - 104 - return mapping, nil 105 - } 106 - 107 - func (ix *Indexer) intialize(ctx context.Context) (bool, error) { 108 - if ix.indexer != nil { 109 - return false, errors.New("indexer is already initialized") 110 - } 111 - 112 - indexer, err := openIndexer(ctx, ix.path) 113 - if err != nil { 114 - return false, err 115 - } 116 - if indexer != nil { 117 - ix.indexer = indexer 118 - return true, nil 119 - } 120 - 121 - mapping, err := generatePullIndexMapping() 122 - if err != nil { 123 - return false, err 124 - } 125 - indexer, err = bleve.New(ix.path, mapping) 126 - if err != nil { 127 - return false, err 128 - } 129 - 130 - ix.indexer = indexer 131 - 132 - return false, nil 133 - } 134 - 135 - func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 136 - l := tlog.FromContext(ctx) 137 - indexer, err := bleve.Open(path) 138 - if err != nil { 139 - if errors.Is(err, upsidedown.IncompatibleVersion) { 140 - l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding") 141 - return nil, os.RemoveAll(path) 142 - } 143 - return nil, nil 144 - } 145 - return indexer, nil 146 - } 147 - 148 - func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 149 - l := tlog.FromContext(ctx) 150 - 151 - pulls, err := db.GetPulls(e) 152 - if err != nil { 153 - return err 154 - } 155 - count := len(pulls) 156 - err = ix.Index(ctx, pulls...) 157 - if err != nil { 158 - return err 159 - } 160 - l.Info("pulls indexed", "count", count) 161 - return err 162 - } 163 - 164 - // pullData data stored and will be indexed 165 - type pullData struct { 166 - ID int64 `json:"id"` 167 - RepoAt string `json:"repo_at"` 168 - PullID int `json:"pull_id"` 169 - Title string `json:"title"` 170 - Body string `json:"body"` 171 - State string `json:"state"` 172 - 173 - Comments []pullCommentData `json:"comments"` 174 - } 175 - 176 - func makePullData(pull *models.Pull) *pullData { 177 - return &pullData{ 178 - ID: int64(pull.ID), 179 - RepoAt: pull.RepoAt.String(), 180 - PullID: pull.PullId, 181 - Title: pull.Title, 182 - Body: pull.Body, 183 - State: pull.State.String(), 184 - } 185 - } 186 - 187 - // Type returns the document type, for bleve's mapping.Classifier interface. 188 - func (i *pullData) Type() string { 189 - return pullIndexerDocType 190 - } 191 - 192 - type pullCommentData struct { 193 - Body string `json:"body"` 194 - } 195 - 196 - type searchResult struct { 197 - Hits []int64 198 - Total uint64 199 - } 200 - 201 - const maxBatchSize = 20 202 - 203 - func (ix *Indexer) Index(ctx context.Context, pulls ...*models.Pull) error { 204 - batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize) 205 - for _, pull := range pulls { 206 - pullData := makePullData(pull) 207 - if err := batch.Index(base36.Encode(pullData.ID), pullData); err != nil { 208 - return err 209 - } 210 - } 211 - return batch.Flush() 212 - } 213 - 214 - func (ix *Indexer) Delete(ctx context.Context, pullID int64) error { 215 - return ix.indexer.Delete(base36.Encode(pullID)) 216 - } 217 - 218 - // Search searches for pulls 219 - func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 220 - var queries []query.Query 221 - 222 - // TODO(boltless): remove this after implementing pulls page pagination 223 - limit := opts.Page.Limit 224 - if limit == 0 { 225 - limit = 500 226 - } 227 - 228 - if opts.Keyword != "" { 229 - queries = append(queries, bleve.NewDisjunctionQuery( 230 - bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), 231 - bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), 232 - )) 233 - } 234 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 235 - queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) 236 - 237 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 238 - searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 239 - res, err := ix.indexer.SearchInContext(ctx, searchReq) 240 - if err != nil { 241 - return nil, nil 242 - } 243 - ret := &searchResult{ 244 - Total: res.Total, 245 - Hits: make([]int64, len(res.Hits)), 246 - } 247 - for i, hit := range res.Hits { 248 - id, err := base36.Decode(hit.ID) 249 - if err != nil { 250 - return nil, err 251 - } 252 - ret.Hits[i] = id 253 - } 254 - return ret, nil 255 - }
+1 -7
appview/ingester.go
··· 89 89 } 90 90 91 91 if err != nil { 92 - l.Warn("refused to ingest record", "err", err) 92 + l.Debug("error ingesting record", "err", err) 93 93 } 94 94 95 95 return nil ··· 291 291 292 292 includeBluesky := record.Bluesky 293 293 294 - pronouns := "" 295 - if record.Pronouns != nil { 296 - pronouns = *record.Pronouns 297 - } 298 - 299 294 location := "" 300 295 if record.Location != nil { 301 296 location = *record.Location ··· 330 325 Links: links, 331 326 Stats: stats, 332 327 PinnedRepos: pinned, 333 - Pronouns: pronouns, 334 328 } 335 329 336 330 ddb, ok := i.Db.Execer.(*db.DB)
+54 -113
appview/issues/issues.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 + "log" 8 9 "log/slog" 9 10 "net/http" 10 11 "slices" 11 12 "time" 12 13 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - atpclient "github.com/bluesky-social/indigo/atproto/client" 15 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 17 "github.com/go-chi/chi/v5" ··· 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/config" 21 21 "tangled.org/core/appview/db" 22 - issues_indexer "tangled.org/core/appview/indexer/issues" 23 22 "tangled.org/core/appview/models" 24 23 "tangled.org/core/appview/notify" 25 24 "tangled.org/core/appview/oauth" 26 25 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 26 "tangled.org/core/appview/pagination" 29 27 "tangled.org/core/appview/reporesolver" 30 28 "tangled.org/core/appview/validator" 29 + "tangled.org/core/appview/xrpcclient" 31 30 "tangled.org/core/idresolver" 31 + tlog "tangled.org/core/log" 32 32 "tangled.org/core/tid" 33 33 ) 34 34 ··· 42 42 notifier notify.Notifier 43 43 logger *slog.Logger 44 44 validator *validator.Validator 45 - indexer *issues_indexer.Indexer 46 45 } 47 46 48 47 func New( ··· 54 53 config *config.Config, 55 54 notifier notify.Notifier, 56 55 validator *validator.Validator, 57 - indexer *issues_indexer.Indexer, 58 - logger *slog.Logger, 59 56 ) *Issues { 60 57 return &Issues{ 61 58 oauth: oauth, ··· 65 62 db: db, 66 63 config: config, 67 64 notifier: notifier, 68 - logger: logger, 65 + logger: tlog.New("issues"), 69 66 validator: validator, 70 - indexer: indexer, 71 67 } 72 68 } 73 69 ··· 76 72 user := rp.oauth.GetUser(r) 77 73 f, err := rp.repoResolver.Resolve(r) 78 74 if err != nil { 79 - l.Error("failed to get repo and knot", "err", err) 75 + log.Println("failed to get repo and knot", err) 80 76 return 81 77 } 82 78 ··· 87 83 return 88 84 } 89 85 90 - reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 86 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 91 87 if err != nil { 92 88 l.Error("failed to get issue reactions", "err", err) 93 89 } ··· 103 99 db.FilterContains("scope", tangled.RepoIssueNSID), 104 100 ) 105 101 if err != nil { 106 - l.Error("failed to fetch labels", "err", err) 102 + log.Println("failed to fetch labels", err) 107 103 rp.pages.Error503(w) 108 104 return 109 105 } ··· 119 115 Issue: issue, 120 116 CommentList: issue.CommentList(), 121 117 OrderedReactionKinds: models.OrderedReactionKinds, 122 - Reactions: reactionMap, 118 + Reactions: reactionCountMap, 123 119 UserReacted: userReactions, 124 120 LabelDefs: defs, 125 121 }) ··· 130 126 user := rp.oauth.GetUser(r) 131 127 f, err := rp.repoResolver.Resolve(r) 132 128 if err != nil { 133 - l.Error("failed to get repo and knot", "err", err) 129 + log.Println("failed to get repo and knot", err) 134 130 return 135 131 } 136 132 ··· 170 166 return 171 167 } 172 168 173 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 169 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 174 170 if err != nil { 175 171 l.Error("failed to get record", "err", err) 176 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 177 173 return 178 174 } 179 175 180 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 176 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 181 177 Collection: tangled.RepoIssueNSID, 182 178 Repo: user.Did, 183 179 Rkey: newIssue.Rkey, ··· 203 199 204 200 err = db.PutIssue(tx, newIssue) 205 201 if err != nil { 206 - l.Error("failed to edit issue", "err", err) 202 + log.Println("failed to edit issue", err) 207 203 rp.pages.Notice(w, "issues", "Failed to edit issue.") 208 204 return 209 205 } ··· 241 237 // delete from PDS 242 238 client, err := rp.oauth.AuthorizedClient(r) 243 239 if err != nil { 244 - l.Error("failed to get authorized client", "err", err) 240 + log.Println("failed to get authorized client", err) 245 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 246 242 return 247 243 } 248 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 244 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 249 245 Collection: tangled.RepoIssueNSID, 250 246 Repo: issue.Did, 251 247 Rkey: issue.Rkey, ··· 264 260 return 265 261 } 266 262 267 - rp.notifier.DeleteIssue(r.Context(), issue) 268 - 269 263 // return to all issues page 270 264 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 271 265 } ··· 288 282 289 283 collaborators, err := f.Collaborators(r.Context()) 290 284 if err != nil { 291 - l.Error("failed to fetch repo collaborators", "err", err) 285 + log.Println("failed to fetch repo collaborators: %w", err) 292 286 } 293 287 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 294 288 return user.Did == collab.Did ··· 302 296 db.FilterEq("id", issue.Id), 303 297 ) 304 298 if err != nil { 305 - l.Error("failed to close issue", "err", err) 299 + log.Println("failed to close issue", err) 306 300 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 307 301 return 308 302 } 309 - // change the issue state (this will pass down to the notifiers) 310 - issue.Open = false 311 303 312 304 // notify about the issue closure 313 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 314 306 315 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 316 308 return 317 309 } else { 318 - l.Error("user is not permitted to close issue") 310 + log.Println("user is not permitted to close issue") 319 311 http.Error(w, "for biden", http.StatusUnauthorized) 320 312 return 321 313 } ··· 326 318 user := rp.oauth.GetUser(r) 327 319 f, err := rp.repoResolver.Resolve(r) 328 320 if err != nil { 329 - l.Error("failed to get repo and knot", "err", err) 321 + log.Println("failed to get repo and knot", err) 330 322 return 331 323 } 332 324 ··· 339 331 340 332 collaborators, err := f.Collaborators(r.Context()) 341 333 if err != nil { 342 - l.Error("failed to fetch repo collaborators", "err", err) 334 + log.Println("failed to fetch repo collaborators: %w", err) 343 335 } 344 336 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 345 337 return user.Did == collab.Did ··· 352 344 db.FilterEq("id", issue.Id), 353 345 ) 354 346 if err != nil { 355 - l.Error("failed to reopen issue", "err", err) 347 + log.Println("failed to reopen issue", err) 356 348 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 357 349 return 358 350 } 359 - // change the issue state (this will pass down to the notifiers) 360 - issue.Open = true 361 - 362 - // notify about the issue reopen 363 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 364 - 365 351 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 366 352 return 367 353 } else { 368 - l.Error("user is not the owner of the repo") 354 + log.Println("user is not the owner of the repo") 369 355 http.Error(w, "forbidden", http.StatusUnauthorized) 370 356 return 371 357 } ··· 422 408 } 423 409 424 410 // create a record first 425 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 411 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 426 412 Collection: tangled.RepoIssueCommentNSID, 427 413 Repo: comment.Did, 428 414 Rkey: comment.Rkey, ··· 454 440 455 441 // notify about the new comment 456 442 comment.Id = commentId 457 - 458 - rawMentions := markup.FindUserMentions(comment.Body) 459 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 460 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 461 - var mentions []syntax.DID 462 - for _, ident := range idents { 463 - if ident != nil && !ident.Handle.IsInvalidHandle() { 464 - mentions = append(mentions, ident.DID) 465 - } 466 - } 467 - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 468 444 469 445 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 470 446 } ··· 562 538 newBody := r.FormValue("body") 563 539 client, err := rp.oauth.AuthorizedClient(r) 564 540 if err != nil { 565 - l.Error("failed to get authorized client", "err", err) 541 + log.Println("failed to get authorized client", err) 566 542 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 567 543 return 568 544 } ··· 575 551 576 552 _, err = db.AddIssueComment(rp.db, newComment) 577 553 if err != nil { 578 - l.Error("failed to perferom update-description query", "err", err) 554 + log.Println("failed to perferom update-description query", err) 579 555 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 580 556 return 581 557 } ··· 583 559 // rkey is optional, it was introduced later 584 560 if newComment.Rkey != "" { 585 561 // update the record on pds 586 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 562 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 587 563 if err != nil { 588 - l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 564 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 589 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 590 566 return 591 567 } 592 568 593 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 569 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 594 570 Collection: tangled.RepoIssueCommentNSID, 595 571 Repo: user.Did, 596 572 Rkey: newComment.Rkey, ··· 753 729 if comment.Rkey != "" { 754 730 client, err := rp.oauth.AuthorizedClient(r) 755 731 if err != nil { 756 - l.Error("failed to get authorized client", "err", err) 732 + log.Println("failed to get authorized client", err) 757 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 758 734 return 759 735 } 760 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 736 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 761 737 Collection: tangled.RepoIssueCommentNSID, 762 738 Repo: user.Did, 763 739 Rkey: comment.Rkey, 764 740 }) 765 741 if err != nil { 766 - l.Error("failed to delete from PDS", "err", err) 742 + log.Println(err) 767 743 } 768 744 } 769 745 ··· 781 757 } 782 758 783 759 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 784 - l := rp.logger.With("handler", "RepoIssues") 785 - 786 760 params := r.URL.Query() 787 761 state := params.Get("state") 788 762 isOpen := true ··· 795 769 isOpen = true 796 770 } 797 771 798 - page := pagination.FromContext(r.Context()) 772 + page, ok := r.Context().Value("page").(pagination.Page) 773 + if !ok { 774 + log.Println("failed to get page") 775 + page = pagination.FirstPage() 776 + } 799 777 800 778 user := rp.oauth.GetUser(r) 801 779 f, err := rp.repoResolver.Resolve(r) 802 780 if err != nil { 803 - l.Error("failed to get repo and knot", "err", err) 781 + log.Println("failed to get repo and knot", err) 804 782 return 805 783 } 806 784 807 - keyword := params.Get("q") 808 - 809 - var ids []int64 810 - searchOpts := models.IssueSearchOptions{ 811 - Keyword: keyword, 812 - RepoAt: f.RepoAt().String(), 813 - IsOpen: isOpen, 814 - Page: page, 785 + openVal := 0 786 + if isOpen { 787 + openVal = 1 815 788 } 816 - if keyword != "" { 817 - res, err := rp.indexer.Search(r.Context(), searchOpts) 818 - if err != nil { 819 - l.Error("failed to search for issues", "err", err) 820 - return 821 - } 822 - ids = res.Hits 823 - l.Debug("searched issues with indexer", "count", len(ids)) 824 - } else { 825 - ids, err = db.GetIssueIDs(rp.db, searchOpts) 826 - if err != nil { 827 - l.Error("failed to search for issues", "err", err) 828 - return 829 - } 830 - l.Debug("indexed all issues from the db", "count", len(ids)) 831 - } 832 - 833 - issues, err := db.GetIssues( 789 + issues, err := db.GetIssuesPaginated( 834 790 rp.db, 835 - db.FilterIn("id", ids), 791 + page, 792 + db.FilterEq("repo_at", f.RepoAt()), 793 + db.FilterEq("open", openVal), 836 794 ) 837 795 if err != nil { 838 - l.Error("failed to get issues", "err", err) 796 + log.Println("failed to get issues", err) 839 797 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 840 798 return 841 799 } 842 800 843 - labelDefs, err := db.GetLabelDefinitions( 844 - rp.db, 845 - db.FilterIn("at_uri", f.Repo.Labels), 846 - db.FilterContains("scope", tangled.RepoIssueNSID), 847 - ) 801 + labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 848 802 if err != nil { 849 - l.Error("failed to fetch labels", "err", err) 803 + log.Println("failed to fetch labels", err) 850 804 rp.pages.Error503(w) 851 805 return 852 806 } ··· 862 816 Issues: issues, 863 817 LabelDefs: defs, 864 818 FilteringByOpen: isOpen, 865 - FilterQuery: keyword, 866 819 Page: page, 867 820 }) 868 821 } ··· 889 842 Rkey: tid.TID(), 890 843 Title: r.FormValue("title"), 891 844 Body: r.FormValue("body"), 892 - Open: true, 893 845 Did: user.Did, 894 846 Created: time.Now(), 895 - Repo: &f.Repo, 896 847 } 897 848 898 849 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 910 861 rp.pages.Notice(w, "issues", "Failed to create issue.") 911 862 return 912 863 } 913 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 864 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 914 865 Collection: tangled.RepoIssueNSID, 915 866 Repo: user.Did, 916 867 Rkey: issue.Rkey, ··· 946 897 947 898 err = db.PutIssue(tx, issue) 948 899 if err != nil { 949 - l.Error("failed to create issue", "err", err) 900 + log.Println("failed to create issue", err) 950 901 rp.pages.Notice(w, "issues", "Failed to create issue.") 951 902 return 952 903 } 953 904 954 905 if err = tx.Commit(); err != nil { 955 - l.Error("failed to create issue", "err", err) 906 + log.Println("failed to create issue", err) 956 907 rp.pages.Notice(w, "issues", "Failed to create issue.") 957 908 return 958 909 } 959 910 960 911 // everything is successful, do not rollback the atproto record 961 912 atUri = "" 962 - 963 - rawMentions := markup.FindUserMentions(issue.Body) 964 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 965 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 966 - var mentions []syntax.DID 967 - for _, ident := range idents { 968 - if ident != nil && !ident.Handle.IsInvalidHandle() { 969 - mentions = append(mentions, ident.DID) 970 - } 971 - } 972 - rp.notifier.NewIssue(r.Context(), issue, mentions) 913 + rp.notifier.NewIssue(r.Context(), issue) 973 914 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 974 915 return 975 916 } ··· 978 919 // this is used to rollback changes made to the PDS 979 920 // 980 921 // it is a no-op if the provided ATURI is empty 981 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 922 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 982 923 if aturi == "" { 983 924 return nil 984 925 } ··· 989 930 repo := parsed.Authority().String() 990 931 rkey := parsed.RecordKey().String() 991 932 992 - _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 933 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 993 934 Collection: collection, 994 935 Repo: repo, 995 936 Rkey: rkey,
-267
appview/issues/opengraph.go
··· 1 - package issues 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "image" 8 - "image/color" 9 - "image/png" 10 - "log" 11 - "net/http" 12 - 13 - "tangled.org/core/appview/models" 14 - "tangled.org/core/appview/ogcard" 15 - ) 16 - 17 - func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 - width, height := ogcard.DefaultSize() 19 - mainCard, err := ogcard.NewCard(width, height) 20 - if err != nil { 21 - return nil, err 22 - } 23 - 24 - // Split: content area (75%) and status/stats area (25%) 25 - contentCard, statsArea := mainCard.Split(false, 75) 26 - 27 - // Add padding to content 28 - contentCard.SetMargin(50) 29 - 30 - // Split content horizontally: main content (80%) and avatar area (20%) 31 - mainContent, avatarArea := contentCard.Split(true, 80) 32 - 33 - // Add margin to main content like repo card 34 - mainContent.SetMargin(10) 35 - 36 - // Use full main content area for repo name and title 37 - bounds := mainContent.Img.Bounds() 38 - startX := bounds.Min.X + mainContent.Margin 39 - startY := bounds.Min.Y + mainContent.Margin 40 - 41 - // Draw full repository name at top (owner/repo format) 42 - var repoOwner string 43 - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 - if err != nil { 45 - repoOwner = repo.Did 46 - } else { 47 - repoOwner = "@" + owner.Handle.String() 48 - } 49 - 50 - fullRepoName := repoOwner + " / " + repo.Name 51 - if len(fullRepoName) > 60 { 52 - fullRepoName = fullRepoName[:60] + "…" 53 - } 54 - 55 - grayColor := color.RGBA{88, 96, 105, 255} 56 - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 - if err != nil { 58 - return nil, err 59 - } 60 - 61 - // Draw issue title below repo name with wrapping 62 - titleY := startY + 60 63 - titleX := startX 64 - 65 - // Truncate title if too long 66 - issueTitle := issue.Title 67 - maxTitleLength := 80 68 - if len(issueTitle) > maxTitleLength { 69 - issueTitle = issueTitle[:maxTitleLength] + "…" 70 - } 71 - 72 - // Create a temporary card for the title area to enable wrapping 73 - titleBounds := mainContent.Img.Bounds() 74 - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 - 77 - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 - titleCard := &ogcard.Card{ 79 - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 - Font: mainContent.Font, 81 - Margin: 0, 82 - } 83 - 84 - // Draw wrapped title 85 - lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 - if err != nil { 87 - return nil, err 88 - } 89 - 90 - // Calculate where title ends (number of lines * line height) 91 - lineHeight := 60 // Approximate line height for 54pt font 92 - titleEndY := titleY + (len(lines) * lineHeight) + 10 93 - 94 - // Draw issue ID in gray below the title 95 - issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 - err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 - if err != nil { 98 - return nil, err 99 - } 100 - 101 - // Get issue author handle (needed for avatar and metadata) 102 - var authorHandle string 103 - author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 - if err != nil { 105 - authorHandle = issue.Did 106 - } else { 107 - authorHandle = "@" + author.Handle.String() 108 - } 109 - 110 - // Draw avatar circle on the right side 111 - avatarBounds := avatarArea.Img.Bounds() 112 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 - if avatarSize > 220 { 114 - avatarSize = 220 115 - } 116 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 - avatarY := avatarBounds.Min.Y + 20 118 - 119 - // Get avatar URL for issue author 120 - avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 - if err != nil { 123 - log.Printf("failed to draw avatar (non-fatal): %v", err) 124 - } 125 - 126 - // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 - statusCommentsArea, dollyArea := statsArea.Split(true, 80) 128 - 129 - // Draw status and comment count in status/comments area 130 - statsBounds := statusCommentsArea.Img.Bounds() 131 - statsX := statsBounds.Min.X + 60 // left padding 132 - statsY := statsBounds.Min.Y 133 - 134 - iconColor := color.RGBA{88, 96, 105, 255} 135 - iconSize := 36 136 - textSize := 36.0 137 - labelSize := 28.0 138 - iconBaselineOffset := int(textSize) / 2 139 - 140 - // Draw status (open/closed) with colored icon and text 141 - var statusIcon string 142 - var statusText string 143 - var statusBgColor color.RGBA 144 - 145 - if issue.Open { 146 - statusIcon = "circle-dot" 147 - statusText = "open" 148 - statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 - } else { 150 - statusIcon = "ban" 151 - statusText = "closed" 152 - statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 - } 154 - 155 - badgeIconSize := 36 156 - 157 - // Draw icon with status color (no background) 158 - err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 - if err != nil { 160 - log.Printf("failed to draw status icon: %v", err) 161 - } 162 - 163 - // Draw text with status color (no background) 164 - textX := statsX + badgeIconSize + 12 165 - badgeTextSize := 32.0 166 - err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left) 167 - if err != nil { 168 - log.Printf("failed to draw status text: %v", err) 169 - } 170 - 171 - statusTextWidth := len(statusText) * 20 172 - currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 - 174 - // Draw comment count 175 - err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 - if err != nil { 177 - log.Printf("failed to draw comment icon: %v", err) 178 - } 179 - 180 - currentX += iconSize + 15 181 - commentText := fmt.Sprintf("%d comments", commentCount) 182 - if commentCount == 1 { 183 - commentText = "1 comment" 184 - } 185 - err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 186 - if err != nil { 187 - log.Printf("failed to draw comment text: %v", err) 188 - } 189 - 190 - // Draw dolly logo on the right side 191 - dollyBounds := dollyArea.Img.Bounds() 192 - dollySize := 90 193 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 197 - if err != nil { 198 - log.Printf("dolly silhouette not available (this is ok): %v", err) 199 - } 200 - 201 - // Draw "opened by @author" and date at the bottom with more spacing 202 - labelY := statsY + iconSize + 30 203 - 204 - // Format the opened date 205 - openedDate := issue.Created.Format("Jan 2, 2006") 206 - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 207 - 208 - err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 209 - if err != nil { 210 - log.Printf("failed to draw metadata: %v", err) 211 - } 212 - 213 - return mainCard, nil 214 - } 215 - 216 - func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 217 - f, err := rp.repoResolver.Resolve(r) 218 - if err != nil { 219 - log.Println("failed to get repo and knot", err) 220 - return 221 - } 222 - 223 - issue, ok := r.Context().Value("issue").(*models.Issue) 224 - if !ok { 225 - log.Println("issue not found in context") 226 - http.Error(w, "issue not found", http.StatusNotFound) 227 - return 228 - } 229 - 230 - // Get comment count 231 - commentCount := len(issue.Comments) 232 - 233 - // Get owner handle for avatar 234 - var ownerHandle string 235 - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 236 - if err != nil { 237 - ownerHandle = f.Repo.Did 238 - } else { 239 - ownerHandle = "@" + owner.Handle.String() 240 - } 241 - 242 - card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 243 - if err != nil { 244 - log.Println("failed to draw issue summary card", err) 245 - http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) 246 - return 247 - } 248 - 249 - var imageBuffer bytes.Buffer 250 - err = png.Encode(&imageBuffer, card.Img) 251 - if err != nil { 252 - log.Println("failed to encode issue summary card", err) 253 - http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 254 - return 255 - } 256 - 257 - imageBytes := imageBuffer.Bytes() 258 - 259 - w.Header().Set("Content-Type", "image/png") 260 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 261 - w.WriteHeader(http.StatusOK) 262 - _, err = w.Write(imageBytes) 263 - if err != nil { 264 - log.Println("failed to write issue summary card", err) 265 - return 266 - } 267 - }
-1
appview/issues/router.go
··· 16 16 r.Route("/{issue}", func(r chi.Router) { 17 17 r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 - r.Get("/opengraph", i.IssueOpenGraphSummary) 20 19 21 20 // authenticated routes 22 21 r.Group(func(r chi.Router) {
+6 -15
appview/knots/knots.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 - "strings" 10 9 "time" 11 10 12 11 "github.com/go-chi/chi/v5" ··· 146 145 } 147 146 148 147 domain := r.FormValue("domain") 149 - // Strip protocol, trailing slashes, and whitespace 150 - // Rkey cannot contain slashes 151 - domain = strings.TrimSpace(domain) 152 - domain = strings.TrimPrefix(domain, "https://") 153 - domain = strings.TrimPrefix(domain, "http://") 154 - domain = strings.TrimSuffix(domain, "/") 155 148 if domain == "" { 156 149 k.Pages.Notice(w, noticeId, "Incomplete form.") 157 150 return ··· 192 185 return 193 186 } 194 187 195 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 196 189 var exCid *string 197 190 if ex != nil { 198 191 exCid = ex.Cid 199 192 } 200 193 201 194 // re-announce by registering under same rkey 202 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 195 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 203 196 Collection: tangled.KnotNSID, 204 197 Repo: user.Did, 205 198 Rkey: domain, ··· 330 323 return 331 324 } 332 325 333 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 326 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 334 327 Collection: tangled.KnotNSID, 335 328 Repo: user.Did, 336 329 Rkey: domain, ··· 438 431 return 439 432 } 440 433 441 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 442 435 var exCid *string 443 436 if ex != nil { 444 437 exCid = ex.Cid 445 438 } 446 439 447 440 // ignore the error here 448 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 441 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 449 442 Collection: tangled.KnotNSID, 450 443 Repo: user.Did, 451 444 Rkey: domain, ··· 533 526 } 534 527 535 528 member := r.FormValue("member") 536 - member = strings.TrimPrefix(member, "@") 537 529 if member == "" { 538 530 l.Error("empty member") 539 531 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 563 555 564 556 rkey := tid.TID() 565 557 566 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 558 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 567 559 Collection: tangled.KnotMemberNSID, 568 560 Repo: user.Did, 569 561 Rkey: rkey, ··· 634 626 } 635 627 636 628 member := r.FormValue("member") 637 - member = strings.TrimPrefix(member, "@") 638 629 if member == "" { 639 630 l.Error("empty member") 640 631 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+13 -11
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 + comatproto "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + lexutil "github.com/bluesky-social/indigo/lex/util" 15 + "github.com/go-chi/chi/v5" 16 + 12 17 "tangled.org/core/api/tangled" 13 18 "tangled.org/core/appview/db" 14 19 "tangled.org/core/appview/middleware" ··· 16 21 "tangled.org/core/appview/oauth" 17 22 "tangled.org/core/appview/pages" 18 23 "tangled.org/core/appview/validator" 24 + "tangled.org/core/appview/xrpcclient" 25 + "tangled.org/core/log" 19 26 "tangled.org/core/rbac" 20 27 "tangled.org/core/tid" 21 - 22 - comatproto "github.com/bluesky-social/indigo/api/atproto" 23 - atpclient "github.com/bluesky-social/indigo/atproto/client" 24 - "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 - "github.com/go-chi/chi/v5" 27 28 ) 28 29 29 30 type Labels struct { ··· 41 42 db *db.DB, 42 43 validator *validator.Validator, 43 44 enforcer *rbac.Enforcer, 44 - logger *slog.Logger, 45 45 ) *Labels { 46 + logger := log.New("labels") 47 + 46 48 return &Labels{ 47 49 oauth: oauth, 48 50 pages: pages, ··· 53 55 } 54 56 } 55 57 56 - func (l *Labels) Router() http.Handler { 58 + func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 57 59 r := chi.NewRouter() 58 60 59 61 r.Use(middleware.AuthMiddleware(l.oauth)) ··· 194 196 return 195 197 } 196 198 197 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 199 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 198 200 Collection: tangled.LabelOpNSID, 199 201 Repo: did, 200 202 Rkey: rkey, ··· 250 252 // this is used to rollback changes made to the PDS 251 253 // 252 254 // it is a no-op if the provided ATURI is empty 253 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 255 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 254 256 if aturi == "" { 255 257 return nil 256 258 } ··· 261 263 repo := parsed.Authority().String() 262 264 rkey := parsed.RecordKey().String() 263 265 264 - _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 266 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 265 267 Collection: collection, 266 268 Repo: repo, 267 269 Rkey: rkey,
+30 -16
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 46 + func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 + return func(next http.Handler) http.Handler { 48 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + _, _, _ = mw.oauth.GetSession(r) 50 + next.ServeHTTP(w, r) 51 + }) 52 + } 53 + } 54 + 55 + func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 56 return func(next http.Handler) http.Handler { 48 57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 58 returnURL := "/" ··· 63 72 } 64 73 } 65 74 66 - sess, err := o.ResumeSession(r) 75 + _, auth, err := a.GetSession(r) 67 76 if err != nil { 68 - log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 77 + log.Println("not logged in, redirecting", "err", err) 69 78 redirectFunc(w, r) 70 79 return 71 80 } 72 81 73 - if sess == nil { 74 - log.Printf("session is nil, redirecting...") 82 + if !auth { 83 + log.Printf("not logged in, redirecting") 75 84 redirectFunc(w, r) 76 85 return 77 86 } ··· 105 114 } 106 115 } 107 116 108 - ctx := pagination.IntoContext(r.Context(), page) 117 + ctx := context.WithValue(r.Context(), "page", page) 109 118 next.ServeHTTP(w, r.WithContext(ctx)) 110 119 }) 111 120 } ··· 180 189 return func(next http.Handler) http.Handler { 181 190 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 191 didOrHandle := chi.URLParam(req, "user") 183 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 184 - 185 192 if slices.Contains(excluded, didOrHandle) { 186 193 next.ServeHTTP(w, req) 187 194 return 188 195 } 189 196 197 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 198 + 190 199 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 200 if err != nil { 192 201 // invalid did or handle ··· 206 215 return func(next http.Handler) http.Handler { 207 216 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 217 repoName := chi.URLParam(req, "repo") 209 - repoName = strings.TrimSuffix(repoName, ".git") 210 - 211 218 id, ok := req.Context().Value("resolvedId").(identity.Identity) 212 219 if !ok { 213 220 log.Println("malformed middleware") ··· 246 253 prId := chi.URLParam(r, "pull") 247 254 prIdInt, err := strconv.Atoi(prId) 248 255 if err != nil { 256 + http.Error(w, "bad pr id", http.StatusBadRequest) 249 257 log.Println("failed to parse pr id", err) 250 - mw.pages.Error404(w) 251 258 return 252 259 } 253 260 254 261 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 262 if err != nil { 256 263 log.Println("failed to get pull and comments", err) 257 - mw.pages.Error404(w) 258 264 return 259 265 } 260 266 ··· 295 301 issueId, err := strconv.Atoi(issueIdStr) 296 302 if err != nil { 297 303 log.Println("failed to fully resolve issue ID", err) 298 - mw.pages.Error404(w) 304 + mw.pages.ErrorKnot404(w) 299 305 return 300 306 } 301 307 302 - issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId) 308 + issues, err := db.GetIssues( 309 + mw.db, 310 + db.FilterEq("repo_at", f.RepoAt()), 311 + db.FilterEq("issue_id", issueId), 312 + ) 303 313 if err != nil { 304 314 log.Println("failed to get issues", "err", err) 305 - mw.pages.Error404(w) 315 + return 316 + } 317 + if len(issues) != 1 { 318 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 306 319 return 307 320 } 321 + issue := issues[0] 308 322 309 - ctx := context.WithValue(r.Context(), "issue", issue) 323 + ctx := context.WithValue(r.Context(), "issue", &issue) 310 324 next.ServeHTTP(w, r.WithContext(ctx)) 311 325 }) 312 326 }
-24
appview/models/issue.go
··· 54 54 Replies []*IssueComment 55 55 } 56 56 57 - func (it *CommentListItem) Participants() []syntax.DID { 58 - participantSet := make(map[syntax.DID]struct{}) 59 - participants := []syntax.DID{} 60 - 61 - addParticipant := func(did syntax.DID) { 62 - if _, exists := participantSet[did]; !exists { 63 - participantSet[did] = struct{}{} 64 - participants = append(participants, did) 65 - } 66 - } 67 - 68 - addParticipant(syntax.DID(it.Self.Did)) 69 - 70 - for _, c := range it.Replies { 71 - addParticipant(syntax.DID(c.Did)) 72 - } 73 - 74 - return participants 75 - } 76 - 77 57 func (i *Issue) CommentList() []CommentListItem { 78 58 // Create a map to quickly find comments by their aturi 79 59 toplevel := make(map[string]*CommentListItem) ··· 187 167 188 168 func (i *IssueComment) IsTopLevel() bool { 189 169 return i.ReplyTo == nil 190 - } 191 - 192 - func (i *IssueComment) IsReply() bool { 193 - return i.ReplyTo != nil 194 170 } 195 171 196 172 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+42 -25
appview/models/label.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "tangled.org/core/api/tangled" 17 + "tangled.org/core/consts" 17 18 "tangled.org/core/idresolver" 18 19 ) 19 20 ··· 460 461 return result 461 462 } 462 463 463 - func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 464 - var labelDefs []LabelDefinition 465 - ctx := context.Background() 464 + func DefaultLabelDefs() []string { 465 + rkeys := []string{ 466 + "wontfix", 467 + "duplicate", 468 + "assignee", 469 + "good-first-issue", 470 + "documentation", 471 + } 466 472 467 - for _, dl := range aturis { 468 - atUri, err := syntax.ParseATURI(dl) 469 - if err != nil { 470 - return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err) 471 - } 472 - if atUri.Collection() != tangled.LabelDefinitionNSID { 473 - return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri) 474 - } 473 + defs := make([]string, len(rkeys)) 474 + for i, r := range rkeys { 475 + defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 476 + } 475 477 476 - owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 477 - if err != nil { 478 - return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 479 - } 478 + return defs 479 + } 480 480 481 - xrpcc := xrpc.Client{ 482 - Host: owner.PDSEndpoint(), 483 - } 481 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 482 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 483 + if err != nil { 484 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 485 + } 486 + pdsEndpoint := resolved.PDSEndpoint() 487 + if pdsEndpoint == "" { 488 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 489 + } 490 + client := &xrpc.Client{ 491 + Host: pdsEndpoint, 492 + } 484 493 494 + var labelDefs []LabelDefinition 495 + 496 + for _, dl := range DefaultLabelDefs() { 497 + atUri := syntax.ATURI(dl) 498 + parsedUri, err := syntax.ParseATURI(string(atUri)) 499 + if err != nil { 500 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 501 + } 485 502 record, err := atproto.RepoGetRecord( 486 - ctx, 487 - &xrpcc, 503 + context.Background(), 504 + client, 488 505 "", 489 - atUri.Collection().String(), 490 - atUri.Authority().String(), 491 - atUri.RecordKey().String(), 506 + parsedUri.Collection().String(), 507 + parsedUri.Authority().String(), 508 + parsedUri.RecordKey().String(), 492 509 ) 493 510 if err != nil { 494 511 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 508 525 } 509 526 510 527 labelDef, err := LabelDefinitionFromRecord( 511 - atUri.Authority().String(), 512 - atUri.RecordKey().String(), 528 + parsedUri.Authority().String(), 529 + parsedUri.RecordKey().String(), 513 530 labelRecord, 514 531 ) 515 532 if err != nil {
+1 -60
appview/models/notifications.go
··· 2 2 3 3 import ( 4 4 "time" 5 - 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 5 ) 8 6 9 7 type NotificationType string ··· 17 15 NotificationTypeFollowed NotificationType = "followed" 18 16 NotificationTypePullMerged NotificationType = "pull_merged" 19 17 NotificationTypeIssueClosed NotificationType = "issue_closed" 20 - NotificationTypeIssueReopen NotificationType = "issue_reopen" 21 18 NotificationTypePullClosed NotificationType = "pull_closed" 22 - NotificationTypePullReopen NotificationType = "pull_reopen" 23 - NotificationTypeUserMentioned NotificationType = "user_mentioned" 24 19 ) 25 20 26 21 type Notification struct { ··· 50 45 return "message-square" 51 46 case NotificationTypeIssueClosed: 52 47 return "ban" 53 - case NotificationTypeIssueReopen: 54 - return "circle-dot" 55 48 case NotificationTypePullCreated: 56 49 return "git-pull-request-create" 57 50 case NotificationTypePullCommented: ··· 60 53 return "git-merge" 61 54 case NotificationTypePullClosed: 62 55 return "git-pull-request-closed" 63 - case NotificationTypePullReopen: 64 - return "git-pull-request-create" 65 56 case NotificationTypeFollowed: 66 57 return "user-plus" 67 - case NotificationTypeUserMentioned: 68 - return "at-sign" 69 58 default: 70 59 return "" 71 60 } ··· 80 69 81 70 type NotificationPreferences struct { 82 71 ID int64 83 - UserDid syntax.DID 72 + UserDid string 84 73 RepoStarred bool 85 74 IssueCreated bool 86 75 IssueCommented bool 87 76 PullCreated bool 88 77 PullCommented bool 89 78 Followed bool 90 - UserMentioned bool 91 79 PullMerged bool 92 80 IssueClosed bool 93 81 EmailNotifications bool 94 82 } 95 - 96 - func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool { 97 - switch t { 98 - case NotificationTypeRepoStarred: 99 - return prefs.RepoStarred 100 - case NotificationTypeIssueCreated: 101 - return prefs.IssueCreated 102 - case NotificationTypeIssueCommented: 103 - return prefs.IssueCommented 104 - case NotificationTypeIssueClosed: 105 - return prefs.IssueClosed 106 - case NotificationTypeIssueReopen: 107 - return prefs.IssueCreated // smae pref for now 108 - case NotificationTypePullCreated: 109 - return prefs.PullCreated 110 - case NotificationTypePullCommented: 111 - return prefs.PullCommented 112 - case NotificationTypePullMerged: 113 - return prefs.PullMerged 114 - case NotificationTypePullClosed: 115 - return prefs.PullMerged // same pref for now 116 - case NotificationTypePullReopen: 117 - return prefs.PullCreated // same pref for now 118 - case NotificationTypeFollowed: 119 - return prefs.Followed 120 - case NotificationTypeUserMentioned: 121 - return prefs.UserMentioned 122 - default: 123 - return false 124 - } 125 - } 126 - 127 - func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences { 128 - return &NotificationPreferences{ 129 - UserDid: user, 130 - RepoStarred: true, 131 - IssueCreated: true, 132 - IssueCommented: true, 133 - PullCreated: true, 134 - PullCommented: true, 135 - Followed: true, 136 - UserMentioned: true, 137 - PullMerged: true, 138 - IssueClosed: true, 139 - EmailNotifications: false, 140 - } 141 - }
-1
appview/models/profile.go
··· 19 19 Links [5]string 20 20 Stats [2]VanityStat 21 21 PinnedRepos [6]syntax.ATURI 22 - Pronouns string 23 22 } 24 23 25 24 func (p Profile) IsLinksEmpty() bool {
+28 -77
appview/models/pull.go
··· 77 77 PullSource *PullSource 78 78 79 79 // optionally, populate this when querying for reverse mappings 80 - Labels LabelState 81 - Repo *Repo 80 + Repo *Repo 82 81 } 83 82 84 83 func (p Pull) AsRecord() tangled.RepoPull { 85 84 var source *tangled.RepoPull_Source 86 85 if p.PullSource != nil { 87 - source = &tangled.RepoPull_Source{} 88 - source.Branch = p.PullSource.Branch 86 + s := p.PullSource.AsRecord() 87 + source = &s 89 88 source.Sha = p.LatestSha() 90 - if p.PullSource.RepoAt != nil { 91 - s := p.PullSource.RepoAt.String() 92 - source.Repo = &s 93 - } 94 89 } 95 90 96 91 record := tangled.RepoPull{ ··· 115 110 Repo *Repo 116 111 } 117 112 113 + func (p PullSource) AsRecord() tangled.RepoPull_Source { 114 + var repoAt *string 115 + if p.RepoAt != nil { 116 + s := p.RepoAt.String() 117 + repoAt = &s 118 + } 119 + record := tangled.RepoPull_Source{ 120 + Branch: p.Branch, 121 + Repo: repoAt, 122 + } 123 + return record 124 + } 125 + 118 126 type PullSubmission struct { 119 127 // ids 120 - ID int 128 + ID int 129 + PullId int 121 130 122 131 // at ids 123 - PullAt syntax.ATURI 132 + RepoAt syntax.ATURI 124 133 125 134 // content 126 135 RoundNumber int 127 136 Patch string 128 - Combined string 129 137 Comments []PullComment 130 138 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 131 139 ··· 151 159 Created time.Time 152 160 } 153 161 154 - func (p *Pull) LastRoundNumber() int { 155 - return len(p.Submissions) - 1 156 - } 157 - 158 - func (p *Pull) LatestSubmission() *PullSubmission { 159 - return p.Submissions[p.LastRoundNumber()] 160 - } 161 - 162 162 func (p *Pull) LatestPatch() string { 163 - return p.LatestSubmission().Patch 163 + latestSubmission := p.Submissions[p.LastRoundNumber()] 164 + return latestSubmission.Patch 164 165 } 165 166 166 167 func (p *Pull) LatestSha() string { 167 - return p.LatestSubmission().SourceRev 168 + latestSubmission := p.Submissions[p.LastRoundNumber()] 169 + return latestSubmission.SourceRev 168 170 } 169 171 170 - func (p *Pull) AtUri() syntax.ATURI { 172 + func (p *Pull) PullAt() syntax.ATURI { 171 173 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 + } 175 + 176 + func (p *Pull) LastRoundNumber() int { 177 + return len(p.Submissions) - 1 172 178 } 173 179 174 180 func (p *Pull) IsPatchBased() bool { ··· 201 207 return p.StackId != "" 202 208 } 203 209 204 - func (p *Pull) Participants() []string { 205 - participantSet := make(map[string]struct{}) 206 - participants := []string{} 207 - 208 - addParticipant := func(did string) { 209 - if _, exists := participantSet[did]; !exists { 210 - participantSet[did] = struct{}{} 211 - participants = append(participants, did) 212 - } 213 - } 214 - 215 - addParticipant(p.OwnerDid) 216 - 217 - for _, s := range p.Submissions { 218 - for _, sp := range s.Participants() { 219 - addParticipant(sp) 220 - } 221 - } 222 - 223 - return participants 224 - } 225 - 226 210 func (s PullSubmission) IsFormatPatch() bool { 227 211 return patchutil.IsFormatPatch(s.Patch) 228 212 } ··· 235 219 } 236 220 237 221 return patches 238 - } 239 - 240 - func (s *PullSubmission) Participants() []string { 241 - participantSet := make(map[string]struct{}) 242 - participants := []string{} 243 - 244 - addParticipant := func(did string) { 245 - if _, exists := participantSet[did]; !exists { 246 - participantSet[did] = struct{}{} 247 - participants = append(participants, did) 248 - } 249 - } 250 - 251 - addParticipant(s.PullAt.Authority().String()) 252 - 253 - for _, c := range s.Comments { 254 - addParticipant(c.OwnerDid) 255 - } 256 - 257 - return participants 258 - } 259 - 260 - func (s PullSubmission) CombinedPatch() string { 261 - if s.Combined == "" { 262 - return s.Patch 263 - } 264 - 265 - return s.Combined 266 222 } 267 223 268 224 type Stack []*Pull ··· 352 308 353 309 return mergeable 354 310 } 355 - 356 - type BranchDeleteStatus struct { 357 - Repo *Repo 358 - Branch string 359 - }
-5
appview/models/reaction.go
··· 55 55 Rkey string 56 56 Kind ReactionKind 57 57 } 58 - 59 - type ReactionDisplayData struct { 60 - Count int 61 - Users []string 62 - }
+1 -66
appview/models/repo.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "strings" 6 5 "time" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 18 17 Rkey string 19 18 Created time.Time 20 19 Description string 21 - Website string 22 - Topics []string 23 20 Spindle string 24 21 Labels []string 25 22 ··· 31 28 } 32 29 33 30 func (r *Repo) AsRecord() tangled.Repo { 34 - var source, spindle, description, website *string 31 + var source, spindle, description *string 35 32 36 33 if r.Source != "" { 37 34 source = &r.Source ··· 43 40 44 41 if r.Description != "" { 45 42 description = &r.Description 46 - } 47 - 48 - if r.Website != "" { 49 - website = &r.Website 50 43 } 51 44 52 45 return tangled.Repo{ 53 46 Knot: r.Knot, 54 47 Name: r.Name, 55 48 Description: description, 56 - Website: website, 57 - Topics: r.Topics, 58 49 CreatedAt: r.Created.Format(time.RFC3339), 59 50 Source: source, 60 51 Spindle: spindle, ··· 69 60 func (r Repo) DidSlashRepo() string { 70 61 p, _ := securejoin.SecureJoin(r.Did, r.Name) 71 62 return p 72 - } 73 - 74 - func (r Repo) TopicStr() string { 75 - return strings.Join(r.Topics, " ") 76 63 } 77 64 78 65 type RepoStats struct { ··· 99 86 RepoAt syntax.ATURI 100 87 LabelAt syntax.ATURI 101 88 } 102 - 103 - type RepoGroup struct { 104 - Repo *Repo 105 - Issues []Issue 106 - } 107 - 108 - type BlobContentType int 109 - 110 - const ( 111 - BlobContentTypeCode BlobContentType = iota 112 - BlobContentTypeMarkup 113 - BlobContentTypeImage 114 - BlobContentTypeSvg 115 - BlobContentTypeVideo 116 - BlobContentTypeSubmodule 117 - ) 118 - 119 - func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode } 120 - func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup } 121 - func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage } 122 - func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg } 123 - func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo } 124 - func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule } 125 - 126 - type BlobView struct { 127 - HasTextView bool // can show as code/text 128 - HasRenderedView bool // can show rendered (markup/image/video/submodule) 129 - HasRawView bool // can download raw (everything except submodule) 130 - 131 - // current display mode 132 - ShowingRendered bool // currently in rendered mode 133 - ShowingText bool // currently in text/code mode 134 - 135 - // content type flags 136 - ContentType BlobContentType 137 - 138 - // Content data 139 - Contents string 140 - ContentSrc string // URL for media files 141 - Lines int 142 - SizeHint uint64 143 - } 144 - 145 - // if both views are available, then show a toggle between them 146 - func (b BlobView) ShowToggle() bool { 147 - return b.HasTextView && b.HasRenderedView 148 - } 149 - 150 - func (b BlobView) IsUnsupported() bool { 151 - // no view available, only raw 152 - return !(b.HasRenderedView || b.HasTextView) 153 - }
-31
appview/models/search.go
··· 1 - package models 2 - 3 - import "tangled.org/core/appview/pagination" 4 - 5 - type IssueSearchOptions struct { 6 - Keyword string 7 - RepoAt string 8 - IsOpen bool 9 - 10 - Page pagination.Page 11 - } 12 - 13 - type PullSearchOptions struct { 14 - Keyword string 15 - RepoAt string 16 - State PullState 17 - 18 - Page pagination.Page 19 - } 20 - 21 - // func (so *SearchOptions) ToFilters() []filter { 22 - // var filters []filter 23 - // if so.IsOpen != nil { 24 - // openValue := 0 25 - // if *so.IsOpen { 26 - // openValue = 1 27 - // } 28 - // filters = append(filters, FilterEq("open", openValue)) 29 - // } 30 - // return filters 31 - // }
+39 -36
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "log/slog" 4 + "fmt" 5 + "log" 5 6 "net/http" 6 7 "strconv" 7 8 ··· 14 15 ) 15 16 16 17 type Notifications struct { 17 - db *db.DB 18 - oauth *oauth.OAuth 19 - pages *pages.Pages 20 - logger *slog.Logger 18 + db *db.DB 19 + oauth *oauth.OAuth 20 + pages *pages.Pages 21 21 } 22 22 23 - func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 24 24 return &Notifications{ 25 - db: database, 26 - oauth: oauthHandler, 27 - pages: pagesHandler, 28 - logger: logger, 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 29 28 } 30 29 } 31 30 32 31 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 33 32 r := chi.NewRouter() 34 33 34 + r.Use(middleware.AuthMiddleware(n.oauth)) 35 + 36 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 + 35 38 r.Get("/count", n.getUnreadCount) 36 - 37 - r.Group(func(r chi.Router) { 38 - r.Use(middleware.AuthMiddleware(n.oauth)) 39 - r.With(middleware.Paginate).Get("/", n.notificationsPage) 40 - r.Post("/{id}/read", n.markRead) 41 - r.Post("/read-all", n.markAllRead) 42 - r.Delete("/{id}", n.deleteNotification) 43 - }) 39 + r.Post("/{id}/read", n.markRead) 40 + r.Post("/read-all", n.markAllRead) 41 + r.Delete("/{id}", n.deleteNotification) 44 42 45 43 return r 46 44 } 47 45 48 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 49 - l := n.logger.With("handler", "notificationsPage") 50 - user := n.oauth.GetUser(r) 47 + userDid := n.oauth.GetDid(r) 51 48 52 - page := pagination.FromContext(r.Context()) 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 53 54 54 55 total, err := db.CountNotifications( 55 56 n.db, 56 - db.FilterEq("recipient_did", user.Did), 57 + db.FilterEq("recipient_did", userDid), 57 58 ) 58 59 if err != nil { 59 - l.Error("failed to get total notifications", "err", err) 60 + log.Println("failed to get total notifications:", err) 60 61 n.pages.Error500(w) 61 62 return 62 63 } ··· 64 65 notifications, err := db.GetNotificationsWithEntities( 65 66 n.db, 66 67 page, 67 - db.FilterEq("recipient_did", user.Did), 68 + db.FilterEq("recipient_did", userDid), 68 69 ) 69 70 if err != nil { 70 - l.Error("failed to get notifications", "err", err) 71 + log.Println("failed to get notifications:", err) 71 72 n.pages.Error500(w) 72 73 return 73 74 } 74 75 75 - err = db.MarkAllNotificationsRead(n.db, user.Did) 76 + err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 76 77 if err != nil { 77 - l.Error("failed to mark notifications as read", "err", err) 78 + log.Println("failed to mark notifications as read:", err) 78 79 } 79 80 80 81 unreadCount := 0 81 82 82 - n.pages.Notifications(w, pages.NotificationsParams{ 83 + user := n.oauth.GetUser(r) 84 + if user == nil { 85 + http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 + return 87 + } 88 + 89 + fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 83 90 LoggedInUser: user, 84 91 Notifications: notifications, 85 92 UnreadCount: unreadCount, 86 93 Page: page, 87 94 Total: total, 88 - }) 95 + })) 89 96 } 90 97 91 98 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 92 99 user := n.oauth.GetUser(r) 93 - if user == nil { 94 - return 95 - } 96 - 97 100 count, err := db.CountNotifications( 98 101 n.db, 99 102 db.FilterEq("recipient_did", user.Did), ··· 124 127 return 125 128 } 126 129 127 - err = db.MarkNotificationRead(n.db, notificationID, userDid) 130 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 128 131 if err != nil { 129 132 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 130 133 return ··· 136 139 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 137 140 userDid := n.oauth.GetDid(r) 138 141 139 - err := db.MarkAllNotificationsRead(n.db, userDid) 142 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 140 143 if err != nil { 141 144 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 142 145 return ··· 155 158 return 156 159 } 157 160 158 - err = db.DeleteNotification(n.db, notificationID, userDid) 161 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 159 162 if err != nil { 160 163 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 161 164 return
+260 -320
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 - "maps" 7 - "slices" 8 6 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 7 "tangled.org/core/appview/db" 11 8 "tangled.org/core/appview/models" 12 9 "tangled.org/core/appview/notify" 13 10 "tangled.org/core/idresolver" 14 - ) 15 - 16 - const ( 17 - maxMentions = 5 18 11 ) 19 12 20 13 type databaseNotifier struct { ··· 43 36 return 44 37 } 45 38 46 - actorDid := syntax.DID(star.StarredByDid) 47 - recipients := []syntax.DID{syntax.DID(repo.Did)} 48 - eventType := models.NotificationTypeRepoStarred 49 - entityType := "repo" 50 - entityId := star.RepoAt.String() 51 - repoId := &repo.Id 52 - var issueId *int64 53 - var pullId *int64 39 + // don't notify yourself 40 + if repo.Did == star.StarredByDid { 41 + return 42 + } 54 43 55 - n.notifyEvent( 56 - actorDid, 57 - recipients, 58 - eventType, 59 - entityType, 60 - entityId, 61 - repoId, 62 - issueId, 63 - pullId, 64 - ) 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 65 67 } 66 68 67 69 func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 68 70 // no-op 69 71 } 70 72 71 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 73 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 + if err != nil { 76 + log.Printf("NewIssue: failed to get repos: %v", err) 77 + return 78 + } 72 79 73 - // build the recipients list 74 - // - owner of the repo 75 - // - collaborators in the repo 76 - var recipients []syntax.DID 77 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 78 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 80 + if repo.Did == issue.Did { 81 + return 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 79 85 if err != nil { 80 - log.Printf("failed to fetch collaborators: %v", err) 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 81 87 return 82 88 } 83 - for _, c := range collaborators { 84 - recipients = append(recipients, c.SubjectDid) 89 + if !prefs.IssueCreated { 90 + return 85 91 } 86 92 87 - actorDid := syntax.DID(issue.Did) 88 - entityType := "issue" 89 - entityId := issue.AtUri().String() 90 - repoId := &issue.Repo.Id 91 - issueId := &issue.Id 92 - var pullId *int64 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 93 102 94 - n.notifyEvent( 95 - actorDid, 96 - recipients, 97 - models.NotificationTypeIssueCreated, 98 - entityType, 99 - entityId, 100 - repoId, 101 - issueId, 102 - pullId, 103 - ) 104 - n.notifyEvent( 105 - actorDid, 106 - mentions, 107 - models.NotificationTypeUserMentioned, 108 - entityType, 109 - entityId, 110 - repoId, 111 - issueId, 112 - pullId, 113 - ) 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 114 108 } 115 109 116 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 117 111 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 118 112 if err != nil { 119 113 log.Printf("NewIssueComment: failed to get issues: %v", err) ··· 125 119 } 126 120 issue := issues[0] 127 121 128 - var recipients []syntax.DID 129 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 + if err != nil { 124 + log.Printf("NewIssueComment: failed to get repos: %v", err) 125 + return 126 + } 130 127 131 - if comment.IsReply() { 132 - // if this comment is a reply, then notify everybody in that thread 133 - parentAtUri := *comment.ReplyTo 134 - allThreads := issue.CommentList() 128 + recipients := make(map[string]bool) 135 129 136 - // find the parent thread, and add all DIDs from here to the recipient list 137 - for _, t := range allThreads { 138 - if t.Self.AtUri().String() == parentAtUri { 139 - recipients = append(recipients, t.Participants()...) 140 - } 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 141 137 } 142 - } else { 143 - // not a reply, notify just the issue author 144 - recipients = append(recipients, syntax.DID(issue.Did)) 145 138 } 146 139 147 - actorDid := syntax.DID(comment.Did) 148 - entityType := "issue" 149 - entityId := issue.AtUri().String() 150 - repoId := &issue.Repo.Id 151 - issueId := &issue.Id 152 - var pullId *int64 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 153 149 154 - n.notifyEvent( 155 - actorDid, 156 - recipients, 157 - models.NotificationTypeIssueCommented, 158 - entityType, 159 - entityId, 160 - repoId, 161 - issueId, 162 - pullId, 163 - ) 164 - n.notifyEvent( 165 - actorDid, 166 - mentions, 167 - models.NotificationTypeUserMentioned, 168 - entityType, 169 - entityId, 170 - repoId, 171 - issueId, 172 - pullId, 173 - ) 174 - } 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 175 161 176 - func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 177 - // no-op for now 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 178 167 } 179 168 180 169 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 181 - actorDid := syntax.DID(follow.UserDid) 182 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 183 - eventType := models.NotificationTypeFollowed 184 - entityType := "follow" 185 - entityId := follow.UserDid 186 - var repoId, issueId, pullId *int64 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 187 186 188 - n.notifyEvent( 189 - actorDid, 190 - recipients, 191 - eventType, 192 - entityType, 193 - entityId, 194 - repoId, 195 - issueId, 196 - pullId, 197 - ) 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 198 192 } 199 193 200 194 func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { ··· 208 202 return 209 203 } 210 204 211 - // build the recipients list 212 - // - owner of the repo 213 - // - collaborators in the repo 214 - var recipients []syntax.DID 215 - recipients = append(recipients, syntax.DID(repo.Did)) 216 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 205 + if repo.Did == pull.OwnerDid { 206 + return 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 217 210 if err != nil { 218 - log.Printf("failed to fetch collaborators: %v", err) 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 219 212 return 220 213 } 221 - for _, c := range collaborators { 222 - recipients = append(recipients, c.SubjectDid) 214 + if !prefs.PullCreated { 215 + return 223 216 } 224 217 225 - actorDid := syntax.DID(pull.OwnerDid) 226 - eventType := models.NotificationTypePullCreated 227 - entityType := "pull" 228 - entityId := pull.AtUri().String() 229 - repoId := &repo.Id 230 - var issueId *int64 231 - p := int64(pull.ID) 232 - pullId := &p 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 233 227 234 - n.notifyEvent( 235 - actorDid, 236 - recipients, 237 - eventType, 238 - entityType, 239 - entityId, 240 - repoId, 241 - issueId, 242 - pullId, 243 - ) 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 244 233 } 245 234 246 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 247 - pull, err := db.GetPull(n.db, 248 - syntax.ATURI(comment.RepoAt), 249 - comment.PullId, 250 - ) 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 251 239 if err != nil { 252 240 log.Printf("NewPullComment: failed to get pulls: %v", err) 253 241 return 254 242 } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + return 246 + } 247 + pull := pulls[0] 255 248 256 249 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 257 250 if err != nil { ··· 259 252 return 260 253 } 261 254 262 - // build up the recipients list: 263 - // - repo owner 264 - // - all pull participants 265 - var recipients []syntax.DID 266 - recipients = append(recipients, syntax.DID(repo.Did)) 267 - for _, p := range pull.Participants() { 268 - recipients = append(recipients, syntax.DID(p)) 255 + recipients := make(map[string]bool) 256 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 269 265 } 270 266 271 - actorDid := syntax.DID(comment.OwnerDid) 272 - eventType := models.NotificationTypePullCommented 273 - entityType := "pull" 274 - entityId := pull.AtUri().String() 275 - repoId := &repo.Id 276 - var issueId *int64 277 - p := int64(pull.ID) 278 - pullId := &p 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 279 287 280 - n.notifyEvent( 281 - actorDid, 282 - recipients, 283 - eventType, 284 - entityType, 285 - entityId, 286 - repoId, 287 - issueId, 288 - pullId, 289 - ) 290 - n.notifyEvent( 291 - actorDid, 292 - mentions, 293 - models.NotificationTypeUserMentioned, 294 - entityType, 295 - entityId, 296 - repoId, 297 - issueId, 298 - pullId, 299 - ) 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 300 293 } 301 294 302 295 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 315 308 // no-op 316 309 } 317 310 318 - func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 319 - // build up the recipients list: 320 - // - repo owner 321 - // - repo collaborators 322 - // - all issue participants 323 - var recipients []syntax.DID 324 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 325 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 311 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 + // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 326 314 if err != nil { 327 - log.Printf("failed to fetch collaborators: %v", err) 315 + log.Printf("NewIssueClosed: failed to get repos: %v", err) 328 316 return 329 317 } 330 - for _, c := range collaborators { 331 - recipients = append(recipients, c.SubjectDid) 332 - } 333 - for _, p := range issue.Participants() { 334 - recipients = append(recipients, syntax.DID(p)) 318 + 319 + // Don't notify yourself 320 + if repo.Did == issue.Did { 321 + return 335 322 } 336 323 337 - entityType := "pull" 338 - entityId := issue.AtUri().String() 339 - repoId := &issue.Repo.Id 340 - issueId := &issue.Id 341 - var pullId *int64 342 - var eventType models.NotificationType 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 343 333 344 - if issue.Open { 345 - eventType = models.NotificationTypeIssueReopen 346 - } else { 347 - eventType = models.NotificationTypeIssueClosed 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 348 342 } 349 343 350 - n.notifyEvent( 351 - actor, 352 - recipients, 353 - eventType, 354 - entityType, 355 - entityId, 356 - repoId, 357 - issueId, 358 - pullId, 359 - ) 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 360 349 } 361 350 362 - func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 351 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 363 352 // Get repo details 364 353 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 365 354 if err != nil { 366 - log.Printf("NewPullState: failed to get repos: %v", err) 355 + log.Printf("NewPullMerged: failed to get repos: %v", err) 367 356 return 368 357 } 369 358 370 - // build up the recipients list: 371 - // - repo owner 372 - // - all pull participants 373 - var recipients []syntax.DID 374 - recipients = append(recipients, syntax.DID(repo.Did)) 375 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 359 + // Don't notify yourself 360 + if repo.Did == pull.OwnerDid { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 376 366 if err != nil { 377 - log.Printf("failed to fetch collaborators: %v", err) 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 378 368 return 379 369 } 380 - for _, c := range collaborators { 381 - recipients = append(recipients, c.SubjectDid) 370 + if !prefs.PullMerged { 371 + return 382 372 } 383 - for _, p := range pull.Participants() { 384 - recipients = append(recipients, syntax.DID(p)) 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 385 382 } 386 383 387 - entityType := "pull" 388 - entityId := pull.AtUri().String() 389 - repoId := &repo.Id 390 - var issueId *int64 391 - var eventType models.NotificationType 392 - switch pull.State { 393 - case models.PullClosed: 394 - eventType = models.NotificationTypePullClosed 395 - case models.PullOpen: 396 - eventType = models.NotificationTypePullReopen 397 - case models.PullMerged: 398 - eventType = models.NotificationTypePullMerged 399 - default: 400 - log.Println("NewPullState: unexpected new PR state:", pull.State) 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 401 387 return 402 388 } 403 - p := int64(pull.ID) 404 - pullId := &p 405 - 406 - n.notifyEvent( 407 - actor, 408 - recipients, 409 - eventType, 410 - entityType, 411 - entityId, 412 - repoId, 413 - issueId, 414 - pullId, 415 - ) 416 389 } 417 390 418 - func (n *databaseNotifier) notifyEvent( 419 - actorDid syntax.DID, 420 - recipients []syntax.DID, 421 - eventType models.NotificationType, 422 - entityType string, 423 - entityId string, 424 - repoId *int64, 425 - issueId *int64, 426 - pullId *int64, 427 - ) { 428 - if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 429 - recipients = recipients[:maxMentions] 430 - } 431 - recipientSet := make(map[syntax.DID]struct{}) 432 - for _, did := range recipients { 433 - // everybody except actor themselves 434 - if did != actorDid { 435 - recipientSet[did] = struct{}{} 436 - } 391 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 + // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 + if err != nil { 395 + log.Printf("NewPullClosed: failed to get repos: %v", err) 396 + return 437 397 } 438 398 439 - prefMap, err := db.GetNotificationPreferences( 440 - n.db, 441 - db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 442 - ) 443 - if err != nil { 444 - // failed to get prefs for users 399 + // Don't notify yourself 400 + if repo.Did == pull.OwnerDid { 445 401 return 446 402 } 447 403 448 - // create a transaction for bulk notification storage 449 - tx, err := n.db.Begin() 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 450 406 if err != nil { 451 - // failed to start tx 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 452 408 return 453 409 } 454 - defer tx.Rollback() 455 - 456 - // filter based on preferences 457 - for recipientDid := range recipientSet { 458 - prefs, ok := prefMap[recipientDid] 459 - if !ok { 460 - prefs = models.DefaultNotificationPreferences(recipientDid) 461 - } 462 - 463 - // skip users who don’t want this type 464 - if !prefs.ShouldNotify(eventType) { 465 - continue 466 - } 467 - 468 - // create notification 469 - notif := &models.Notification{ 470 - RecipientDid: recipientDid.String(), 471 - ActorDid: actorDid.String(), 472 - Type: eventType, 473 - EntityType: entityType, 474 - EntityId: entityId, 475 - RepoId: repoId, 476 - IssueId: issueId, 477 - PullId: pullId, 478 - } 410 + if !prefs.PullMerged { 411 + return 412 + } 479 413 480 - if err := db.CreateNotification(tx, notif); err != nil { 481 - log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 482 - } 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 483 422 } 484 423 485 - if err := tx.Commit(); err != nil { 486 - // failed to commit 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 487 427 return 488 428 } 489 429 }
+59 -57
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log/slog" 6 - "reflect" 7 - "sync" 8 5 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 6 "tangled.org/core/appview/models" 11 - "tangled.org/core/log" 12 7 ) 13 8 14 9 type mergedNotifier struct { 15 10 notifiers []Notifier 16 - logger *slog.Logger 17 11 } 18 12 19 - func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 - return &mergedNotifier{notifiers, logger} 13 + func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 + return &mergedNotifier{notifiers} 21 15 } 22 16 23 17 var _ Notifier = &mergedNotifier{} 24 18 25 - // fanout calls the same method on all notifiers concurrently 26 - func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) { 27 - ctx = log.IntoContext(ctx, m.logger.With("method", method)) 28 - var wg sync.WaitGroup 29 - for _, n := range m.notifiers { 30 - wg.Add(1) 31 - go func(notifier Notifier) { 32 - defer wg.Done() 33 - v := reflect.ValueOf(notifier).MethodByName(method) 34 - in := make([]reflect.Value, len(args)+1) 35 - in[0] = reflect.ValueOf(ctx) 36 - for i, arg := range args { 37 - in[i+1] = reflect.ValueOf(arg) 38 - } 39 - v.Call(in) 40 - }(n) 41 - } 42 - wg.Wait() 43 - } 44 - 45 19 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 46 - m.fanout("NewRepo", ctx, repo) 20 + for _, notifier := range m.notifiers { 21 + notifier.NewRepo(ctx, repo) 22 + } 47 23 } 48 24 49 25 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 50 - m.fanout("NewStar", ctx, star) 26 + for _, notifier := range m.notifiers { 27 + notifier.NewStar(ctx, star) 28 + } 51 29 } 52 - 53 30 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 54 - m.fanout("DeleteStar", ctx, star) 31 + for _, notifier := range m.notifiers { 32 + notifier.DeleteStar(ctx, star) 33 + } 55 34 } 56 35 57 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 58 - m.fanout("NewIssue", ctx, issue, mentions) 59 - } 60 - 61 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 62 - m.fanout("NewIssueComment", ctx, comment, mentions) 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 + for _, notifier := range m.notifiers { 38 + notifier.NewIssue(ctx, issue) 39 + } 63 40 } 64 - 65 - func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 66 - m.fanout("NewIssueState", ctx, actor, issue) 41 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 + for _, notifier := range m.notifiers { 43 + notifier.NewIssueComment(ctx, comment) 44 + } 67 45 } 68 46 69 - func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 70 - m.fanout("DeleteIssue", ctx, issue) 47 + func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 + for _, notifier := range m.notifiers { 49 + notifier.NewIssueClosed(ctx, issue) 50 + } 71 51 } 72 52 73 53 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 74 - m.fanout("NewFollow", ctx, follow) 54 + for _, notifier := range m.notifiers { 55 + notifier.NewFollow(ctx, follow) 56 + } 75 57 } 76 - 77 58 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 78 - m.fanout("DeleteFollow", ctx, follow) 59 + for _, notifier := range m.notifiers { 60 + notifier.DeleteFollow(ctx, follow) 61 + } 79 62 } 80 63 81 64 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 82 - m.fanout("NewPull", ctx, pull) 65 + for _, notifier := range m.notifiers { 66 + notifier.NewPull(ctx, pull) 67 + } 68 + } 69 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 + for _, notifier := range m.notifiers { 71 + notifier.NewPullComment(ctx, comment) 72 + } 83 73 } 84 74 85 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 - m.fanout("NewPullComment", ctx, comment, mentions) 75 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 + for _, notifier := range m.notifiers { 77 + notifier.NewPullMerged(ctx, pull) 78 + } 87 79 } 88 80 89 - func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 90 - m.fanout("NewPullState", ctx, actor, pull) 81 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 + for _, notifier := range m.notifiers { 83 + notifier.NewPullClosed(ctx, pull) 84 + } 91 85 } 92 86 93 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 94 - m.fanout("UpdateProfile", ctx, profile) 88 + for _, notifier := range m.notifiers { 89 + notifier.UpdateProfile(ctx, profile) 90 + } 95 91 } 96 92 97 - func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 98 - m.fanout("NewString", ctx, s) 93 + func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 + for _, notifier := range m.notifiers { 95 + notifier.NewString(ctx, string) 96 + } 99 97 } 100 98 101 - func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 102 - m.fanout("EditString", ctx, s) 99 + func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 + for _, notifier := range m.notifiers { 101 + notifier.EditString(ctx, string) 102 + } 103 103 } 104 104 105 105 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 - m.fanout("DeleteString", ctx, did, rkey) 106 + for _, notifier := range m.notifiers { 107 + notifier.DeleteString(ctx, did, rkey) 108 + } 107 109 }
+13 -16
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 6 "tangled.org/core/appview/models" 8 7 ) 9 8 ··· 13 12 NewStar(ctx context.Context, star *models.Star) 14 13 DeleteStar(ctx context.Context, star *models.Star) 15 14 16 - NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 - NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 - DeleteIssue(ctx context.Context, issue *models.Issue) 15 + NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 20 18 21 19 NewFollow(ctx context.Context, follow *models.Follow) 22 20 DeleteFollow(ctx context.Context, follow *models.Follow) 23 21 24 22 NewPull(ctx context.Context, pull *models.Pull) 25 - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 - NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 23 + NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 27 26 28 27 UpdateProfile(ctx context.Context, profile *models.Profile) 29 28 ··· 42 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 43 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 43 45 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 - } 48 - func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 - func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 50 47 51 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 52 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 53 50 54 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 - } 57 - func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 51 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 + func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 58 55 59 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 60 57
+9 -33
appview/notify/posthog/notifier.go
··· 4 4 "context" 5 5 "log" 6 6 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 7 "github.com/posthog/posthog-go" 9 8 "tangled.org/core/appview/models" 10 9 "tangled.org/core/appview/notify" ··· 57 56 } 58 57 } 59 58 60 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 61 60 err := n.client.Enqueue(posthog.Capture{ 62 61 DistinctId: issue.Did, 63 62 Event: "new_issue", 64 63 Properties: posthog.Properties{ 65 64 "repo_at": issue.RepoAt.String(), 66 65 "issue_id": issue.IssueId, 67 - "mentions": mentions, 68 66 }, 69 67 }) 70 68 if err != nil { ··· 86 84 } 87 85 } 88 86 89 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 90 88 err := n.client.Enqueue(posthog.Capture{ 91 89 DistinctId: comment.OwnerDid, 92 90 Event: "new_pull_comment", 93 91 Properties: posthog.Properties{ 94 - "repo_at": comment.RepoAt, 95 - "pull_id": comment.PullId, 96 - "mentions": mentions, 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 97 94 }, 98 95 }) 99 96 if err != nil { ··· 180 177 } 181 178 } 182 179 183 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 184 181 err := n.client.Enqueue(posthog.Capture{ 185 182 DistinctId: comment.Did, 186 183 Event: "new_issue_comment", 187 184 Properties: posthog.Properties{ 188 185 "issue_at": comment.IssueAt, 189 - "mentions": mentions, 190 186 }, 191 187 }) 192 188 if err != nil { ··· 194 190 } 195 191 } 196 192 197 - func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 198 - var event string 199 - if issue.Open { 200 - event = "issue_reopen" 201 - } else { 202 - event = "issue_closed" 203 - } 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 204 194 err := n.client.Enqueue(posthog.Capture{ 205 195 DistinctId: issue.Did, 206 - Event: event, 196 + Event: "issue_closed", 207 197 Properties: posthog.Properties{ 208 198 "repo_at": issue.RepoAt.String(), 209 - "actor": actor, 210 199 "issue_id": issue.IssueId, 211 200 }, 212 201 }) ··· 215 204 } 216 205 } 217 206 218 - func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 219 - var event string 220 - switch pull.State { 221 - case models.PullClosed: 222 - event = "pull_closed" 223 - case models.PullOpen: 224 - event = "pull_reopen" 225 - case models.PullMerged: 226 - event = "pull_merged" 227 - default: 228 - log.Println("posthog: unexpected new PR state:", pull.State) 229 - return 230 - } 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 231 208 err := n.client.Enqueue(posthog.Capture{ 232 209 DistinctId: pull.OwnerDid, 233 - Event: event, 210 + Event: "pull_merged", 234 211 Properties: posthog.Properties{ 235 212 "repo_at": pull.RepoAt, 236 213 "pull_id": pull.PullId, 237 - "actor": actor, 238 214 }, 239 215 }) 240 216 if err != nil {
+24
appview/oauth/client/oauth_client.go
··· 1 + package client 2 + 3 + import ( 4 + oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 + "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 + ) 7 + 8 + type OAuthClient struct { 9 + *oauth.Client 10 + } 11 + 12 + func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 + k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 + if err != nil { 15 + return nil, err 16 + } 17 + 18 + cli, err := oauth.NewClient(oauth.ClientArgs{ 19 + ClientId: clientId, 20 + ClientJwk: k, 21 + RedirectUri: redirectUri, 22 + }) 23 + return &OAuthClient{cli}, err 24 + }
+1 -2
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session-v2" 4 + SessionName = "appview-session" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 - SessionId = "id" 8 7 SessionPds = "pds" 9 8 SessionAccessJwt = "accessJwt" 10 9 SessionRefreshJwt = "refreshJwt"
+538
appview/oauth/handler/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log" 9 + "net/http" 10 + "net/url" 11 + "slices" 12 + "strings" 13 + "time" 14 + 15 + "github.com/go-chi/chi/v5" 16 + "github.com/gorilla/sessions" 17 + "github.com/lestrrat-go/jwx/v2/jwk" 18 + "github.com/posthog/posthog-go" 19 + tangled "tangled.org/core/api/tangled" 20 + sessioncache "tangled.org/core/appview/cache/session" 21 + "tangled.org/core/appview/config" 22 + "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/middleware" 24 + "tangled.org/core/appview/oauth" 25 + "tangled.org/core/appview/oauth/client" 26 + "tangled.org/core/appview/pages" 27 + "tangled.org/core/consts" 28 + "tangled.org/core/idresolver" 29 + "tangled.org/core/rbac" 30 + "tangled.org/core/tid" 31 + "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 + ) 33 + 34 + const ( 35 + oauthScope = "atproto transition:generic" 36 + ) 37 + 38 + type OAuthHandler struct { 39 + config *config.Config 40 + pages *pages.Pages 41 + idResolver *idresolver.Resolver 42 + sess *sessioncache.SessionStore 43 + db *db.DB 44 + store *sessions.CookieStore 45 + oauth *oauth.OAuth 46 + enforcer *rbac.Enforcer 47 + posthog posthog.Client 48 + } 49 + 50 + func New( 51 + config *config.Config, 52 + pages *pages.Pages, 53 + idResolver *idresolver.Resolver, 54 + db *db.DB, 55 + sess *sessioncache.SessionStore, 56 + store *sessions.CookieStore, 57 + oauth *oauth.OAuth, 58 + enforcer *rbac.Enforcer, 59 + posthog posthog.Client, 60 + ) *OAuthHandler { 61 + return &OAuthHandler{ 62 + config: config, 63 + pages: pages, 64 + idResolver: idResolver, 65 + db: db, 66 + sess: sess, 67 + store: store, 68 + oauth: oauth, 69 + enforcer: enforcer, 70 + posthog: posthog, 71 + } 72 + } 73 + 74 + func (o *OAuthHandler) Router() http.Handler { 75 + r := chi.NewRouter() 76 + 77 + r.Get("/login", o.login) 78 + r.Post("/login", o.login) 79 + 80 + r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 + 82 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 + r.Get("/oauth/jwks.json", o.jwks) 84 + r.Get("/oauth/callback", o.callback) 85 + return r 86 + } 87 + 88 + func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 + w.Header().Set("Content-Type", "application/json") 90 + w.WriteHeader(http.StatusOK) 91 + json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 + } 93 + 94 + func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 + jwks := o.config.OAuth.Jwks 96 + pubKey, err := pubKeyFromJwk(jwks) 97 + if err != nil { 98 + log.Printf("error parsing public key: %v", err) 99 + http.Error(w, err.Error(), http.StatusInternalServerError) 100 + return 101 + } 102 + 103 + response := helpers.CreateJwksResponseObject(pubKey) 104 + 105 + w.Header().Set("Content-Type", "application/json") 106 + w.WriteHeader(http.StatusOK) 107 + json.NewEncoder(w).Encode(response) 108 + } 109 + 110 + func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 + switch r.Method { 112 + case http.MethodGet: 113 + returnURL := r.URL.Query().Get("return_url") 114 + o.pages.Login(w, pages.LoginParams{ 115 + ReturnUrl: returnURL, 116 + }) 117 + case http.MethodPost: 118 + handle := r.FormValue("handle") 119 + 120 + // when users copy their handle from bsky.app, it tends to have these characters around it: 121 + // 122 + // @nelind.dk: 123 + // \u202a ensures that the handle is always rendered left to right and 124 + // \u202c reverts that so the rest of the page renders however it should 125 + handle = strings.TrimPrefix(handle, "\u202a") 126 + handle = strings.TrimSuffix(handle, "\u202c") 127 + 128 + // `@` is harmless 129 + handle = strings.TrimPrefix(handle, "@") 130 + 131 + // basic handle validation 132 + if !strings.Contains(handle, ".") { 133 + log.Println("invalid handle format", "raw", handle) 134 + o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 + return 136 + } 137 + 138 + resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 + if err != nil { 140 + log.Println("failed to resolve handle:", err) 141 + o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 + return 143 + } 144 + self := o.oauth.ClientMetadata() 145 + oauthClient, err := client.NewClient( 146 + self.ClientID, 147 + o.config.OAuth.Jwks, 148 + self.RedirectURIs[0], 149 + ) 150 + 151 + if err != nil { 152 + log.Println("failed to create oauth client:", err) 153 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 + return 155 + } 156 + 157 + authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 + if err != nil { 159 + log.Println("failed to resolve auth server:", err) 160 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 + return 162 + } 163 + 164 + authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 + if err != nil { 166 + log.Println("failed to fetch auth server metadata:", err) 167 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 + return 169 + } 170 + 171 + dpopKey, err := helpers.GenerateKey(nil) 172 + if err != nil { 173 + log.Println("failed to generate dpop key:", err) 174 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 + return 176 + } 177 + 178 + dpopKeyJson, err := json.Marshal(dpopKey) 179 + if err != nil { 180 + log.Println("failed to marshal dpop key:", err) 181 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 + return 183 + } 184 + 185 + parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 + if err != nil { 187 + log.Println("failed to send par auth request:", err) 188 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 + return 190 + } 191 + 192 + err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 + Did: resolved.DID.String(), 194 + PdsUrl: resolved.PDSEndpoint(), 195 + Handle: handle, 196 + AuthserverIss: authMeta.Issuer, 197 + PkceVerifier: parResp.PkceVerifier, 198 + DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 + DpopPrivateJwk: string(dpopKeyJson), 200 + State: parResp.State, 201 + ReturnUrl: r.FormValue("return_url"), 202 + }) 203 + if err != nil { 204 + log.Println("failed to save oauth request:", err) 205 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 + return 207 + } 208 + 209 + u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 + query := url.Values{} 211 + query.Add("client_id", self.ClientID) 212 + query.Add("request_uri", parResp.RequestUri) 213 + u.RawQuery = query.Encode() 214 + o.pages.HxRedirect(w, u.String()) 215 + } 216 + } 217 + 218 + func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 + state := r.FormValue("state") 220 + 221 + oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 + if err != nil { 223 + log.Println("failed to get oauth request:", err) 224 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 + return 226 + } 227 + 228 + defer func() { 229 + err := o.sess.DeleteRequestByState(r.Context(), state) 230 + if err != nil { 231 + log.Println("failed to delete oauth request for state:", state, err) 232 + } 233 + }() 234 + 235 + error := r.FormValue("error") 236 + errorDescription := r.FormValue("error_description") 237 + if error != "" || errorDescription != "" { 238 + log.Printf("error: %s, %s", error, errorDescription) 239 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 + return 241 + } 242 + 243 + code := r.FormValue("code") 244 + if code == "" { 245 + log.Println("missing code for state: ", state) 246 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 + return 248 + } 249 + 250 + iss := r.FormValue("iss") 251 + if iss == "" { 252 + log.Println("missing iss for state: ", state) 253 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 + return 255 + } 256 + 257 + if iss != oauthRequest.AuthserverIss { 258 + log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 + return 261 + } 262 + 263 + self := o.oauth.ClientMetadata() 264 + 265 + oauthClient, err := client.NewClient( 266 + self.ClientID, 267 + o.config.OAuth.Jwks, 268 + self.RedirectURIs[0], 269 + ) 270 + 271 + if err != nil { 272 + log.Println("failed to create oauth client:", err) 273 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 + return 275 + } 276 + 277 + jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 + if err != nil { 279 + log.Println("failed to parse jwk:", err) 280 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 + return 282 + } 283 + 284 + tokenResp, err := oauthClient.InitialTokenRequest( 285 + r.Context(), 286 + code, 287 + oauthRequest.AuthserverIss, 288 + oauthRequest.PkceVerifier, 289 + oauthRequest.DpopAuthserverNonce, 290 + jwk, 291 + ) 292 + if err != nil { 293 + log.Println("failed to get token:", err) 294 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 + return 296 + } 297 + 298 + if tokenResp.Scope != oauthScope { 299 + log.Println("scope doesn't match:", tokenResp.Scope) 300 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 + return 302 + } 303 + 304 + err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 + if err != nil { 306 + log.Println("failed to save session:", err) 307 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 + return 309 + } 310 + 311 + log.Println("session saved successfully") 312 + go o.addToDefaultKnot(oauthRequest.Did) 313 + go o.addToDefaultSpindle(oauthRequest.Did) 314 + 315 + if !o.config.Core.Dev { 316 + err = o.posthog.Enqueue(posthog.Capture{ 317 + DistinctId: oauthRequest.Did, 318 + Event: "signin", 319 + }) 320 + if err != nil { 321 + log.Println("failed to enqueue posthog event:", err) 322 + } 323 + } 324 + 325 + returnUrl := oauthRequest.ReturnUrl 326 + if returnUrl == "" { 327 + returnUrl = "/" 328 + } 329 + 330 + http.Redirect(w, r, returnUrl, http.StatusFound) 331 + } 332 + 333 + func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 + err := o.oauth.ClearSession(r, w) 335 + if err != nil { 336 + log.Println("failed to clear session:", err) 337 + http.Redirect(w, r, "/", http.StatusFound) 338 + return 339 + } 340 + 341 + log.Println("session cleared successfully") 342 + o.pages.HxRedirect(w, "/login") 343 + } 344 + 345 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 + k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 + if err != nil { 348 + return nil, err 349 + } 350 + pubKey, err := k.PublicKey() 351 + if err != nil { 352 + return nil, err 353 + } 354 + return pubKey, nil 355 + } 356 + 357 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 + // use the tangled.sh app password to get an accessJwt 359 + // and create an sh.tangled.spindle.member record with that 360 + spindleMembers, err := db.GetSpindleMembers( 361 + o.db, 362 + db.FilterEq("instance", "spindle.tangled.sh"), 363 + db.FilterEq("subject", did), 364 + ) 365 + if err != nil { 366 + log.Printf("failed to get spindle members for did %s: %v", did, err) 367 + return 368 + } 369 + 370 + if len(spindleMembers) != 0 { 371 + log.Printf("did %s is already a member of the default spindle", did) 372 + return 373 + } 374 + 375 + log.Printf("adding %s to default spindle", did) 376 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 + if err != nil { 378 + log.Printf("failed to create session: %s", err) 379 + return 380 + } 381 + 382 + record := tangled.SpindleMember{ 383 + LexiconTypeID: "sh.tangled.spindle.member", 384 + Subject: did, 385 + Instance: consts.DefaultSpindle, 386 + CreatedAt: time.Now().Format(time.RFC3339), 387 + } 388 + 389 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 + log.Printf("failed to add member to default spindle: %s", err) 391 + return 392 + } 393 + 394 + log.Printf("successfully added %s to default spindle", did) 395 + } 396 + 397 + func (o *OAuthHandler) addToDefaultKnot(did string) { 398 + // use the tangled.sh app password to get an accessJwt 399 + // and create an sh.tangled.spindle.member record with that 400 + 401 + allKnots, err := o.enforcer.GetKnotsForUser(did) 402 + if err != nil { 403 + log.Printf("failed to get knot members for did %s: %v", did, err) 404 + return 405 + } 406 + 407 + if slices.Contains(allKnots, consts.DefaultKnot) { 408 + log.Printf("did %s is already a member of the default knot", did) 409 + return 410 + } 411 + 412 + log.Printf("adding %s to default knot", did) 413 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 + if err != nil { 415 + log.Printf("failed to create session: %s", err) 416 + return 417 + } 418 + 419 + record := tangled.KnotMember{ 420 + LexiconTypeID: "sh.tangled.knot.member", 421 + Subject: did, 422 + Domain: consts.DefaultKnot, 423 + CreatedAt: time.Now().Format(time.RFC3339), 424 + } 425 + 426 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 + log.Printf("failed to add member to default knot: %s", err) 428 + return 429 + } 430 + 431 + if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 + log.Printf("failed to set up enforcer rules: %s", err) 433 + return 434 + } 435 + 436 + log.Printf("successfully added %s to default Knot", did) 437 + } 438 + 439 + // create a session using apppasswords 440 + type session struct { 441 + AccessJwt string `json:"accessJwt"` 442 + PdsEndpoint string 443 + Did string 444 + } 445 + 446 + func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 + if appPassword == "" { 448 + return nil, fmt.Errorf("no app password configured, skipping member addition") 449 + } 450 + 451 + resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 + if err != nil { 453 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 + } 455 + 456 + pdsEndpoint := resolved.PDSEndpoint() 457 + if pdsEndpoint == "" { 458 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 + } 460 + 461 + sessionPayload := map[string]string{ 462 + "identifier": did, 463 + "password": appPassword, 464 + } 465 + sessionBytes, err := json.Marshal(sessionPayload) 466 + if err != nil { 467 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 + } 469 + 470 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 + if err != nil { 473 + return nil, fmt.Errorf("failed to create session request: %v", err) 474 + } 475 + sessionReq.Header.Set("Content-Type", "application/json") 476 + 477 + client := &http.Client{Timeout: 30 * time.Second} 478 + sessionResp, err := client.Do(sessionReq) 479 + if err != nil { 480 + return nil, fmt.Errorf("failed to create session: %v", err) 481 + } 482 + defer sessionResp.Body.Close() 483 + 484 + if sessionResp.StatusCode != http.StatusOK { 485 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 + } 487 + 488 + var session session 489 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 + return nil, fmt.Errorf("failed to decode session response: %v", err) 491 + } 492 + 493 + session.PdsEndpoint = pdsEndpoint 494 + session.Did = did 495 + 496 + return &session, nil 497 + } 498 + 499 + func (s *session) putRecord(record any, collection string) error { 500 + recordBytes, err := json.Marshal(record) 501 + if err != nil { 502 + return fmt.Errorf("failed to marshal knot member record: %w", err) 503 + } 504 + 505 + payload := map[string]any{ 506 + "repo": s.Did, 507 + "collection": collection, 508 + "rkey": tid.TID(), 509 + "record": json.RawMessage(recordBytes), 510 + } 511 + 512 + payloadBytes, err := json.Marshal(payload) 513 + if err != nil { 514 + return fmt.Errorf("failed to marshal request payload: %w", err) 515 + } 516 + 517 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 + if err != nil { 520 + return fmt.Errorf("failed to create HTTP request: %w", err) 521 + } 522 + 523 + req.Header.Set("Content-Type", "application/json") 524 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 + 526 + client := &http.Client{Timeout: 30 * time.Second} 527 + resp, err := client.Do(req) 528 + if err != nil { 529 + return fmt.Errorf("failed to add user to default service: %w", err) 530 + } 531 + defer resp.Body.Close() 532 + 533 + if resp.StatusCode != http.StatusOK { 534 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 + } 536 + 537 + return nil 538 + }
-278
appview/oauth/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "net/http" 10 - "slices" 11 - "time" 12 - 13 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 - "github.com/go-chi/chi/v5" 15 - "github.com/posthog/posthog-go" 16 - "tangled.org/core/api/tangled" 17 - "tangled.org/core/appview/db" 18 - "tangled.org/core/consts" 19 - "tangled.org/core/tid" 20 - ) 21 - 22 - func (o *OAuth) Router() http.Handler { 23 - r := chi.NewRouter() 24 - 25 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 26 - r.Get("/oauth/jwks.json", o.jwks) 27 - r.Get("/oauth/callback", o.callback) 28 - return r 29 - } 30 - 31 - func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 32 - doc := o.ClientApp.Config.ClientMetadata() 33 - doc.JWKSURI = &o.JwksUri 34 - doc.ClientName = &o.ClientName 35 - doc.ClientURI = &o.ClientUri 36 - 37 - w.Header().Set("Content-Type", "application/json") 38 - if err := json.NewEncoder(w).Encode(doc); err != nil { 39 - http.Error(w, err.Error(), http.StatusInternalServerError) 40 - return 41 - } 42 - } 43 - 44 - func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 45 - w.Header().Set("Content-Type", "application/json") 46 - body := o.ClientApp.Config.PublicJWKS() 47 - if err := json.NewEncoder(w).Encode(body); err != nil { 48 - http.Error(w, err.Error(), http.StatusInternalServerError) 49 - return 50 - } 51 - } 52 - 53 - func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 54 - ctx := r.Context() 55 - l := o.Logger.With("query", r.URL.Query()) 56 - 57 - sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 58 - if err != nil { 59 - var callbackErr *oauth.AuthRequestCallbackError 60 - if errors.As(err, &callbackErr) { 61 - l.Debug("callback error", "err", callbackErr) 62 - http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 63 - return 64 - } 65 - l.Error("failed to process callback", "err", err) 66 - http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 67 - return 68 - } 69 - 70 - if err := o.SaveSession(w, r, sessData); err != nil { 71 - l.Error("failed to save session", "data", sessData, "err", err) 72 - http.Redirect(w, r, "/login?error=session", http.StatusFound) 73 - return 74 - } 75 - 76 - o.Logger.Debug("session saved successfully") 77 - go o.addToDefaultKnot(sessData.AccountDID.String()) 78 - go o.addToDefaultSpindle(sessData.AccountDID.String()) 79 - 80 - if !o.Config.Core.Dev { 81 - err = o.Posthog.Enqueue(posthog.Capture{ 82 - DistinctId: sessData.AccountDID.String(), 83 - Event: "signin", 84 - }) 85 - if err != nil { 86 - o.Logger.Error("failed to enqueue posthog event", "err", err) 87 - } 88 - } 89 - 90 - http.Redirect(w, r, "/", http.StatusFound) 91 - } 92 - 93 - func (o *OAuth) addToDefaultSpindle(did string) { 94 - l := o.Logger.With("subject", did) 95 - 96 - // use the tangled.sh app password to get an accessJwt 97 - // and create an sh.tangled.spindle.member record with that 98 - spindleMembers, err := db.GetSpindleMembers( 99 - o.Db, 100 - db.FilterEq("instance", "spindle.tangled.sh"), 101 - db.FilterEq("subject", did), 102 - ) 103 - if err != nil { 104 - l.Error("failed to get spindle members", "err", err) 105 - return 106 - } 107 - 108 - if len(spindleMembers) != 0 { 109 - l.Warn("already a member of the default spindle") 110 - return 111 - } 112 - 113 - l.Debug("adding to default spindle") 114 - session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 115 - if err != nil { 116 - l.Error("failed to create session", "err", err) 117 - return 118 - } 119 - 120 - record := tangled.SpindleMember{ 121 - LexiconTypeID: "sh.tangled.spindle.member", 122 - Subject: did, 123 - Instance: consts.DefaultSpindle, 124 - CreatedAt: time.Now().Format(time.RFC3339), 125 - } 126 - 127 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 128 - l.Error("failed to add to default spindle", "err", err) 129 - return 130 - } 131 - 132 - l.Debug("successfully added to default spindle", "did", did) 133 - } 134 - 135 - func (o *OAuth) addToDefaultKnot(did string) { 136 - l := o.Logger.With("subject", did) 137 - 138 - // use the tangled.sh app password to get an accessJwt 139 - // and create an sh.tangled.spindle.member record with that 140 - 141 - allKnots, err := o.Enforcer.GetKnotsForUser(did) 142 - if err != nil { 143 - l.Error("failed to get knot members for did", "err", err) 144 - return 145 - } 146 - 147 - if slices.Contains(allKnots, consts.DefaultKnot) { 148 - l.Warn("already a member of the default knot") 149 - return 150 - } 151 - 152 - l.Debug("addings to default knot") 153 - session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 154 - if err != nil { 155 - l.Error("failed to create session", "err", err) 156 - return 157 - } 158 - 159 - record := tangled.KnotMember{ 160 - LexiconTypeID: "sh.tangled.knot.member", 161 - Subject: did, 162 - Domain: consts.DefaultKnot, 163 - CreatedAt: time.Now().Format(time.RFC3339), 164 - } 165 - 166 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 167 - l.Error("failed to add to default knot", "err", err) 168 - return 169 - } 170 - 171 - if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 172 - l.Error("failed to set up enforcer rules", "err", err) 173 - return 174 - } 175 - 176 - l.Debug("successfully addeds to default Knot") 177 - } 178 - 179 - // create a session using apppasswords 180 - type session struct { 181 - AccessJwt string `json:"accessJwt"` 182 - PdsEndpoint string 183 - Did string 184 - } 185 - 186 - func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 187 - if appPassword == "" { 188 - return nil, fmt.Errorf("no app password configured, skipping member addition") 189 - } 190 - 191 - resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 192 - if err != nil { 193 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 194 - } 195 - 196 - pdsEndpoint := resolved.PDSEndpoint() 197 - if pdsEndpoint == "" { 198 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 199 - } 200 - 201 - sessionPayload := map[string]string{ 202 - "identifier": did, 203 - "password": appPassword, 204 - } 205 - sessionBytes, err := json.Marshal(sessionPayload) 206 - if err != nil { 207 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 208 - } 209 - 210 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 211 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 212 - if err != nil { 213 - return nil, fmt.Errorf("failed to create session request: %v", err) 214 - } 215 - sessionReq.Header.Set("Content-Type", "application/json") 216 - 217 - client := &http.Client{Timeout: 30 * time.Second} 218 - sessionResp, err := client.Do(sessionReq) 219 - if err != nil { 220 - return nil, fmt.Errorf("failed to create session: %v", err) 221 - } 222 - defer sessionResp.Body.Close() 223 - 224 - if sessionResp.StatusCode != http.StatusOK { 225 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 226 - } 227 - 228 - var session session 229 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 230 - return nil, fmt.Errorf("failed to decode session response: %v", err) 231 - } 232 - 233 - session.PdsEndpoint = pdsEndpoint 234 - session.Did = did 235 - 236 - return &session, nil 237 - } 238 - 239 - func (s *session) putRecord(record any, collection string) error { 240 - recordBytes, err := json.Marshal(record) 241 - if err != nil { 242 - return fmt.Errorf("failed to marshal knot member record: %w", err) 243 - } 244 - 245 - payload := map[string]any{ 246 - "repo": s.Did, 247 - "collection": collection, 248 - "rkey": tid.TID(), 249 - "record": json.RawMessage(recordBytes), 250 - } 251 - 252 - payloadBytes, err := json.Marshal(payload) 253 - if err != nil { 254 - return fmt.Errorf("failed to marshal request payload: %w", err) 255 - } 256 - 257 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 258 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 259 - if err != nil { 260 - return fmt.Errorf("failed to create HTTP request: %w", err) 261 - } 262 - 263 - req.Header.Set("Content-Type", "application/json") 264 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 265 - 266 - client := &http.Client{Timeout: 30 * time.Second} 267 - resp, err := client.Do(req) 268 - if err != nil { 269 - return fmt.Errorf("failed to add user to default service: %w", err) 270 - } 271 - defer resp.Body.Close() 272 - 273 - if resp.StatusCode != http.StatusOK { 274 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 275 - } 276 - 277 - return nil 278 - }
+203 -136
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 - "errors" 5 4 "fmt" 6 - "log/slog" 5 + "log" 7 6 "net/http" 7 + "net/url" 8 8 "time" 9 9 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 - atpclient "github.com/bluesky-social/indigo/atproto/client" 13 - atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - xrpc "github.com/bluesky-social/indigo/xrpc" 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 16 11 "github.com/gorilla/sessions" 17 - "github.com/posthog/posthog-go" 12 + sessioncache "tangled.org/core/appview/cache/session" 18 13 "tangled.org/core/appview/config" 19 - "tangled.org/core/appview/db" 20 - "tangled.org/core/idresolver" 21 - "tangled.org/core/rbac" 14 + "tangled.org/core/appview/oauth/client" 15 + xrpc "tangled.org/core/appview/xrpcclient" 16 + oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 + "tangled.sh/icyphox.sh/atproto-oauth/helpers" 22 18 ) 23 19 24 20 type OAuth struct { 25 - ClientApp *oauth.ClientApp 26 - SessStore *sessions.CookieStore 27 - Config *config.Config 28 - JwksUri string 29 - ClientName string 30 - ClientUri string 31 - Posthog posthog.Client 32 - Db *db.DB 33 - Enforcer *rbac.Enforcer 34 - IdResolver *idresolver.Resolver 35 - Logger *slog.Logger 21 + store *sessions.CookieStore 22 + config *config.Config 23 + sess *sessioncache.SessionStore 36 24 } 37 25 38 - func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 39 - var oauthConfig oauth.ClientConfig 40 - var clientUri string 41 - if config.Core.Dev { 42 - clientUri = "http://127.0.0.1:3000" 43 - callbackUri := clientUri + "/oauth/callback" 44 - oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 45 - } else { 46 - clientUri = config.Core.AppviewHost 47 - clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 48 - callbackUri := clientUri + "/oauth/callback" 49 - oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 26 + func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 + return &OAuth{ 28 + store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 + config: config, 30 + sess: sess, 50 31 } 32 + } 51 33 52 - // configure client secret 53 - priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret) 34 + func (o *OAuth) Stores() *sessions.CookieStore { 35 + return o.store 36 + } 37 + 38 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 39 + // first we save the did in the user session 40 + userSession, err := o.store.Get(r, SessionName) 54 41 if err != nil { 55 - return nil, err 56 - } 57 - if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil { 58 - return nil, err 42 + return err 59 43 } 60 44 61 - jwksUri := clientUri + "/oauth/jwks.json" 62 - 63 - authStore, err := NewRedisStore(&RedisStoreConfig{ 64 - RedisURL: config.Redis.ToURL(), 65 - SessionExpiryDuration: time.Hour * 24 * 90, 66 - SessionInactivityDuration: time.Hour * 24 * 14, 67 - AuthRequestExpiryDuration: time.Minute * 30, 68 - }) 45 + userSession.Values[SessionDid] = oreq.Did 46 + userSession.Values[SessionHandle] = oreq.Handle 47 + userSession.Values[SessionPds] = oreq.PdsUrl 48 + userSession.Values[SessionAuthenticated] = true 49 + err = userSession.Save(r, w) 69 50 if err != nil { 70 - return nil, err 51 + return fmt.Errorf("error saving user session: %w", err) 71 52 } 72 53 73 - sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 74 - 75 - clientApp := oauth.NewClientApp(&oauthConfig, authStore) 76 - clientApp.Dir = res.Directory() 77 - // allow non-public transports in dev mode 78 - if config.Core.Dev { 79 - clientApp.Resolver.Client.Transport = http.DefaultTransport 54 + // then save the whole thing in the db 55 + session := sessioncache.OAuthSession{ 56 + Did: oreq.Did, 57 + Handle: oreq.Handle, 58 + PdsUrl: oreq.PdsUrl, 59 + DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 + AuthServerIss: oreq.AuthserverIss, 61 + DpopPrivateJwk: oreq.DpopPrivateJwk, 62 + AccessJwt: oresp.AccessToken, 63 + RefreshJwt: oresp.RefreshToken, 64 + Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 80 65 } 81 66 82 - clientName := config.Core.AppviewName 67 + return o.sess.SaveSession(r.Context(), session) 68 + } 69 + 70 + func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 + userSession, err := o.store.Get(r, SessionName) 72 + if err != nil || userSession.IsNew { 73 + return fmt.Errorf("error getting user session (or new session?): %w", err) 74 + } 83 75 84 - logger.Info("oauth setup successfully", "IsConfidential", clientApp.Config.IsConfidential()) 85 - return &OAuth{ 86 - ClientApp: clientApp, 87 - Config: config, 88 - SessStore: sessStore, 89 - JwksUri: jwksUri, 90 - ClientName: clientName, 91 - ClientUri: clientUri, 92 - Posthog: ph, 93 - Db: db, 94 - Enforcer: enforcer, 95 - IdResolver: res, 96 - Logger: logger, 97 - }, nil 98 - } 76 + did := userSession.Values[SessionDid].(string) 99 77 100 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 101 - // first we save the did in the user session 102 - userSession, err := o.SessStore.Get(r, SessionName) 78 + err = o.sess.DeleteSession(r.Context(), did) 103 79 if err != nil { 104 - return err 80 + return fmt.Errorf("error deleting oauth session: %w", err) 105 81 } 106 82 107 - userSession.Values[SessionDid] = sessData.AccountDID.String() 108 - userSession.Values[SessionPds] = sessData.HostURL 109 - userSession.Values[SessionId] = sessData.SessionID 110 - userSession.Values[SessionAuthenticated] = true 83 + userSession.Options.MaxAge = -1 84 + 111 85 return userSession.Save(r, w) 112 86 } 113 87 114 - func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 115 - userSession, err := o.SessStore.Get(r, SessionName) 116 - if err != nil { 117 - return nil, fmt.Errorf("error getting user session: %w", err) 118 - } 119 - if userSession.IsNew { 120 - return nil, fmt.Errorf("no session available for user") 88 + func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 + userSession, err := o.store.Get(r, SessionName) 90 + if err != nil || userSession.IsNew { 91 + return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 121 92 } 122 93 123 - d := userSession.Values[SessionDid].(string) 124 - sessDid, err := syntax.ParseDID(d) 94 + did := userSession.Values[SessionDid].(string) 95 + auth := userSession.Values[SessionAuthenticated].(bool) 96 + 97 + session, err := o.sess.GetSession(r.Context(), did) 125 98 if err != nil { 126 - return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 99 + return nil, false, fmt.Errorf("error getting oauth session: %w", err) 127 100 } 128 101 129 - sessId := userSession.Values[SessionId].(string) 130 - 131 - clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 102 + expiry, err := time.Parse(time.RFC3339, session.Expiry) 132 103 if err != nil { 133 - return nil, fmt.Errorf("failed to resume session: %w", err) 104 + return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 134 105 } 106 + if time.Until(expiry) <= 5*time.Minute { 107 + privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 + if err != nil { 109 + return nil, false, err 110 + } 135 111 136 - return clientSess, nil 137 - } 112 + self := o.ClientMetadata() 138 113 139 - func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 140 - userSession, err := o.SessStore.Get(r, SessionName) 141 - if err != nil { 142 - return fmt.Errorf("error getting user session: %w", err) 143 - } 144 - if userSession.IsNew { 145 - return fmt.Errorf("no session available for user") 146 - } 114 + oauthClient, err := client.NewClient( 115 + self.ClientID, 116 + o.config.OAuth.Jwks, 117 + self.RedirectURIs[0], 118 + ) 147 119 148 - d := userSession.Values[SessionDid].(string) 149 - sessDid, err := syntax.ParseDID(d) 150 - if err != nil { 151 - return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 152 - } 120 + if err != nil { 121 + return nil, false, err 122 + } 153 123 154 - sessId := userSession.Values[SessionId].(string) 124 + resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 + if err != nil { 126 + return nil, false, err 127 + } 155 128 156 - // delete the session 157 - err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 129 + newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 + err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 + if err != nil { 132 + return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 + } 158 134 159 - // remove the cookie 160 - userSession.Options.MaxAge = -1 161 - err2 := o.SessStore.Save(r, w, userSession) 135 + // update the current session 136 + session.AccessJwt = resp.AccessToken 137 + session.RefreshJwt = resp.RefreshToken 138 + session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 + session.Expiry = newExpiry 140 + } 162 141 163 - return errors.Join(err1, err2) 142 + return session, auth, nil 164 143 } 165 144 166 145 type User struct { 167 - Did string 168 - Pds string 146 + Handle string 147 + Did string 148 + Pds string 169 149 } 170 150 171 - func (o *OAuth) GetUser(r *http.Request) *User { 172 - sess, err := o.ResumeSession(r) 173 - if err != nil { 151 + func (a *OAuth) GetUser(r *http.Request) *User { 152 + clientSession, err := a.store.Get(r, SessionName) 153 + 154 + if err != nil || clientSession.IsNew { 174 155 return nil 175 156 } 176 157 177 158 return &User{ 178 - Did: sess.Data.AccountDID.String(), 179 - Pds: sess.Data.HostURL, 159 + Handle: clientSession.Values[SessionHandle].(string), 160 + Did: clientSession.Values[SessionDid].(string), 161 + Pds: clientSession.Values[SessionPds].(string), 180 162 } 181 163 } 182 164 183 - func (o *OAuth) GetDid(r *http.Request) string { 184 - if u := o.GetUser(r); u != nil { 185 - return u.Did 165 + func (a *OAuth) GetDid(r *http.Request) string { 166 + clientSession, err := a.store.Get(r, SessionName) 167 + 168 + if err != nil || clientSession.IsNew { 169 + return "" 186 170 } 187 171 188 - return "" 172 + return clientSession.Values[SessionDid].(string) 189 173 } 190 174 191 - func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 192 - session, err := o.ResumeSession(r) 175 + func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 + session, auth, err := o.GetSession(r) 193 177 if err != nil { 194 178 return nil, fmt.Errorf("error getting session: %w", err) 195 179 } 196 - return session.APIClient(), nil 180 + if !auth { 181 + return nil, fmt.Errorf("not authorized") 182 + } 183 + 184 + client := &oauth.XrpcClient{ 185 + OnDpopPdsNonceChanged: func(did, newNonce string) { 186 + err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 + if err != nil { 188 + log.Printf("error updating dpop pds nonce: %v", err) 189 + } 190 + }, 191 + } 192 + 193 + privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 + if err != nil { 195 + return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 + } 197 + 198 + xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 + Did: session.Did, 200 + PdsUrl: session.PdsUrl, 201 + DpopPdsNonce: session.PdsUrl, 202 + AccessToken: session.AccessJwt, 203 + Issuer: session.AuthServerIss, 204 + DpopPrivateJwk: privateJwk, 205 + }) 206 + 207 + return xrpcClient, nil 197 208 } 198 209 210 + // use this to create a client to communicate with knots or spindles 211 + // 199 212 // this is a higher level abstraction on ServerGetServiceAuth 200 213 type ServiceClientOpts struct { 201 214 service string ··· 246 259 return scheme + s.service 247 260 } 248 261 249 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 262 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 250 263 opts := ServiceClientOpts{} 251 264 for _, o := range os { 252 265 o(&opts) 253 266 } 254 267 255 - client, err := o.AuthorizedClient(r) 268 + authorizedClient, err := o.AuthorizedClient(r) 256 269 if err != nil { 257 270 return nil, err 258 271 } ··· 263 276 opts.exp = sixty 264 277 } 265 278 266 - resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 279 + resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 267 280 if err != nil { 268 281 return nil, err 269 282 } 270 283 271 - return &xrpc.Client{ 272 - Auth: &xrpc.AuthInfo{ 284 + return &indigo_xrpc.Client{ 285 + Auth: &indigo_xrpc.AuthInfo{ 273 286 AccessJwt: resp.Token, 274 287 }, 275 288 Host: opts.Host(), ··· 278 291 }, 279 292 }, nil 280 293 } 294 + 295 + type ClientMetadata struct { 296 + ClientID string `json:"client_id"` 297 + ClientName string `json:"client_name"` 298 + SubjectType string `json:"subject_type"` 299 + ClientURI string `json:"client_uri"` 300 + RedirectURIs []string `json:"redirect_uris"` 301 + GrantTypes []string `json:"grant_types"` 302 + ResponseTypes []string `json:"response_types"` 303 + ApplicationType string `json:"application_type"` 304 + DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 + JwksURI string `json:"jwks_uri"` 306 + Scope string `json:"scope"` 307 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 + } 310 + 311 + func (o *OAuth) ClientMetadata() ClientMetadata { 312 + makeRedirectURIs := func(c string) []string { 313 + return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 + } 315 + 316 + clientURI := o.config.Core.AppviewHost 317 + clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 + redirectURIs := makeRedirectURIs(clientURI) 319 + 320 + if o.config.Core.Dev { 321 + clientURI = "http://127.0.0.1:3000" 322 + redirectURIs = makeRedirectURIs(clientURI) 323 + 324 + query := url.Values{} 325 + query.Add("redirect_uri", redirectURIs[0]) 326 + query.Add("scope", "atproto transition:generic") 327 + clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 + } 329 + 330 + jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 + 332 + return ClientMetadata{ 333 + ClientID: clientID, 334 + ClientName: "Tangled", 335 + SubjectType: "public", 336 + ClientURI: clientURI, 337 + RedirectURIs: redirectURIs, 338 + GrantTypes: []string{"authorization_code", "refresh_token"}, 339 + ResponseTypes: []string{"code"}, 340 + ApplicationType: "web", 341 + DpopBoundAccessTokens: true, 342 + JwksURI: jwksURI, 343 + Scope: "atproto transition:generic", 344 + TokenEndpointAuthMethod: "private_key_jwt", 345 + TokenEndpointAuthSigningAlg: "ES256", 346 + } 347 + }
-246
appview/oauth/store.go
··· 1 - package oauth 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "time" 8 - 9 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 - "github.com/redis/go-redis/v9" 12 - ) 13 - 14 - type RedisStoreConfig struct { 15 - RedisURL string 16 - 17 - // The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely. 18 - // The durations here should be *at least as long as* the expected duration of the oauth session itself. 19 - SessionExpiryDuration time.Duration // duration since session creation (max TTL) 20 - SessionInactivityDuration time.Duration // duration since last session update 21 - AuthRequestExpiryDuration time.Duration // duration since auth request creation 22 - } 23 - 24 - // redis-backed implementation of ClientAuthStore. 25 - type RedisStore struct { 26 - client *redis.Client 27 - cfg *RedisStoreConfig 28 - } 29 - 30 - var _ oauth.ClientAuthStore = &RedisStore{} 31 - 32 - type sessionMetadata struct { 33 - CreatedAt time.Time `json:"created_at"` 34 - UpdatedAt time.Time `json:"updated_at"` 35 - } 36 - 37 - func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) { 38 - if cfg == nil { 39 - return nil, fmt.Errorf("missing cfg") 40 - } 41 - if cfg.RedisURL == "" { 42 - return nil, fmt.Errorf("missing RedisURL") 43 - } 44 - if cfg.SessionExpiryDuration == 0 { 45 - return nil, fmt.Errorf("missing SessionExpiryDuration") 46 - } 47 - if cfg.SessionInactivityDuration == 0 { 48 - return nil, fmt.Errorf("missing SessionInactivityDuration") 49 - } 50 - if cfg.AuthRequestExpiryDuration == 0 { 51 - return nil, fmt.Errorf("missing AuthRequestExpiryDuration") 52 - } 53 - 54 - opts, err := redis.ParseURL(cfg.RedisURL) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to parse redis URL: %w", err) 57 - } 58 - 59 - client := redis.NewClient(opts) 60 - 61 - // test the connection 62 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 63 - defer cancel() 64 - 65 - if err := client.Ping(ctx).Err(); err != nil { 66 - return nil, fmt.Errorf("failed to connect to redis: %w", err) 67 - } 68 - 69 - return &RedisStore{ 70 - client: client, 71 - cfg: cfg, 72 - }, nil 73 - } 74 - 75 - func (r *RedisStore) Close() error { 76 - return r.client.Close() 77 - } 78 - 79 - func sessionKey(did syntax.DID, sessionID string) string { 80 - return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 81 - } 82 - 83 - func sessionMetadataKey(did syntax.DID, sessionID string) string { 84 - return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID) 85 - } 86 - 87 - func authRequestKey(state string) string { 88 - return fmt.Sprintf("oauth:auth_request:%s", state) 89 - } 90 - 91 - func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 92 - key := sessionKey(did, sessionID) 93 - metaKey := sessionMetadataKey(did, sessionID) 94 - 95 - // Check metadata for inactivity expiry 96 - metaData, err := r.client.Get(ctx, metaKey).Bytes() 97 - if err == redis.Nil { 98 - return nil, fmt.Errorf("session not found: %s", did) 99 - } 100 - if err != nil { 101 - return nil, fmt.Errorf("failed to get session metadata: %w", err) 102 - } 103 - 104 - var meta sessionMetadata 105 - if err := json.Unmarshal(metaData, &meta); err != nil { 106 - return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) 107 - } 108 - 109 - // Check if session has been inactive for too long 110 - inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration) 111 - if meta.UpdatedAt.Before(inactiveThreshold) { 112 - // Session is inactive, delete it 113 - r.client.Del(ctx, key, metaKey) 114 - return nil, fmt.Errorf("session expired due to inactivity: %s", did) 115 - } 116 - 117 - // Get the actual session data 118 - data, err := r.client.Get(ctx, key).Bytes() 119 - if err == redis.Nil { 120 - return nil, fmt.Errorf("session not found: %s", did) 121 - } 122 - if err != nil { 123 - return nil, fmt.Errorf("failed to get session: %w", err) 124 - } 125 - 126 - var sess oauth.ClientSessionData 127 - if err := json.Unmarshal(data, &sess); err != nil { 128 - return nil, fmt.Errorf("failed to unmarshal session: %w", err) 129 - } 130 - 131 - return &sess, nil 132 - } 133 - 134 - func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 135 - key := sessionKey(sess.AccountDID, sess.SessionID) 136 - metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID) 137 - 138 - data, err := json.Marshal(sess) 139 - if err != nil { 140 - return fmt.Errorf("failed to marshal session: %w", err) 141 - } 142 - 143 - // Check if session already exists to preserve CreatedAt 144 - var meta sessionMetadata 145 - existingMetaData, err := r.client.Get(ctx, metaKey).Bytes() 146 - if err == redis.Nil { 147 - // New session 148 - meta = sessionMetadata{ 149 - CreatedAt: time.Now(), 150 - UpdatedAt: time.Now(), 151 - } 152 - } else if err != nil { 153 - return fmt.Errorf("failed to check existing session metadata: %w", err) 154 - } else { 155 - // Existing session - preserve CreatedAt, update UpdatedAt 156 - if err := json.Unmarshal(existingMetaData, &meta); err != nil { 157 - return fmt.Errorf("failed to unmarshal existing session metadata: %w", err) 158 - } 159 - meta.UpdatedAt = time.Now() 160 - } 161 - 162 - // Calculate remaining TTL based on creation time 163 - remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt) 164 - if remainingTTL <= 0 { 165 - return fmt.Errorf("session has expired") 166 - } 167 - 168 - // Use the shorter of: remaining TTL or inactivity duration 169 - ttl := min(r.cfg.SessionInactivityDuration, remainingTTL) 170 - 171 - // Save session data 172 - if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil { 173 - return fmt.Errorf("failed to save session: %w", err) 174 - } 175 - 176 - // Save metadata 177 - metaData, err := json.Marshal(meta) 178 - if err != nil { 179 - return fmt.Errorf("failed to marshal session metadata: %w", err) 180 - } 181 - if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil { 182 - return fmt.Errorf("failed to save session metadata: %w", err) 183 - } 184 - 185 - return nil 186 - } 187 - 188 - func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 189 - key := sessionKey(did, sessionID) 190 - metaKey := sessionMetadataKey(did, sessionID) 191 - 192 - if err := r.client.Del(ctx, key, metaKey).Err(); err != nil { 193 - return fmt.Errorf("failed to delete session: %w", err) 194 - } 195 - return nil 196 - } 197 - 198 - func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 199 - key := authRequestKey(state) 200 - data, err := r.client.Get(ctx, key).Bytes() 201 - if err == redis.Nil { 202 - return nil, fmt.Errorf("request info not found: %s", state) 203 - } 204 - if err != nil { 205 - return nil, fmt.Errorf("failed to get auth request: %w", err) 206 - } 207 - 208 - var req oauth.AuthRequestData 209 - if err := json.Unmarshal(data, &req); err != nil { 210 - return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 211 - } 212 - 213 - return &req, nil 214 - } 215 - 216 - func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 217 - key := authRequestKey(info.State) 218 - 219 - // check if already exists (to match MemStore behavior) 220 - exists, err := r.client.Exists(ctx, key).Result() 221 - if err != nil { 222 - return fmt.Errorf("failed to check auth request existence: %w", err) 223 - } 224 - if exists > 0 { 225 - return fmt.Errorf("auth request already saved for state %s", info.State) 226 - } 227 - 228 - data, err := json.Marshal(info) 229 - if err != nil { 230 - return fmt.Errorf("failed to marshal auth request: %w", err) 231 - } 232 - 233 - if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil { 234 - return fmt.Errorf("failed to save auth request: %w", err) 235 - } 236 - 237 - return nil 238 - } 239 - 240 - func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 241 - key := authRequestKey(state) 242 - if err := r.client.Del(ctx, key).Err(); err != nil { 243 - return fmt.Errorf("failed to delete auth request: %w", err) 244 - } 245 - return nil 246 - }
-584
appview/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "html/template" 11 - "image" 12 - "image/color" 13 - "io" 14 - "log" 15 - "math" 16 - "net/http" 17 - "strings" 18 - "sync" 19 - "time" 20 - 21 - "github.com/goki/freetype" 22 - "github.com/goki/freetype/truetype" 23 - "github.com/srwiley/oksvg" 24 - "github.com/srwiley/rasterx" 25 - "golang.org/x/image/draw" 26 - "golang.org/x/image/font" 27 - "tangled.org/core/appview/pages" 28 - 29 - _ "golang.org/x/image/webp" // for processing webp images 30 - ) 31 - 32 - type Card struct { 33 - Img *image.RGBA 34 - Font *truetype.Font 35 - Margin int 36 - Width int 37 - Height int 38 - } 39 - 40 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 41 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 42 - if err != nil { 43 - return nil, err 44 - } 45 - return truetype.Parse(interVar) 46 - }) 47 - 48 - // DefaultSize returns the default size for a card 49 - func DefaultSize() (int, int) { 50 - return 1200, 630 51 - } 52 - 53 - // NewCard creates a new card with the given dimensions in pixels 54 - func NewCard(width, height int) (*Card, error) { 55 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 56 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 57 - 58 - font, err := fontCache() 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &Card{ 64 - Img: img, 65 - Font: font, 66 - Margin: 0, 67 - Width: width, 68 - Height: height, 69 - }, nil 70 - } 71 - 72 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 73 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 74 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 75 - bounds := c.Img.Bounds() 76 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 77 - if vertical { 78 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 79 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 80 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 81 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 82 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 83 - } 84 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 85 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 86 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 87 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 88 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 89 - } 90 - 91 - // SetMargin sets the margins for the card 92 - func (c *Card) SetMargin(margin int) { 93 - c.Margin = margin 94 - } 95 - 96 - type ( 97 - VAlign int64 98 - HAlign int64 99 - ) 100 - 101 - const ( 102 - Top VAlign = iota 103 - Middle 104 - Bottom 105 - ) 106 - 107 - const ( 108 - Left HAlign = iota 109 - Center 110 - Right 111 - ) 112 - 113 - // DrawText draws text within the card, respecting margins and alignment 114 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 115 - ft := freetype.NewContext() 116 - ft.SetDPI(72) 117 - ft.SetFont(c.Font) 118 - ft.SetFontSize(sizePt) 119 - ft.SetClip(c.Img.Bounds()) 120 - ft.SetDst(c.Img) 121 - ft.SetSrc(image.NewUniform(textColor)) 122 - 123 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 124 - fontHeight := ft.PointToFixed(sizePt).Ceil() 125 - 126 - bounds := c.Img.Bounds() 127 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 128 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 129 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 130 - 131 - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 132 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 133 - // knowing the total height, which is related to how many lines we'll have. 134 - lines := make([]string, 0) 135 - textWords := strings.Split(text, " ") 136 - currentLine := "" 137 - heightTotal := 0 138 - 139 - for { 140 - if len(textWords) == 0 { 141 - // Ran out of words. 142 - if currentLine != "" { 143 - heightTotal += fontHeight 144 - lines = append(lines, currentLine) 145 - } 146 - break 147 - } 148 - 149 - nextWord := textWords[0] 150 - proposedLine := currentLine 151 - if proposedLine != "" { 152 - proposedLine += " " 153 - } 154 - proposedLine += nextWord 155 - 156 - proposedLineWidth := font.MeasureString(face, proposedLine) 157 - if proposedLineWidth.Ceil() > boxWidth { 158 - // no, proposed line is too big; we'll use the last "currentLine" 159 - heightTotal += fontHeight 160 - if currentLine != "" { 161 - lines = append(lines, currentLine) 162 - currentLine = "" 163 - // leave nextWord in textWords and keep going 164 - } else { 165 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 166 - // regardless as a line by itself. It will be clipped by the drawing routine. 167 - lines = append(lines, nextWord) 168 - textWords = textWords[1:] 169 - } 170 - } else { 171 - // yes, it will fit 172 - currentLine = proposedLine 173 - textWords = textWords[1:] 174 - } 175 - } 176 - 177 - textY := 0 178 - switch valign { 179 - case Top: 180 - textY = fontHeight 181 - case Bottom: 182 - textY = boxHeight - heightTotal + fontHeight 183 - case Middle: 184 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 185 - } 186 - 187 - for _, line := range lines { 188 - lineWidth := font.MeasureString(face, line) 189 - 190 - textX := 0 191 - switch halign { 192 - case Left: 193 - textX = 0 194 - case Right: 195 - textX = boxWidth - lineWidth.Ceil() 196 - case Center: 197 - textX = (boxWidth - lineWidth.Ceil()) / 2 198 - } 199 - 200 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 201 - _, err := ft.DrawString(line, pt) 202 - if err != nil { 203 - return nil, err 204 - } 205 - 206 - textY += fontHeight 207 - } 208 - 209 - return lines, nil 210 - } 211 - 212 - // DrawTextAt draws text at a specific position with the given alignment 213 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 214 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 215 - return err 216 - } 217 - 218 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 219 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 220 - ft := freetype.NewContext() 221 - ft.SetDPI(72) 222 - ft.SetFont(c.Font) 223 - ft.SetFontSize(sizePt) 224 - ft.SetClip(c.Img.Bounds()) 225 - ft.SetDst(c.Img) 226 - ft.SetSrc(image.NewUniform(textColor)) 227 - 228 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 229 - fontHeight := ft.PointToFixed(sizePt).Ceil() 230 - lineWidth := font.MeasureString(face, text) 231 - textWidth := lineWidth.Ceil() 232 - 233 - // Adjust position based on alignment 234 - adjustedX := x 235 - adjustedY := y 236 - 237 - switch halign { 238 - case Left: 239 - // x is already at the left position 240 - case Right: 241 - adjustedX = x - textWidth 242 - case Center: 243 - adjustedX = x - textWidth/2 244 - } 245 - 246 - switch valign { 247 - case Top: 248 - adjustedY = y + fontHeight 249 - case Bottom: 250 - adjustedY = y 251 - case Middle: 252 - adjustedY = y + fontHeight/2 253 - } 254 - 255 - pt := freetype.Pt(adjustedX, adjustedY) 256 - _, err := ft.DrawString(text, pt) 257 - return textWidth, err 258 - } 259 - 260 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 261 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 262 - // Draw the text multiple times with slight offsets to create bold effect 263 - offsets := []struct{ dx, dy int }{ 264 - {0, 0}, // original 265 - {1, 0}, // right 266 - {0, 1}, // down 267 - {1, 1}, // diagonal 268 - } 269 - 270 - var width int 271 - for _, offset := range offsets { 272 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 273 - if err != nil { 274 - return 0, err 275 - } 276 - if width == 0 { 277 - width = w 278 - } 279 - } 280 - return width, nil 281 - } 282 - 283 - func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 284 - // Convert color to hex string for SVG 285 - rgba, isRGBA := iconColor.(color.RGBA) 286 - if !isRGBA { 287 - r, g, b, a := iconColor.RGBA() 288 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 289 - } 290 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 291 - 292 - // Replace currentColor with our desired color in the SVG 293 - svgString := string(svgData) 294 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 295 - 296 - // Make the stroke thicker 297 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 298 - 299 - // Parse SVG 300 - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 301 - if err != nil { 302 - return nil, fmt.Errorf("failed to parse SVG: %w", err) 303 - } 304 - 305 - return icon, nil 306 - } 307 - 308 - func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { 309 - svgData, err := pages.Files.ReadFile(svgPath) 310 - if err != nil { 311 - return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 312 - } 313 - 314 - icon, err := BuildSVGIconFromData(svgData, iconColor) 315 - if err != nil { 316 - return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) 317 - } 318 - 319 - return icon, nil 320 - } 321 - 322 - func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { 323 - return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 324 - } 325 - 326 - func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { 327 - icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 328 - if err != nil { 329 - return err 330 - } 331 - 332 - c.DrawSVGIcon(icon, x, y, size) 333 - 334 - return nil 335 - } 336 - 337 - func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error { 338 - tpl, err := template.New("dolly"). 339 - ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") 340 - if err != nil { 341 - return fmt.Errorf("failed to read dolly silhouette template: %w", err) 342 - } 343 - 344 - var svgData bytes.Buffer 345 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil { 346 - return fmt.Errorf("failed to execute dolly silhouette template: %w", err) 347 - } 348 - 349 - icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) 350 - if err != nil { 351 - return err 352 - } 353 - 354 - c.DrawSVGIcon(icon, x, y, size) 355 - 356 - return nil 357 - } 358 - 359 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 360 - func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { 361 - // Set the icon size 362 - w, h := float64(size), float64(size) 363 - icon.SetTarget(0, 0, w, h) 364 - 365 - // Create a temporary RGBA image for the icon 366 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 367 - 368 - // Create scanner and rasterizer 369 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 370 - raster := rasterx.NewDasher(size, size, scanner) 371 - 372 - // Draw the icon 373 - icon.Draw(raster, 1.0) 374 - 375 - // Draw the icon onto the card at the specified position 376 - bounds := c.Img.Bounds() 377 - destRect := image.Rect(x, y, x+size, y+size) 378 - 379 - // Make sure we don't draw outside the card bounds 380 - if destRect.Max.X > bounds.Max.X { 381 - destRect.Max.X = bounds.Max.X 382 - } 383 - if destRect.Max.Y > bounds.Max.Y { 384 - destRect.Max.Y = bounds.Max.Y 385 - } 386 - 387 - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 388 - } 389 - 390 - // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 391 - func (c *Card) DrawImage(img image.Image) { 392 - bounds := c.Img.Bounds() 393 - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 394 - srcBounds := img.Bounds() 395 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 396 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 397 - 398 - var scale float64 399 - if srcAspect > targetAspect { 400 - // Image is wider than target, scale by width 401 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 402 - } else { 403 - // Image is taller or equal, scale by height 404 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 405 - } 406 - 407 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 408 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 409 - 410 - // Center the image within the target rectangle 411 - offsetX := (targetRect.Dx() - newWidth) / 2 412 - offsetY := (targetRect.Dy() - newHeight) / 2 413 - 414 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 415 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 416 - } 417 - 418 - func fallbackImage() image.Image { 419 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 420 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 421 - img.Set(0, 0, color.White) 422 - return img 423 - } 424 - 425 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 426 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 427 - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 428 - // this rendering process to be slowed down 429 - client := &http.Client{ 430 - Timeout: 1 * time.Second, // 1 second timeout 431 - } 432 - 433 - resp, err := client.Get(url) 434 - if err != nil { 435 - log.Printf("error when fetching external image from %s: %v", url, err) 436 - return nil, false 437 - } 438 - defer resp.Body.Close() 439 - 440 - if resp.StatusCode != http.StatusOK { 441 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 442 - return nil, false 443 - } 444 - 445 - contentType := resp.Header.Get("Content-Type") 446 - 447 - body := resp.Body 448 - bodyBytes, err := io.ReadAll(body) 449 - if err != nil { 450 - log.Printf("error when fetching external image from %s: %v", url, err) 451 - return nil, false 452 - } 453 - 454 - // Handle SVG separately 455 - if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 - return c.convertSVGToPNG(bodyBytes) 457 - } 458 - 459 - // Support content types are in-sync with the allowed custom avatar file types 460 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 461 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 462 - return nil, false 463 - } 464 - 465 - bodyBuffer := bytes.NewReader(bodyBytes) 466 - _, imgType, err := image.DecodeConfig(bodyBuffer) 467 - if err != nil { 468 - log.Printf("error when decoding external image from %s: %v", url, err) 469 - return nil, false 470 - } 471 - 472 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 473 - if (contentType == "image/png" && imgType != "png") || 474 - (contentType == "image/jpeg" && imgType != "jpeg") || 475 - (contentType == "image/gif" && imgType != "gif") || 476 - (contentType == "image/webp" && imgType != "webp") { 477 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 478 - return nil, false 479 - } 480 - 481 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 482 - if err != nil { 483 - log.Printf("error w/ bodyBuffer.Seek") 484 - return nil, false 485 - } 486 - img, _, err := image.Decode(bodyBuffer) 487 - if err != nil { 488 - log.Printf("error when decoding external image from %s: %v", url, err) 489 - return nil, false 490 - } 491 - 492 - return img, true 493 - } 494 - 495 - // convertSVGToPNG converts SVG data to a PNG image 496 - func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 - // Parse the SVG 498 - icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 - if err != nil { 500 - log.Printf("error parsing SVG: %v", err) 501 - return nil, false 502 - } 503 - 504 - // Set a reasonable size for the rasterized image 505 - width := 256 506 - height := 256 507 - icon.SetTarget(0, 0, float64(width), float64(height)) 508 - 509 - // Create an image to draw on 510 - rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 511 - 512 - // Fill with white background 513 - draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 514 - 515 - // Create a scanner and rasterize the SVG 516 - scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 517 - raster := rasterx.NewDasher(width, height, scanner) 518 - 519 - icon.Draw(raster, 1.0) 520 - 521 - return rgba, true 522 - } 523 - 524 - func (c *Card) DrawExternalImage(url string) { 525 - image, ok := c.fetchExternalImage(url) 526 - if !ok { 527 - image = fallbackImage() 528 - } 529 - c.DrawImage(image) 530 - } 531 - 532 - // DrawCircularExternalImage draws an external image as a circle at the specified position 533 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 534 - img, ok := c.fetchExternalImage(url) 535 - if !ok { 536 - img = fallbackImage() 537 - } 538 - 539 - // Create a circular mask 540 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 541 - center := size / 2 542 - radius := float64(size / 2) 543 - 544 - // Scale the source image to fit the circle 545 - srcBounds := img.Bounds() 546 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 547 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 - 549 - // Draw the image with circular clipping 550 - for cy := 0; cy < size; cy++ { 551 - for cx := 0; cx < size; cx++ { 552 - // Calculate distance from center 553 - dx := float64(cx - center) 554 - dy := float64(cy - center) 555 - distance := math.Sqrt(dx*dx + dy*dy) 556 - 557 - // Only draw pixels within the circle 558 - if distance <= radius { 559 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 560 - } 561 - } 562 - } 563 - 564 - // Draw the circle onto the card 565 - bounds := c.Img.Bounds() 566 - destRect := image.Rect(x, y, x+size, y+size) 567 - 568 - // Make sure we don't draw outside the card bounds 569 - if destRect.Max.X > bounds.Max.X { 570 - destRect.Max.X = bounds.Max.X 571 - } 572 - if destRect.Max.Y > bounds.Max.Y { 573 - destRect.Max.Y = bounds.Max.Y 574 - } 575 - 576 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 577 - 578 - return nil 579 - } 580 - 581 - // DrawRect draws a rect with the given color 582 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 583 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 584 - }
+13 -72
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "crypto/hmac" 7 6 "crypto/sha256" ··· 18 17 "strings" 19 18 "time" 20 19 21 - "github.com/alecthomas/chroma/v2" 22 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 - "github.com/alecthomas/chroma/v2/lexers" 24 - "github.com/alecthomas/chroma/v2/styles" 25 - "github.com/bluesky-social/indigo/atproto/syntax" 26 20 "github.com/dustin/go-humanize" 27 21 "github.com/go-enry/go-enry/v2" 28 22 "tangled.org/core/appview/filetree" ··· 44 38 "contains": func(s string, target string) bool { 45 39 return strings.Contains(s, target) 46 40 }, 47 - "stripPort": func(hostname string) string { 48 - if strings.Contains(hostname, ":") { 49 - return strings.Split(hostname, ":")[0] 50 - } 51 - return hostname 52 - }, 53 41 "mapContains": func(m any, key any) bool { 54 42 mapValue := reflect.ValueOf(m) 55 43 if mapValue.Kind() != reflect.Map { ··· 69 57 return "handle.invalid" 70 58 } 71 59 72 - return identity.Handle.String() 60 + return "@" + identity.Handle.String() 73 61 }, 74 62 "truncateAt30": func(s string) string { 75 63 if len(s) <= 30 { ··· 79 67 }, 80 68 "splitOn": func(s, sep string) []string { 81 69 return strings.Split(s, sep) 82 - }, 83 - "string": func(v any) string { 84 - return fmt.Sprint(v) 85 70 }, 86 71 "int64": func(a int) int64 { 87 72 return int64(a) ··· 132 117 return b 133 118 }, 134 119 "didOrHandle": func(did, handle string) string { 135 - if handle != "" && handle != syntax.HandleInvalid.String() { 136 - return handle 120 + if handle != "" { 121 + return fmt.Sprintf("@%s", handle) 137 122 } else { 138 123 return did 139 124 } ··· 251 236 sanitized := p.rctx.SanitizeDescription(htmlString) 252 237 return template.HTML(sanitized) 253 238 }, 254 - "readme": func(text string) template.HTML { 255 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 256 - htmlString := p.rctx.RenderMarkdown(text) 257 - sanitized := p.rctx.SanitizeDefault(htmlString) 258 - return template.HTML(sanitized) 259 - }, 260 - "code": func(content, path string) string { 261 - var style *chroma.Style = styles.Get("catpuccin-latte") 262 - formatter := chromahtml.New( 263 - chromahtml.InlineCode(false), 264 - chromahtml.WithLineNumbers(true), 265 - chromahtml.WithLinkableLineNumbers(true, "L"), 266 - chromahtml.Standalone(false), 267 - chromahtml.WithClasses(true), 268 - ) 269 - 270 - lexer := lexers.Get(filepath.Base(path)) 271 - if lexer == nil { 272 - lexer = lexers.Fallback 273 - } 274 - 275 - iterator, err := lexer.Tokenise(nil, content) 276 - if err != nil { 277 - p.logger.Error("chroma tokenize", "err", "err") 278 - return "" 279 - } 280 - 281 - var code bytes.Buffer 282 - err = formatter.Format(&code, style, iterator) 283 - if err != nil { 284 - p.logger.Error("chroma format", "err", "err") 285 - return "" 286 - } 287 - 288 - return code.String() 289 - }, 290 - "trimUriScheme": func(text string) string { 291 - text = strings.TrimPrefix(text, "https://") 292 - text = strings.TrimPrefix(text, "http://") 293 - return text 294 - }, 295 239 "isNil": func(t any) bool { 296 240 // returns false for other "zero" values 297 241 return t == nil ··· 321 265 return nil 322 266 }, 323 267 "i": func(name string, classes ...string) template.HTML { 324 - data, err := p.icon(name, classes) 268 + data, err := icon(name, classes) 325 269 if err != nil { 326 270 log.Printf("icon %s does not exist", name) 327 - data, _ = p.icon("airplay", classes) 271 + data, _ = icon("airplay", classes) 328 272 } 329 273 return template.HTML(data) 330 274 }, 331 - "cssContentHash": p.CssContentHash, 275 + "cssContentHash": CssContentHash, 332 276 "fileTree": filetree.FileTree, 333 277 "pathEscape": func(s string) string { 334 278 return url.PathEscape(s) ··· 337 281 u, _ := url.PathUnescape(s) 338 282 return u 339 283 }, 340 - "safeUrl": func(s string) template.URL { 341 - return template.URL(s) 342 - }, 284 + 343 285 "tinyAvatar": func(handle string) string { 344 - return p.AvatarUrl(handle, "tiny") 286 + return p.avatarUri(handle, "tiny") 345 287 }, 346 288 "fullAvatar": func(handle string) string { 347 - return p.AvatarUrl(handle, "") 289 + return p.avatarUri(handle, "") 348 290 }, 349 291 "langColor": enry.GetColor, 350 292 "layoutSide": func() string { ··· 355 297 }, 356 298 357 299 "normalizeForHtmlId": func(s string) string { 358 - normalized := strings.ReplaceAll(s, ":", "_") 359 - normalized = strings.ReplaceAll(normalized, ".", "_") 360 - return normalized 300 + // TODO: extend this to handle other cases? 301 + return strings.ReplaceAll(s, ":", "_") 361 302 }, 362 303 "sshFingerprint": func(pubKey string) string { 363 304 fp, err := crypto.SSHFingerprint(pubKey) ··· 369 310 } 370 311 } 371 312 372 - func (p *Pages) AvatarUrl(handle, size string) string { 313 + func (p *Pages) avatarUri(handle, size string) string { 373 314 handle = strings.TrimPrefix(handle, "@") 374 315 375 316 secret := p.avatar.SharedSecret ··· 384 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 385 326 } 386 327 387 - func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 328 + func icon(name string, classes []string) (template.HTML, error) { 388 329 iconPath := filepath.Join("static", "icons", name) 389 330 390 331 if filepath.Ext(name) == "" {
+2 -5
appview/pages/funcmap_test.go
··· 2 2 3 3 import ( 4 4 "html/template" 5 - "log/slog" 6 - "testing" 7 - 8 5 "tangled.org/core/appview/config" 9 6 "tangled.org/core/idresolver" 7 + "testing" 10 8 ) 11 9 12 10 func TestPages_funcMap(t *testing.T) { ··· 15 13 // Named input parameters for receiver constructor. 16 14 config *config.Config 17 15 res *idresolver.Resolver 18 - l *slog.Logger 19 16 want template.FuncMap 20 17 }{ 21 18 // TODO: Add test cases. 22 19 } 23 20 for _, tt := range tests { 24 21 t.Run(tt.name, func(t *testing.T) { 25 - p := NewPages(tt.config, tt.res, tt.l) 22 + p := NewPages(tt.config, tt.res) 26 23 got := p.funcMap() 27 24 // TODO: update the condition below to compare got with tt.want. 28 25 if true {
-111
appview/pages/markup/extension/atlink.go
··· 1 - // heavily inspired by: https://github.com/kaleocheng/goldmark-extensions 2 - 3 - package extension 4 - 5 - import ( 6 - "regexp" 7 - 8 - "github.com/yuin/goldmark" 9 - "github.com/yuin/goldmark/ast" 10 - "github.com/yuin/goldmark/parser" 11 - "github.com/yuin/goldmark/renderer" 12 - "github.com/yuin/goldmark/renderer/html" 13 - "github.com/yuin/goldmark/text" 14 - "github.com/yuin/goldmark/util" 15 - ) 16 - 17 - // An AtNode struct represents an AtNode 18 - type AtNode struct { 19 - Handle string 20 - ast.BaseInline 21 - } 22 - 23 - var _ ast.Node = &AtNode{} 24 - 25 - // Dump implements Node.Dump. 26 - func (n *AtNode) Dump(source []byte, level int) { 27 - ast.DumpHelper(n, source, level, nil, nil) 28 - } 29 - 30 - // KindAt is a NodeKind of the At node. 31 - var KindAt = ast.NewNodeKind("At") 32 - 33 - // Kind implements Node.Kind. 34 - func (n *AtNode) Kind() ast.NodeKind { 35 - return KindAt 36 - } 37 - 38 - var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 - 40 - type atParser struct{} 41 - 42 - // NewAtParser return a new InlineParser that parses 43 - // at expressions. 44 - func NewAtParser() parser.InlineParser { 45 - return &atParser{} 46 - } 47 - 48 - func (s *atParser) Trigger() []byte { 49 - return []byte{'@'} 50 - } 51 - 52 - func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 53 - line, segment := block.PeekLine() 54 - m := atRegexp.FindSubmatchIndex(line) 55 - if m == nil { 56 - return nil 57 - } 58 - atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 - block.Advance(m[1]) 60 - node := &AtNode{} 61 - node.AppendChild(node, ast.NewTextSegment(atSegment)) 62 - node.Handle = string(atSegment.Value(block.Source())[1:]) 63 - return node 64 - } 65 - 66 - // atHtmlRenderer is a renderer.NodeRenderer implementation that 67 - // renders At nodes. 68 - type atHtmlRenderer struct { 69 - html.Config 70 - } 71 - 72 - // NewAtHTMLRenderer returns a new AtHTMLRenderer. 73 - func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 74 - r := &atHtmlRenderer{ 75 - Config: html.NewConfig(), 76 - } 77 - for _, opt := range opts { 78 - opt.SetHTMLOption(&r.Config) 79 - } 80 - return r 81 - } 82 - 83 - // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 84 - func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 85 - reg.Register(KindAt, r.renderAt) 86 - } 87 - 88 - func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 - if entering { 90 - w.WriteString(`<a href="/@`) 91 - w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention">`) 93 - } else { 94 - w.WriteString("</a>") 95 - } 96 - return ast.WalkContinue, nil 97 - } 98 - 99 - type atExt struct{} 100 - 101 - // At is an extension that allow you to use at expression like '@user.bsky.social' . 102 - var AtExt = &atExt{} 103 - 104 - func (e *atExt) Extend(m goldmark.Markdown) { 105 - m.Parser().AddOptions(parser.WithInlineParsers( 106 - util.Prioritized(NewAtParser(), 500), 107 - )) 108 - m.Renderer().AddOptions(renderer.WithNodeRenderers( 109 - util.Prioritized(NewAtHTMLRenderer(), 500), 110 - )) 111 - }
+2 -38
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 - "io/fs" 9 8 "net/url" 10 9 "path" 11 10 "strings" ··· 21 20 "github.com/yuin/goldmark/renderer/html" 22 21 "github.com/yuin/goldmark/text" 23 22 "github.com/yuin/goldmark/util" 24 - callout "gitlab.com/staticnoise/goldmark-callout" 25 23 htmlparse "golang.org/x/net/html" 26 24 27 25 "tangled.org/core/api/tangled" 28 - textension "tangled.org/core/appview/pages/markup/extension" 29 26 "tangled.org/core/appview/pages/repoinfo" 30 27 ) 31 28 ··· 48 45 IsDev bool 49 46 RendererType RendererType 50 47 Sanitizer Sanitizer 51 - Files fs.FS 52 48 } 53 49 54 - func NewMarkdown() goldmark.Markdown { 50 + func (rctx *RenderContext) RenderMarkdown(source string) string { 55 51 md := goldmark.New( 56 52 goldmark.WithExtensions( 57 53 extension.GFM, ··· 66 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 67 63 ), 68 64 treeblood.MathML(), 69 - callout.CalloutExtention, 70 - textension.AtExt, 71 65 ), 72 66 goldmark.WithParserOptions( 73 67 parser.WithAutoHeadingID(), 74 68 ), 75 69 goldmark.WithRendererOptions(html.WithUnsafe()), 76 70 ) 77 - return md 78 - } 79 - 80 - func (rctx *RenderContext) RenderMarkdown(source string) string { 81 - md := NewMarkdown() 82 71 83 72 if rctx != nil { 84 73 var transformers []util.PrioritizedValue ··· 151 140 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 152 141 switch node.Type { 153 142 case htmlparse.ElementNode: 154 - switch node.Data { 155 - case "img", "source": 143 + if node.Data == "img" || node.Data == "source" { 156 144 for i, attr := range node.Attr { 157 145 if attr.Key != "src" { 158 146 continue ··· 300 288 } 301 289 302 290 return path.Join(rctx.CurrentDir, dst) 303 - } 304 - 305 - // FindUserMentions returns Set of user handles from given markup soruce. 306 - // It doesn't guarntee unique DIDs 307 - func FindUserMentions(source string) []string { 308 - var ( 309 - mentions []string 310 - mentionsSet = make(map[string]struct{}) 311 - md = NewMarkdown() 312 - sourceBytes = []byte(source) 313 - root = md.Parser().Parse(text.NewReader(sourceBytes)) 314 - ) 315 - ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 316 - if entering && n.Kind() == textension.KindAt { 317 - handle := n.(*textension.AtNode).Handle 318 - mentionsSet[handle] = struct{}{} 319 - return ast.WalkSkipChildren, nil 320 - } 321 - return ast.WalkContinue, nil 322 - }) 323 - for handle := range mentionsSet { 324 - mentions = append(mentions, handle) 325 - } 326 - return mentions 327 291 } 328 292 329 293 func isAbsoluteUrl(link string) bool {
-6
appview/pages/markup/sanitizer.go
··· 77 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 79 80 - // at-mentions 81 - policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") 82 - 83 80 // centering content 84 81 policy.AllowElements("center") 85 82 ··· 116 113 } 117 114 policy.AllowNoAttrs().OnElements(mathElements...) 118 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 119 - 120 - // goldmark-callout 121 - policy.AllowAttrs("data-callout").OnElements("details") 122 116 123 117 return policy 124 118 }
+153 -90
appview/pages/pages.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "bytes" 4 5 "crypto/sha256" 5 6 "embed" 6 7 "encoding/hex" ··· 14 15 "path/filepath" 15 16 "strings" 16 17 "sync" 17 - "time" 18 18 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/commitverify" ··· 28 28 "tangled.org/core/patchutil" 29 29 "tangled.org/core/types" 30 30 31 + "github.com/alecthomas/chroma/v2" 32 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 + "github.com/alecthomas/chroma/v2/lexers" 34 + "github.com/alecthomas/chroma/v2/styles" 31 35 "github.com/bluesky-social/indigo/atproto/identity" 32 36 "github.com/bluesky-social/indigo/atproto/syntax" 33 37 "github.com/go-git/go-git/v5/plumbing" ··· 50 54 logger *slog.Logger 51 55 } 52 56 53 - func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 57 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 54 58 // initialized with safe defaults, can be overriden per use 55 59 rctx := &markup.RenderContext{ 56 60 IsDev: config.Core.Dev, 57 61 CamoUrl: config.Camo.Host, 58 62 CamoSecret: config.Camo.SharedSecret, 59 63 Sanitizer: markup.NewSanitizer(), 60 - Files: Files, 61 64 } 62 65 63 66 p := &Pages{ ··· 68 71 rctx: rctx, 69 72 resolver: res, 70 73 templateDir: "appview/pages", 71 - logger: logger, 74 + logger: slog.Default().With("component", "pages"), 72 75 } 73 76 74 77 if p.dev { ··· 217 220 218 221 type LoginParams struct { 219 222 ReturnUrl string 220 - ErrorCode string 221 223 } 222 224 223 225 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 304 306 LoggedInUser *oauth.User 305 307 Timeline []models.TimelineEvent 306 308 Repos []models.Repo 307 - GfiLabel *models.LabelDefinition 308 309 } 309 310 310 311 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 311 312 return p.execute("timeline/timeline", w, params) 312 313 } 313 314 314 - type GoodFirstIssuesParams struct { 315 - LoggedInUser *oauth.User 316 - Issues []models.Issue 317 - RepoGroups []*models.RepoGroup 318 - LabelDefs map[string]*models.LabelDefinition 319 - GfiLabel *models.LabelDefinition 320 - Page pagination.Page 321 - } 322 - 323 - func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 324 - return p.execute("goodfirstissues/index", w, params) 325 - } 326 - 327 315 type UserProfileSettingsParams struct { 328 316 LoggedInUser *oauth.User 329 317 Tabs []map[string]any ··· 635 623 return p.executePlain("repo/fragments/repoStar", w, params) 636 624 } 637 625 626 + type RepoDescriptionParams struct { 627 + RepoInfo repoinfo.RepoInfo 628 + } 629 + 630 + func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 631 + return p.executePlain("repo/fragments/editRepoDescription", w, params) 632 + } 633 + 634 + func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 635 + return p.executePlain("repo/fragments/repoDescription", w, params) 636 + } 637 + 638 638 type RepoIndexParams struct { 639 639 LoggedInUser *oauth.User 640 640 RepoInfo repoinfo.RepoInfo ··· 644 644 TagsTrunc []*types.TagReference 645 645 BranchesTrunc []types.Branch 646 646 // ForkInfo *types.ForkInfo 647 - HTMLReadme template.HTML 648 - Raw bool 649 - EmailToDid map[string]string 650 - VerifiedCommits commitverify.VerifiedCommits 651 - Languages []types.RepoLanguageDetails 652 - Pipelines map[string]models.Pipeline 653 - NeedsKnotUpgrade bool 647 + HTMLReadme template.HTML 648 + Raw bool 649 + EmailToDidOrHandle map[string]string 650 + VerifiedCommits commitverify.VerifiedCommits 651 + Languages []types.RepoLanguageDetails 652 + Pipelines map[string]models.Pipeline 653 + NeedsKnotUpgrade bool 654 654 types.RepoIndexResponse 655 655 } 656 656 ··· 685 685 } 686 686 687 687 type RepoLogParams struct { 688 - LoggedInUser *oauth.User 689 - RepoInfo repoinfo.RepoInfo 690 - TagMap map[string][]string 691 - Active string 692 - EmailToDid map[string]string 693 - VerifiedCommits commitverify.VerifiedCommits 694 - Pipelines map[string]models.Pipeline 695 - 688 + LoggedInUser *oauth.User 689 + RepoInfo repoinfo.RepoInfo 690 + TagMap map[string][]string 696 691 types.RepoLogResponse 692 + Active string 693 + EmailToDidOrHandle map[string]string 694 + VerifiedCommits commitverify.VerifiedCommits 695 + Pipelines map[string]models.Pipeline 697 696 } 698 697 699 698 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 702 701 } 703 702 704 703 type RepoCommitParams struct { 705 - LoggedInUser *oauth.User 706 - RepoInfo repoinfo.RepoInfo 707 - Active string 708 - EmailToDid map[string]string 709 - Pipeline *models.Pipeline 710 - DiffOpts types.DiffOpts 704 + LoggedInUser *oauth.User 705 + RepoInfo repoinfo.RepoInfo 706 + Active string 707 + EmailToDidOrHandle map[string]string 708 + Pipeline *models.Pipeline 709 + DiffOpts types.DiffOpts 711 710 712 711 // singular because it's always going to be just one 713 712 VerifiedCommit commitverify.VerifiedCommits ··· 739 738 func (r RepoTreeParams) TreeStats() RepoTreeStats { 740 739 numFolders, numFiles := 0, 0 741 740 for _, f := range r.Files { 742 - if !f.IsFile() { 741 + if !f.IsFile { 743 742 numFolders += 1 744 - } else if f.IsFile() { 743 + } else if f.IsFile { 745 744 numFiles += 1 746 745 } 747 746 } ··· 812 811 } 813 812 814 813 type RepoBlobParams struct { 815 - LoggedInUser *oauth.User 816 - RepoInfo repoinfo.RepoInfo 817 - Active string 818 - BreadCrumbs [][]string 819 - BlobView models.BlobView 814 + LoggedInUser *oauth.User 815 + RepoInfo repoinfo.RepoInfo 816 + Active string 817 + Unsupported bool 818 + IsImage bool 819 + IsVideo bool 820 + ContentSrc string 821 + BreadCrumbs [][]string 822 + ShowRendered bool 823 + RenderToggle bool 824 + RenderedContents template.HTML 820 825 *tangled.RepoBlob_Output 826 + // Computed fields for template compatibility 827 + Contents string 828 + Lines int 829 + SizeHint uint64 830 + IsBinary bool 821 831 } 822 832 823 833 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 824 - switch params.BlobView.ContentType { 825 - case models.BlobContentTypeMarkup: 826 - p.rctx.RepoInfo = params.RepoInfo 834 + var style *chroma.Style = styles.Get("catpuccin-latte") 835 + 836 + if params.ShowRendered { 837 + switch markup.GetFormat(params.Path) { 838 + case markup.FormatMarkdown: 839 + p.rctx.RepoInfo = params.RepoInfo 840 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 841 + htmlString := p.rctx.RenderMarkdown(params.Contents) 842 + sanitized := p.rctx.SanitizeDefault(htmlString) 843 + params.RenderedContents = template.HTML(sanitized) 844 + } 845 + } 846 + 847 + c := params.Contents 848 + formatter := chromahtml.New( 849 + chromahtml.InlineCode(false), 850 + chromahtml.WithLineNumbers(true), 851 + chromahtml.WithLinkableLineNumbers(true, "L"), 852 + chromahtml.Standalone(false), 853 + chromahtml.WithClasses(true), 854 + ) 855 + 856 + lexer := lexers.Get(filepath.Base(params.Path)) 857 + if lexer == nil { 858 + lexer = lexers.Fallback 859 + } 860 + 861 + iterator, err := lexer.Tokenise(nil, c) 862 + if err != nil { 863 + return fmt.Errorf("chroma tokenize: %w", err) 864 + } 865 + 866 + var code bytes.Buffer 867 + err = formatter.Format(&code, style, iterator) 868 + if err != nil { 869 + return fmt.Errorf("chroma format: %w", err) 827 870 } 828 871 872 + params.Contents = code.String() 829 873 params.Active = "overview" 830 874 return p.executeRepo("repo/blob", w, params) 831 875 } ··· 911 955 LabelDefs map[string]*models.LabelDefinition 912 956 Page pagination.Page 913 957 FilteringByOpen bool 914 - FilterQuery string 915 958 } 916 959 917 960 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 928 971 LabelDefs map[string]*models.LabelDefinition 929 972 930 973 OrderedReactionKinds []models.ReactionKind 931 - Reactions map[models.ReactionKind]models.ReactionDisplayData 974 + Reactions map[models.ReactionKind]int 932 975 UserReacted map[models.ReactionKind]bool 933 976 } 934 977 ··· 953 996 ThreadAt syntax.ATURI 954 997 Kind models.ReactionKind 955 998 Count int 956 - Users []string 957 999 IsReacted bool 958 1000 } 959 1001 ··· 1042 1084 Pulls []*models.Pull 1043 1085 Active string 1044 1086 FilteringBy models.PullState 1045 - FilterQuery string 1046 1087 Stacks map[string]models.Stack 1047 1088 Pipelines map[string]models.Pipeline 1048 - LabelDefs map[string]*models.LabelDefinition 1049 1089 } 1050 1090 1051 1091 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1072 1112 } 1073 1113 1074 1114 type RepoSinglePullParams struct { 1075 - LoggedInUser *oauth.User 1076 - RepoInfo repoinfo.RepoInfo 1077 - Active string 1078 - Pull *models.Pull 1079 - Stack models.Stack 1080 - AbandonedPulls []*models.Pull 1081 - BranchDeleteStatus *models.BranchDeleteStatus 1082 - MergeCheck types.MergeCheckResponse 1083 - ResubmitCheck ResubmitResult 1084 - Pipelines map[string]models.Pipeline 1115 + LoggedInUser *oauth.User 1116 + RepoInfo repoinfo.RepoInfo 1117 + Active string 1118 + Pull *models.Pull 1119 + Stack models.Stack 1120 + AbandonedPulls []*models.Pull 1121 + MergeCheck types.MergeCheckResponse 1122 + ResubmitCheck ResubmitResult 1123 + Pipelines map[string]models.Pipeline 1085 1124 1086 1125 OrderedReactionKinds []models.ReactionKind 1087 - Reactions map[models.ReactionKind]models.ReactionDisplayData 1126 + Reactions map[models.ReactionKind]int 1088 1127 UserReacted map[models.ReactionKind]bool 1089 - 1090 - LabelDefs map[string]*models.LabelDefinition 1091 1128 } 1092 1129 1093 1130 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1177 1214 } 1178 1215 1179 1216 type PullActionsParams struct { 1180 - LoggedInUser *oauth.User 1181 - RepoInfo repoinfo.RepoInfo 1182 - Pull *models.Pull 1183 - RoundNumber int 1184 - MergeCheck types.MergeCheckResponse 1185 - ResubmitCheck ResubmitResult 1186 - BranchDeleteStatus *models.BranchDeleteStatus 1187 - Stack models.Stack 1217 + LoggedInUser *oauth.User 1218 + RepoInfo repoinfo.RepoInfo 1219 + Pull *models.Pull 1220 + RoundNumber int 1221 + MergeCheck types.MergeCheckResponse 1222 + ResubmitCheck ResubmitResult 1223 + Stack models.Stack 1188 1224 } 1189 1225 1190 1226 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1300 1336 Name string 1301 1337 Command string 1302 1338 Collapsed bool 1303 - StartTime time.Time 1304 1339 } 1305 1340 1306 1341 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1307 1342 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1308 - } 1309 - 1310 - type LogBlockEndParams struct { 1311 - Id int 1312 - StartTime time.Time 1313 - EndTime time.Time 1314 - } 1315 - 1316 - func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1317 - return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1318 1343 } 1319 1344 1320 1345 type LogLineParams struct { ··· 1382 1407 } 1383 1408 1384 1409 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1410 + var style *chroma.Style = styles.Get("catpuccin-latte") 1411 + 1412 + if params.ShowRendered { 1413 + switch markup.GetFormat(params.String.Filename) { 1414 + case markup.FormatMarkdown: 1415 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1416 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1417 + sanitized := p.rctx.SanitizeDefault(htmlString) 1418 + params.RenderedContents = template.HTML(sanitized) 1419 + } 1420 + } 1421 + 1422 + c := params.String.Contents 1423 + formatter := chromahtml.New( 1424 + chromahtml.InlineCode(false), 1425 + chromahtml.WithLineNumbers(true), 1426 + chromahtml.WithLinkableLineNumbers(true, "L"), 1427 + chromahtml.Standalone(false), 1428 + chromahtml.WithClasses(true), 1429 + ) 1430 + 1431 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1432 + if lexer == nil { 1433 + lexer = lexers.Fallback 1434 + } 1435 + 1436 + iterator, err := lexer.Tokenise(nil, c) 1437 + if err != nil { 1438 + return fmt.Errorf("chroma tokenize: %w", err) 1439 + } 1440 + 1441 + var code bytes.Buffer 1442 + err = formatter.Format(&code, style, iterator) 1443 + if err != nil { 1444 + return fmt.Errorf("chroma format: %w", err) 1445 + } 1446 + 1447 + params.String.Contents = code.String() 1385 1448 return p.execute("strings/string", w, params) 1386 1449 } 1387 1450 ··· 1394 1457 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1395 1458 } 1396 1459 1397 - sub, err := fs.Sub(p.embedFS, "static") 1460 + sub, err := fs.Sub(Files, "static") 1398 1461 if err != nil { 1399 1462 p.logger.Error("no static dir found? that's crazy", "err", err) 1400 1463 panic(err) ··· 1417 1480 }) 1418 1481 } 1419 1482 1420 - func (p *Pages) CssContentHash() string { 1421 - cssFile, err := p.embedFS.Open("static/tw.css") 1483 + func CssContentHash() string { 1484 + cssFile, err := Files.Open("static/tw.css") 1422 1485 if err != nil { 1423 1486 slog.Debug("Error opening CSS file", "err", err) 1424 1487 return ""
+7 -7
appview/pages/repoinfo/repoinfo.go
··· 1 1 package repoinfo 2 2 3 3 import ( 4 + "fmt" 4 5 "path" 5 6 "slices" 7 + "strings" 6 8 7 9 "github.com/bluesky-social/indigo/atproto/syntax" 8 10 "tangled.org/core/appview/models" 9 11 "tangled.org/core/appview/state/userutil" 10 12 ) 11 13 12 - func (r RepoInfo) Owner() string { 14 + func (r RepoInfo) OwnerWithAt() string { 13 15 if r.OwnerHandle != "" { 14 - return r.OwnerHandle 16 + return fmt.Sprintf("@%s", r.OwnerHandle) 15 17 } else { 16 18 return r.OwnerDid 17 19 } 18 20 } 19 21 20 22 func (r RepoInfo) FullName() string { 21 - return path.Join(r.Owner(), r.Name) 23 + return path.Join(r.OwnerWithAt(), r.Name) 22 24 } 23 25 24 26 func (r RepoInfo) OwnerWithoutAt() string { 25 - if r.OwnerHandle != "" { 26 - return r.OwnerHandle 27 + if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 + return after 27 29 } else { 28 30 return userutil.FlattenDid(r.OwnerDid) 29 31 } ··· 54 56 OwnerDid string 55 57 OwnerHandle string 56 58 Description string 57 - Website string 58 - Topics []string 59 59 Knot string 60 60 Spindle string 61 61 RepoAt syntax.ATURI
+54 -82
appview/pages/templates/fragments/dolly/logo.html
··· 1 1 {{ define "fragments/dolly/logo" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - class="{{ . }}" 6 - width="25" 7 - height="25" 8 - viewBox="0 0 25 25" 9 - sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 10 - inkscape:export-filename="tangled_logotype_black_on_trans.svg" 11 - inkscape:export-xdpi="96" 12 - inkscape:export-ydpi="96" 13 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 14 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 15 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 16 - xmlns="http://www.w3.org/2000/svg" 17 - xmlns:svg="http://www.w3.org/2000/svg" 18 - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 - xmlns:cc="http://creativecommons.org/ns#"> 20 - <sodipodi:namedview 21 - id="namedview1" 22 - pagecolor="#ffffff" 23 - bordercolor="#000000" 24 - borderopacity="0.25" 25 - inkscape:showpageshadow="2" 26 - inkscape:pageopacity="0.0" 27 - inkscape:pagecheckerboard="true" 28 - inkscape:deskcolor="#d5d5d5" 29 - inkscape:zoom="45.254834" 30 - inkscape:cx="3.1377863" 31 - inkscape:cy="8.9382717" 32 - inkscape:window-width="3840" 33 - inkscape:window-height="2160" 34 - inkscape:window-x="0" 35 - inkscape:window-y="0" 36 - inkscape:window-maximized="0" 37 - inkscape:current-layer="g1" 38 - borderlayer="true"> 39 - <inkscape:page 40 - x="0" 41 - y="0" 42 - width="25" 43 - height="25" 44 - id="page2" 45 - margin="0" 46 - bleed="0" /> 47 - </sodipodi:namedview> 48 - <g 49 - inkscape:groupmode="layer" 50 - inkscape:label="Image" 51 - id="g1" 52 - transform="translate(-0.42924038,-0.87777209)"> 53 - <path 54 - fill="currentColor" 55 - style="stroke-width:0.111183;" 56 - d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 - id="path4" 58 - sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" /> 59 - </g> 60 - <metadata 61 - id="metadata1"> 62 - <rdf:RDF> 63 - <cc:Work 64 - rdf:about=""> 65 - <cc:license 66 - rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 67 - </cc:Work> 68 - <cc:License 69 - rdf:about="http://creativecommons.org/licenses/by/4.0/"> 70 - <cc:permits 71 - rdf:resource="http://creativecommons.org/ns#Reproduction" /> 72 - <cc:permits 73 - rdf:resource="http://creativecommons.org/ns#Distribution" /> 74 - <cc:requires 75 - rdf:resource="http://creativecommons.org/ns#Notice" /> 76 - <cc:requires 77 - rdf:resource="http://creativecommons.org/ns#Attribution" /> 78 - <cc:permits 79 - rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 80 - </cc:License> 81 - </rdf:RDF> 82 - </metadata> 83 - </svg> 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{.}}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only.png" 10 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 + xmlns:xlink="http://www.w3.org/1999/xlink" 13 + xmlns="http://www.w3.org/2000/svg" 14 + xmlns:svg="http://www.w3.org/2000/svg"> 15 + <title>Dolly</title> 16 + <defs 17 + id="defs1" /> 18 + <sodipodi:namedview 19 + id="namedview1" 20 + pagecolor="#ffffff" 21 + bordercolor="#000000" 22 + borderopacity="0.25" 23 + inkscape:showpageshadow="2" 24 + inkscape:pageopacity="0.0" 25 + inkscape:pagecheckerboard="true" 26 + inkscape:deskcolor="#d5d5d5"> 27 + <inkscape:page 28 + x="0" 29 + y="0" 30 + width="25" 31 + height="25" 32 + id="page2" 33 + margin="0" 34 + bleed="0" /> 35 + </sodipodi:namedview> 36 + <g 37 + inkscape:groupmode="layer" 38 + inkscape:label="Image" 39 + id="g1"> 40 + <image 41 + width="252.48" 42 + height="248.96001" 43 + preserveAspectRatio="none" 44 + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 45 + id="image1" 46 + x="-233.6257" 47 + y="10.383364" 48 + style="display:none" /> 49 + <path 50 + fill="currentColor" 51 + style="stroke-width:0.111183" 52 + d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 53 + id="path4" /> 54 + </g> 55 + </svg> 84 56 {{ end }}
+22 -60
appview/pages/templates/fragments/dolly/silhouette.html
··· 2 2 <svg 3 3 version="1.1" 4 4 id="svg1" 5 - width="25" 6 - height="25" 5 + width="32" 6 + height="32" 7 7 viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 - inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 - inkscape:export-xdpi="96" 11 - inkscape:export-ydpi="96" 12 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 8 + sodipodi:docname="tangled_dolly_silhouette.png" 13 9 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 10 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 11 xmlns="http://www.w3.org/2000/svg" 16 - xmlns:svg="http://www.w3.org/2000/svg" 17 - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 - xmlns:cc="http://creativecommons.org/ns#"> 19 - <style> 20 - .dolly { 21 - color: #000000; 22 - } 12 + xmlns:svg="http://www.w3.org/2000/svg"> 13 + <style> 14 + .dolly { 15 + color: #000000; 16 + } 23 17 24 - @media (prefers-color-scheme: dark) { 25 - .dolly { 26 - color: #ffffff; 27 - } 28 - } 29 - </style> 18 + @media (prefers-color-scheme: dark) { 19 + .dolly { 20 + color: #ffffff; 21 + } 22 + } 23 + </style> 24 + <title>Dolly</title> 25 + <defs 26 + id="defs1" /> 30 27 <sodipodi:namedview 31 28 id="namedview1" 32 29 pagecolor="#ffffff" ··· 35 32 inkscape:showpageshadow="2" 36 33 inkscape:pageopacity="0.0" 37 34 inkscape:pagecheckerboard="true" 38 - inkscape:deskcolor="#d5d5d5" 39 - inkscape:zoom="64" 40 - inkscape:cx="4.96875" 41 - inkscape:cy="13.429688" 42 - inkscape:window-width="3840" 43 - inkscape:window-height="2160" 44 - inkscape:window-x="0" 45 - inkscape:window-y="0" 46 - inkscape:window-maximized="0" 47 - inkscape:current-layer="g1" 48 - borderlayer="true"> 35 + inkscape:deskcolor="#d1d1d1"> 49 36 <inkscape:page 50 37 x="0" 51 38 y="0" ··· 58 45 <g 59 46 inkscape:groupmode="layer" 60 47 inkscape:label="Image" 61 - id="g1" 62 - transform="translate(-0.42924038,-0.87777209)"> 48 + id="g1"> 63 49 <path 64 50 class="dolly" 65 51 fill="currentColor" 66 - style="stroke-width:0.111183" 67 - d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 - id="path7" 69 - sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 52 + style="stroke-width:1.12248" 53 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 54 + id="path1" /> 70 55 </g> 71 - <metadata 72 - id="metadata1"> 73 - <rdf:RDF> 74 - <cc:Work 75 - rdf:about=""> 76 - <cc:license 77 - rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 78 - </cc:Work> 79 - <cc:License 80 - rdf:about="http://creativecommons.org/licenses/by/4.0/"> 81 - <cc:permits 82 - rdf:resource="http://creativecommons.org/ns#Reproduction" /> 83 - <cc:permits 84 - rdf:resource="http://creativecommons.org/ns#Distribution" /> 85 - <cc:requires 86 - rdf:resource="http://creativecommons.org/ns#Notice" /> 87 - <cc:requires 88 - rdf:resource="http://creativecommons.org/ns#Attribution" /> 89 - <cc:permits 90 - rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 91 - </cc:License> 92 - </rdf:RDF> 93 - </metadata> 94 56 </svg> 95 57 {{ end }}
-25
appview/pages/templates/fragments/tabSelector.html
··· 1 - {{ define "fragments/tabSelector" }} 2 - {{ $name := .Name }} 3 - {{ $all := .Values }} 4 - {{ $active := .Active }} 5 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 6 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 7 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 8 - {{ range $index, $value := $all }} 9 - {{ $isActive := eq $value.Key $active }} 10 - <a href="?{{ $name }}={{ $value.Key }}" 11 - class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 12 - {{ if $value.Icon }} 13 - {{ i $value.Icon "size-4" }} 14 - {{ end }} 15 - 16 - {{ with $value.Meta }} 17 - {{ . }} 18 - {{ end }} 19 - 20 - {{ $value.Value }} 21 - </a> 22 - {{ end }} 23 - </div> 24 - {{ end }} 25 -
-36
appview/pages/templates/fragments/workflow-timers.html
··· 1 - {{ define "fragments/workflow-timers" }} 2 - <script> 3 - function formatElapsed(seconds) { 4 - if (seconds < 1) return '0s'; 5 - if (seconds < 60) return `${seconds}s`; 6 - const minutes = Math.floor(seconds / 60); 7 - const secs = seconds % 60; 8 - if (seconds < 3600) return `${minutes}m ${secs}s`; 9 - const hours = Math.floor(seconds / 3600); 10 - const mins = Math.floor((seconds % 3600) / 60); 11 - return `${hours}h ${mins}m`; 12 - } 13 - 14 - function updateTimers() { 15 - const now = Math.floor(Date.now() / 1000); 16 - 17 - document.querySelectorAll('[data-timer]').forEach(el => { 18 - const startTime = parseInt(el.dataset.start); 19 - const endTime = el.dataset.end ? parseInt(el.dataset.end) : null; 20 - 21 - if (endTime) { 22 - // Step is complete, show final time 23 - const elapsed = endTime - startTime; 24 - el.textContent = formatElapsed(elapsed); 25 - } else { 26 - // Step is running, update live 27 - const elapsed = now - startTime; 28 - el.textContent = formatElapsed(elapsed); 29 - } 30 - }); 31 - } 32 - 33 - setInterval(updateTimers, 1000); 34 - updateTimers(); 35 - </script> 36 - {{ end }}
-167
appview/pages/templates/goodfirstissues/index.html
··· 1 - {{ define "title" }}good first issues{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="good first issues · tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 - <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-10"> 12 - <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 - <h1 class="scale-150 dark:text-white mb-4"> 14 - {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 - Find beginner-friendly issues across all repositories to get started with open source contributions. 18 - </p> 19 - </header> 20 - 21 - <div class="col-span-full md:col-span-10 space-y-6"> 22 - {{ if eq (len .RepoGroups) 0 }} 23 - <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 - <div class="text-center py-16"> 25 - <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 - {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 - </div> 28 - <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 - <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 - There are currently no open issues labeled as "good-first-issue" across all repositories. 31 - </p> 32 - <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 - Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 - </p> 35 - </div> 36 - </div> 37 - {{ else }} 38 - {{ range .RepoGroups }} 39 - <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 - <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 - <div class="font-medium dark:text-white flex items-center justify-between"> 42 - <div class="flex items-center min-w-0 flex-1 mr-2"> 43 - {{ if .Repo.Source }} 44 - {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 - {{ else }} 46 - {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 - {{ end }} 48 - {{ $repoOwner := resolve .Repo.Did }} 49 - <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 - </div> 51 - </div> 52 - 53 - 54 - {{ if .Repo.RepoStats }} 55 - <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 - {{ with .Repo.RepoStats.Language }} 57 - <div class="flex gap-2 items-center text-sm"> 58 - {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 - <span>{{ . }}</span> 60 - </div> 61 - {{ end }} 62 - {{ with .Repo.RepoStats.StarCount }} 63 - <div class="flex gap-1 items-center text-sm"> 64 - {{ i "star" "w-3 h-3 fill-current" }} 65 - <span>{{ . }}</span> 66 - </div> 67 - {{ end }} 68 - {{ with .Repo.RepoStats.IssueCount.Open }} 69 - <div class="flex gap-1 items-center text-sm"> 70 - {{ i "circle-dot" "w-3 h-3" }} 71 - <span>{{ . }}</span> 72 - </div> 73 - {{ end }} 74 - {{ with .Repo.RepoStats.PullCount.Open }} 75 - <div class="flex gap-1 items-center text-sm"> 76 - {{ i "git-pull-request" "w-3 h-3" }} 77 - <span>{{ . }}</span> 78 - </div> 79 - {{ end }} 80 - </div> 81 - {{ end }} 82 - </div> 83 - 84 - {{ with .Repo.Description }} 85 - <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 - {{ . | description }} 87 - </div> 88 - {{ end }} 89 - 90 - {{ if gt (len .Issues) 0 }} 91 - <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 - {{ range .Issues }} 93 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 - <div class="py-2 px-6"> 95 - <div class="flex-grow min-w-0 w-full"> 96 - <div class="flex text-sm items-center justify-between w-full"> 97 - <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 - <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 - {{ .Title | description }} 101 - </span> 102 - </div> 103 - <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 - <span> 105 - <div class="inline-flex items-center gap-1"> 106 - {{ i "message-square" "w-3 h-3" }} 107 - {{ len .Comments }} 108 - </div> 109 - </span> 110 - <span class="before:content-['·'] before:select-none"></span> 111 - <span class="text-sm"> 112 - {{ template "repo/fragments/shortTimeAgo" .Created }} 113 - </span> 114 - <div class="hidden md:inline-flex md:gap-1"> 115 - {{ $labelState := .Labels }} 116 - {{ range $k, $d := $.LabelDefs }} 117 - {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 - {{ end }} 120 - {{ end }} 121 - </div> 122 - </div> 123 - </div> 124 - </div> 125 - </div> 126 - </a> 127 - {{ end }} 128 - </div> 129 - {{ end }} 130 - </div> 131 - {{ end }} 132 - 133 - {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 - <div class="flex justify-center mt-8"> 135 - <div class="flex gap-2"> 136 - {{ if gt .Page.Offset 0 }} 137 - {{ $prev := .Page.Previous }} 138 - <a 139 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 - hx-boost="true" 141 - href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 - > 143 - {{ i "chevron-left" "w-4 h-4" }} 144 - previous 145 - </a> 146 - {{ else }} 147 - <div></div> 148 - {{ end }} 149 - 150 - {{ if eq (len .RepoGroups) .Page.Limit }} 151 - {{ $next := .Page.Next }} 152 - <a 153 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 - hx-boost="true" 155 - href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 - > 157 - next 158 - {{ i "chevron-right" "w-4 h-4" }} 159 - </a> 160 - {{ end }} 161 - </div> 162 - </div> 163 - {{ end }} 164 - {{ end }} 165 - </div> 166 - </div> 167 - {{ end }}
+9 -17
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class=" 17 - bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 - w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 19 17 {{ block "addKnotMemberPopover" . }} {{ end }} 20 18 </div> 21 19 {{ end }} ··· 31 29 ADD MEMBER 32 30 </label> 33 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 34 - <actor-typeahead> 35 - <input 36 - autocapitalize="none" 37 - autocorrect="off" 38 - autocomplete="off" 39 - type="text" 40 - id="member-did-{{ .Id }}" 41 - name="member" 42 - required 43 - placeholder="user.tngl.sh" 44 - class="w-full" 45 - /> 46 - </actor-typeahead> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="member" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 47 39 <div class="flex gap-2 pt-2"> 48 40 <button 49 41 type="button" ··· 62 54 </div> 63 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 64 56 </form> 65 - {{ end }} 57 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 5 + <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+12 -17
appview/pages/templates/layouts/base.html
··· 9 9 10 10 <script defer src="/static/htmx.min.js"></script> 11 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 - <script defer src="/static/actor-typeahead.js" type="module"></script> 13 12 14 13 <!-- preconnect to image cdn --> 15 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 16 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 17 - 18 - <!-- pwa manifest --> 19 - <link rel="manifest" href="/pwa-manifest.json" /> 20 16 21 17 <!-- preload main font --> 22 18 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> ··· 25 21 <title>{{ block "title" . }}{{ end }} · tangled</title> 26 22 {{ block "extrameta" . }}{{ end }} 27 23 </head> 28 - <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 + style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 29 26 {{ block "topbarLayout" . }} 30 - <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 27 + <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 31 28 32 29 {{ if .LoggedInUser }} 33 30 <div id="upgrade-banner" ··· 41 38 {{ end }} 42 39 43 40 {{ block "mainLayout" . }} 44 - <div class="flex-grow"> 45 - <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 46 - {{ block "contentLayout" . }} 47 - <main> 41 + <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 + {{ block "contentLayout" . }} 43 + <main class="col-span-1 md:col-span-8"> 48 44 {{ block "content" . }}{{ end }} 49 45 </main> 50 - {{ end }} 51 - 52 - {{ block "contentAfterLayout" . }} 53 - <main> 46 + {{ end }} 47 + 48 + {{ block "contentAfterLayout" . }} 49 + <main class="col-span-1 md:col-span-8"> 54 50 {{ block "contentAfter" . }}{{ end }} 55 51 </main> 56 - {{ end }} 57 - </div> 52 + {{ end }} 58 53 </div> 59 54 {{ end }} 60 55 61 56 {{ block "footerLayout" . }} 62 - <footer class="mt-12"> 57 + <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 63 58 {{ template "layouts/fragments/footer" . }} 64 59 </footer> 65 60 {{ end }}
+34 -87
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 - <div class="mx-auto px-4"> 4 - <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 - <!-- Desktop layout: grid with 3 columns --> 6 - <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 - <!-- Left section --> 8 - <div> 9 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 - {{ template "fragments/logotypeSmall" }} 11 - </a> 12 - </div> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 + {{ template "fragments/logotypeSmall" }} 8 + </a> 9 + </div> 13 10 14 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 - 18 - <!-- Center section with max-width --> 19 - <div class="grid grid-cols-4 gap-2"> 20 - <div class="flex flex-col gap-1"> 21 - <div class="{{ $headerStyle }}">legal</div> 22 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 - </div> 25 - 26 - <div class="flex flex-col gap-1"> 27 - <div class="{{ $headerStyle }}">resources</div> 28 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 - <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 - </div> 33 - 34 - <div class="flex flex-col gap-1"> 35 - <div class="{{ $headerStyle }}">social</div> 36 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 - </div> 40 - 41 - <div class="flex flex-col gap-1"> 42 - <div class="{{ $headerStyle }}">contact</div> 43 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 - </div> 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 46 19 </div> 47 20 48 - <!-- Right section --> 49 - <div class="text-right"> 50 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 51 27 </div> 52 - </div> 53 28 54 - <!-- Mobile layout: stacked --> 55 - <div class="lg:hidden flex flex-col gap-8"> 56 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 - 60 - <div class="mb-4 md:mb-0"> 61 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 - {{ template "fragments/logotypeSmall" }} 63 - </a> 29 + <div class="flex flex-col gap-1"> 30 + <div class="{{ $headerStyle }}">social</div> 31 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 32 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 33 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 64 34 </div> 65 35 66 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 - <div class="flex flex-col gap-1"> 68 - <div class="{{ $headerStyle }}">legal</div> 69 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 - </div> 72 - 73 - <div class="flex flex-col gap-1"> 74 - <div class="{{ $headerStyle }}">resources</div> 75 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 - <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 - </div> 80 - 81 - <div class="flex flex-col gap-1"> 82 - <div class="{{ $headerStyle }}">social</div> 83 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 - </div> 87 - 88 - <div class="flex flex-col gap-1"> 89 - <div class="{{ $headerStyle }}">contact</div> 90 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 - </div> 36 + <div class="flex flex-col gap-1"> 37 + <div class="{{ $headerStyle }}">contact</div> 38 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 39 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 93 40 </div> 41 + </div> 94 42 95 - <div class="text-center"> 96 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 - </div> 43 + <div class="text-center lg:text-right flex-shrink-0"> 44 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 98 45 </div> 99 46 </div> 100 47 </div>
+11 -7
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 2 + <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 15 15 {{ with .LoggedInUser }} 16 16 {{ block "newButton" . }} {{ end }} 17 17 {{ template "notifications/fragments/bell" }} 18 - {{ block "profileDropdown" . }} {{ end }} 18 + {{ block "dropDown" . }} {{ end }} 19 19 {{ else }} 20 20 <a href="/login">login</a> 21 21 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 33 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 34 {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 35 </summary> 36 - <div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 36 + <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"> 37 37 <a href="/repo/new" class="flex items-center gap-2"> 38 38 {{ i "book-plus" "w-4 h-4" }} 39 39 new repository ··· 46 46 </details> 47 47 {{ end }} 48 48 49 - {{ define "profileDropdown" }} 49 + {{ define "dropDown" }} 50 50 <details class="relative inline-block text-left nav-dropdown"> 51 - <summary class="cursor-pointer list-none flex items-center gap-1"> 52 - {{ $user := .Did }} 51 + <summary 52 + class="cursor-pointer list-none flex items-center gap-1" 53 + > 54 + {{ $user := didOrHandle .Did .Handle }} 53 55 <img 54 56 src="{{ tinyAvatar $user }}" 55 57 alt="" ··· 57 59 /> 58 60 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 59 61 </summary> 60 - <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"> 62 + <div 63 + 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" 64 + > 61 65 <a href="/{{ $user }}">profile</a> 62 66 <a href="/{{ $user }}?tab=repos">repositories</a> 63 67 <a href="/{{ $user }}?tab=strings">strings</a>
-9
appview/pages/templates/layouts/profilebase.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $avatarUrl := fullAvatar .Card.UserHandle }} 5 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 6 5 <meta property="og:type" content="profile" /> 7 6 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 8 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 9 - <meta property="og:image" content="{{ $avatarUrl }}" /> 10 - <meta property="og:image:width" content="512" /> 11 - <meta property="og:image:height" content="512" /> 12 - 13 - <meta name="twitter:card" content="summary" /> 14 - <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 - <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 16 - <meta name="twitter:image" content="{{ $avatarUrl }}" /> 17 8 {{ end }} 18 9 19 10 {{ define "content" }}
+25 -53
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 - <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 - <!-- left items --> 7 - <div class="flex flex-col gap-2"> 8 - <!-- repo owner / repo name --> 9 - <div class="flex items-center gap-2 flex-wrap"> 10 - {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 11 - <span class="select-none">/</span> 12 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 13 - </div> 14 - 15 - {{ if .RepoInfo.Source }} 16 - {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 17 - <div class="flex items-center gap-1 text-sm flex-wrap"> 18 - {{ i "git-fork" "w-3 h-3 shrink-0" }} 19 - <span>forked from</span> 20 - <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 21 - {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 22 - </a> 23 - </div> 24 - {{ end }} 25 - 26 - <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 27 - {{ if .RepoInfo.Description }} 28 - {{ .RepoInfo.Description | description }} 29 - {{ else }} 30 - <span class="italic">this repo has no description</span> 31 - {{ end }} 32 - 33 - {{ with .RepoInfo.Website }} 34 - <span class="flex items-center gap-1"> 35 - <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 36 - <a href="{{ . }}">{{ . | trimUriScheme }}</a> 37 - </span> 38 - {{ end }} 39 - 40 - {{ if .RepoInfo.Topics }} 41 - <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300"> 42 - {{ range .RepoInfo.Topics }} 43 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 44 - {{ end }} 45 - </div> 46 - {{ end }} 47 - 48 - </span> 4 + <section id="repo-header" class="mb-4 py-2 px-6 dark:text-white"> 5 + {{ if .RepoInfo.Source }} 6 + <p class="text-sm"> 7 + <div class="flex items-center"> 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 + forked from 10 + {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 + <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 12 + </div> 13 + </p> 14 + {{ end }} 15 + <div class="text-lg flex items-center justify-between"> 16 + <div> 17 + <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 18 + <span class="select-none">/</span> 19 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 49 20 </div> 50 21 51 - <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 22 + <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 52 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 53 30 <a 54 31 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" ··· 59 36 fork 60 37 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 61 38 </a> 62 - <a 63 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 64 - href="/{{ .RepoInfo.FullName }}/feed.atom"> 65 - {{ i "rss" "size-4" }} 66 - <span class="md:hidden">atom</span> 67 - </a> 68 39 </div> 69 40 </div> 41 + {{ template "repo/fragments/repoDescription" . }} 70 42 </section> 71 43 72 44 <section class="w-full flex flex-col" > ··· 107 79 </div> 108 80 </nav> 109 81 {{ block "repoContentLayout" . }} 110 - <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 82 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 111 83 {{ block "repoContent" . }}{{ end }} 112 84 </section> 113 85 {{ block "repoAfter" . }}{{ end }}
+209 -37
appview/pages/templates/notifications/fragments/item.html
··· 1 1 {{define "notifications/fragments/item"}} 2 - <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 - <div 4 - class=" 5 - w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 - {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 - flex gap-2 items-center 8 - "> 9 - {{ template "notificationIcon" . }} 10 - <div class="flex-1 w-full flex flex-col gap-1"> 11 - <div class="flex items-center gap-1"> 12 - <span>{{ template "notificationHeader" . }}</span> 13 - <span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span> 14 - </div> 15 - <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 16 - </div> 2 + <div 3 + class=" 4 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 5 + {{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}} 6 + flex gap-2 items-center 7 + " 8 + > 17 9 10 + {{ template "notificationIcon" . }} 11 + <div class="flex-1 w-full flex flex-col gap-1"> 12 + <span>{{ template "notificationHeader" . }}</span> 13 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 18 14 </div> 19 - </a> 15 + 16 + </div> 20 17 {{end}} 21 18 22 19 {{ define "notificationIcon" }} 23 20 <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 24 21 <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 25 - <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10"> 22 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 26 23 {{ i .Icon "size-3 text-black dark:text-white" }} 27 24 </div> 28 25 </div> ··· 40 37 commented on an issue 41 38 {{ else if eq .Type "issue_closed" }} 42 39 closed an issue 43 - {{ else if eq .Type "issue_reopen" }} 44 - reopened an issue 45 40 {{ else if eq .Type "pull_created" }} 46 41 created a pull request 47 42 {{ else if eq .Type "pull_commented" }} ··· 50 45 merged a pull request 51 46 {{ else if eq .Type "pull_closed" }} 52 47 closed a pull request 53 - {{ else if eq .Type "pull_reopen" }} 54 - reopened a pull request 55 48 {{ else if eq .Type "followed" }} 56 49 followed you 57 - {{ else if eq .Type "user_mentioned" }} 58 - mentioned you 59 50 {{ else }} 60 51 {{ end }} 61 52 {{ end }} ··· 73 64 {{ end }} 74 65 {{ end }} 75 66 76 - {{ define "notificationUrl" }} 77 - {{ $url := "" }} 78 - {{ if eq .Type "repo_starred" }} 79 - {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 80 - {{ else if .Issue }} 81 - {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 82 - {{ else if .Pull }} 83 - {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 84 - {{ else if eq .Type "followed" }} 85 - {{$url = printf "/%s" (resolve .ActorDid)}} 86 - {{ else }} 87 - {{ end }} 67 + {{define "issueNotification"}} 68 + {{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 69 + <a 70 + href="{{$url}}" 71 + class="block no-underline hover:no-underline text-inherit -m-3 p-3" 72 + > 73 + <div class="flex items-center justify-between"> 74 + <div class="min-w-0 flex-1"> 75 + <!-- First line: icon + actor action --> 76 + <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 77 + {{if eq .Type "issue_created"}} 78 + <span class="text-green-600 dark:text-green-500"> 79 + {{ i "circle-dot" "w-4 h-4" }} 80 + </span> 81 + {{else if eq .Type "issue_commented"}} 82 + <span class="text-gray-500 dark:text-gray-400"> 83 + {{ i "message-circle" "w-4 h-4" }} 84 + </span> 85 + {{else if eq .Type "issue_closed"}} 86 + <span class="text-gray-500 dark:text-gray-400"> 87 + {{ i "ban" "w-4 h-4" }} 88 + </span> 89 + {{end}} 90 + {{template "user/fragments/picHandle" .ActorDid}} 91 + {{if eq .Type "issue_created"}} 92 + <span class="text-gray-500 dark:text-gray-400">opened issue</span> 93 + {{else if eq .Type "issue_commented"}} 94 + <span class="text-gray-500 dark:text-gray-400">commented on issue</span> 95 + {{else if eq .Type "issue_closed"}} 96 + <span class="text-gray-500 dark:text-gray-400">closed issue</span> 97 + {{end}} 98 + {{if not .Read}} 99 + <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 100 + {{end}} 101 + </div> 102 + 103 + <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 104 + <span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span> 105 + <span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span> 106 + <span>on</span> 107 + <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 108 + </div> 109 + </div> 110 + 111 + <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 112 + {{ template "repo/fragments/time" .Created }} 113 + </div> 114 + </div> 115 + </a> 116 + {{end}} 88 117 89 - {{ $url }} 90 - {{ end }} 118 + {{define "pullNotification"}} 119 + {{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 120 + <a 121 + href="{{$url}}" 122 + class="block no-underline hover:no-underline text-inherit -m-3 p-3" 123 + > 124 + <div class="flex items-center justify-between"> 125 + <div class="min-w-0 flex-1"> 126 + <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 127 + {{if eq .Type "pull_created"}} 128 + <span class="text-green-600 dark:text-green-500"> 129 + {{ i "git-pull-request-create" "w-4 h-4" }} 130 + </span> 131 + {{else if eq .Type "pull_commented"}} 132 + <span class="text-gray-500 dark:text-gray-400"> 133 + {{ i "message-circle" "w-4 h-4" }} 134 + </span> 135 + {{else if eq .Type "pull_merged"}} 136 + <span class="text-purple-600 dark:text-purple-500"> 137 + {{ i "git-merge" "w-4 h-4" }} 138 + </span> 139 + {{else if eq .Type "pull_closed"}} 140 + <span class="text-red-600 dark:text-red-500"> 141 + {{ i "git-pull-request-closed" "w-4 h-4" }} 142 + </span> 143 + {{end}} 144 + {{template "user/fragments/picHandle" (resolve .ActorDid)}} 145 + {{if eq .Type "pull_created"}} 146 + <span class="text-gray-500 dark:text-gray-400">opened pull request</span> 147 + {{else if eq .Type "pull_commented"}} 148 + <span class="text-gray-500 dark:text-gray-400">commented on pull request</span> 149 + {{else if eq .Type "pull_merged"}} 150 + <span class="text-gray-500 dark:text-gray-400">merged pull request</span> 151 + {{else if eq .Type "pull_closed"}} 152 + <span class="text-gray-500 dark:text-gray-400">closed pull request</span> 153 + {{end}} 154 + {{if not .Read}} 155 + <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 156 + {{end}} 157 + </div> 158 + 159 + <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 160 + <span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span> 161 + <span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span> 162 + <span>on</span> 163 + <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 164 + </div> 165 + </div> 166 + 167 + <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 168 + {{ template "repo/fragments/time" .Created }} 169 + </div> 170 + </div> 171 + </a> 172 + {{end}} 173 + 174 + {{define "repoNotification"}} 175 + {{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 176 + <a 177 + href="{{$url}}" 178 + class="block no-underline hover:no-underline text-inherit -m-3 p-3" 179 + > 180 + <div class="flex items-center justify-between"> 181 + <div class="flex items-center gap-2 min-w-0 flex-1"> 182 + <span class="text-yellow-500 dark:text-yellow-400"> 183 + {{ i "star" "w-4 h-4" }} 184 + </span> 185 + 186 + <div class="min-w-0 flex-1"> 187 + <!-- Single line for stars: actor action subject --> 188 + <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 189 + {{template "user/fragments/picHandle" (resolve .ActorDid)}} 190 + <span class="text-gray-500 dark:text-gray-400">starred</span> 191 + <span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 192 + {{if not .Read}} 193 + <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 194 + {{end}} 195 + </div> 196 + </div> 197 + </div> 198 + 199 + <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 200 + {{ template "repo/fragments/time" .Created }} 201 + </div> 202 + </div> 203 + </a> 204 + {{end}} 205 + 206 + {{define "followNotification"}} 207 + {{$url := printf "/%s" (resolve .ActorDid)}} 208 + <a 209 + href="{{$url}}" 210 + class="block no-underline hover:no-underline text-inherit -m-3 p-3" 211 + > 212 + <div class="flex items-center justify-between"> 213 + <div class="flex items-center gap-2 min-w-0 flex-1"> 214 + <span class="text-blue-600 dark:text-blue-400"> 215 + {{ i "user-plus" "w-4 h-4" }} 216 + </span> 217 + 218 + <div class="min-w-0 flex-1"> 219 + <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 220 + {{template "user/fragments/picHandle" (resolve .ActorDid)}} 221 + <span class="text-gray-500 dark:text-gray-400">followed you</span> 222 + {{if not .Read}} 223 + <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 224 + {{end}} 225 + </div> 226 + </div> 227 + </div> 228 + 229 + <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 230 + {{ template "repo/fragments/time" .Created }} 231 + </div> 232 + </div> 233 + </a> 234 + {{end}} 235 + 236 + {{define "genericNotification"}} 237 + <a 238 + href="#" 239 + class="block no-underline hover:no-underline text-inherit -m-3 p-3" 240 + > 241 + <div class="flex items-center justify-between"> 242 + <div class="flex items-center gap-2 min-w-0 flex-1"> 243 + <span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}"> 244 + {{ i "bell" "w-4 h-4" }} 245 + </span> 246 + 247 + <div class="min-w-0 flex-1"> 248 + <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 249 + <span>New notification</span> 250 + {{if not .Read}} 251 + <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 252 + {{end}} 253 + </div> 254 + </div> 255 + </div> 256 + 257 + <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 258 + {{ template "repo/fragments/time" .Created }} 259 + </div> 260 + </div> 261 + </a> 262 + {{end}}
+39 -62
appview/pages/templates/repo/blob.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} 14 + {{ $lines := split .Contents }} 15 + {{ $tot_lines := len $lines }} 16 + {{ $tot_chars := len (printf "%d" $tot_lines) }} 17 + {{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }} 14 18 {{ $linkstyle := "no-underline hover:underline" }} 15 19 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 16 20 <div class="flex flex-col md:flex-row md:justify-between gap-2"> ··· 32 36 </div> 33 37 <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 34 38 <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 35 - 36 - {{ if .BlobView.ShowingText }} 37 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 38 - <span>{{ .Lines }} lines</span> 39 - {{ end }} 40 - 41 - {{ if .BlobView.SizeHint }} 42 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 43 - <span>{{ byteFmt .BlobView.SizeHint }}</span> 44 - {{ end }} 45 - 46 - {{ if .BlobView.HasRawView }} 47 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 48 - <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 49 - {{ end }} 50 - 51 - {{ if .BlobView.ShowToggle }} 52 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 53 - <a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 54 - view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 55 - </a> 39 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 40 + <span>{{ .Lines }} lines</span> 41 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 42 + <span>{{ byteFmt .SizeHint }}</span> 43 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 44 + <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 + {{ if .RenderToggle }} 46 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 + hx-boost="true" 50 + >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 56 51 {{ end }} 57 52 </div> 58 53 </div> 59 54 </div> 60 - {{ if .BlobView.IsUnsupported }} 61 - <p class="text-center text-gray-400 dark:text-gray-500"> 62 - Previews are not supported for this file type. 63 - </p> 64 - {{ else if .BlobView.ContentType.IsSubmodule }} 65 - <p class="text-center text-gray-400 dark:text-gray-500"> 66 - This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>. 67 - </p> 68 - {{ else if .BlobView.ContentType.IsImage }} 69 - <div class="text-center"> 70 - <img src="{{ .BlobView.ContentSrc }}" 71 - alt="{{ .Path }}" 72 - class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 73 - </div> 74 - {{ else if .BlobView.ContentType.IsVideo }} 75 - <div class="text-center"> 76 - <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 77 - <source src="{{ .BlobView.ContentSrc }}"> 78 - Your browser does not support the video tag. 79 - </video> 80 - </div> 81 - {{ else if .BlobView.ContentType.IsSvg }} 82 - <div class="overflow-auto relative"> 83 - {{ if .BlobView.ShowingRendered }} 84 - <div class="text-center"> 85 - <img src="{{ .BlobView.ContentSrc }}" 86 - alt="{{ .Path }}" 87 - class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 88 - </div> 55 + {{ if and .IsBinary .Unsupported }} 56 + <p class="text-center text-gray-400 dark:text-gray-500"> 57 + Previews are not supported for this file type. 58 + </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 72 + {{ else }} 73 + <div class="overflow-auto relative"> 74 + {{ if .ShowRendered }} 75 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 89 76 {{ else }} 90 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 77 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 91 78 {{ end }} 92 - </div> 93 - {{ else if .BlobView.ContentType.IsMarkup }} 94 - <div class="overflow-auto relative"> 95 - {{ if .BlobView.ShowingRendered }} 96 - <div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div> 97 - {{ else }} 98 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 99 - {{ end }} 100 - </div> 101 - {{ else if .BlobView.ContentType.IsCode }} 102 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 79 + </div> 103 80 {{ end }} 104 81 {{ template "fragments/multiline-select" }} 105 82 {{ end }}
+14 -14
appview/pages/templates/repo/commit.html
··· 24 24 </div> 25 25 </div> 26 26 27 - <div class="flex flex-wrap items-center space-x-2"> 28 - <p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300"> 29 - {{ $did := index $.EmailToDid $commit.Author.Email }} 27 + <div class="flex items-center space-x-2"> 28 + <p class="text-sm text-gray-500 dark:text-gray-300"> 29 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 30 30 31 - {{ if $did }} 32 - {{ template "user/fragments/picHandleLink" $did }} 31 + {{ if $didOrHandle }} 32 + <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 33 33 {{ else }} 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 35 {{ end }} 36 - 37 36 <span class="px-1 select-none before:content-['\00B7']"></span> 38 37 {{ template "repo/fragments/time" $commit.Author.When }} 39 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 + </p> 40 40 41 + <p class="flex items-center text-sm text-gray-500 dark:text-gray-300"> 41 42 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> 42 - 43 43 {{ if $commit.Parent }} 44 - {{ i "arrow-left" "w-3 h-3 mx-1" }} 45 - <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 44 + {{ i "arrow-left" "w-3 h-3 mx-1" }} 45 + <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 46 46 {{ end }} 47 47 </p> 48 48 ··· 58 58 <div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div> 59 59 <div class="flex items-center gap-2 my-2"> 60 60 {{ i "user" "w-4 h-4" }} 61 - {{ $committerDid := index $.EmailToDid $commit.Committer.Email }} 62 - {{ template "user/fragments/picHandleLink" $committerDid }} 61 + {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 + <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 63 </div> 64 64 <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header class="col-span-full" style="z-index: 20;"> 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 88 {{ define "mainLayout" }} 89 - <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 90 {{ block "contentLayout" . }} 91 91 {{ block "content" . }}{{ end }} 92 92 {{ end }} ··· 105 105 {{ end }} 106 106 107 107 {{ define "footerLayout" }} 108 - <footer class="col-span-full mt-12"> 108 + <footer class="px-1 col-span-full mt-12"> 109 109 {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }}
+1 -1
appview/pages/templates/repo/empty.html
··· 35 35 36 36 <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 37 <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 - <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 40 </div> 41 41 </div>
-7
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - 10 - <fieldset class="space-y-3"> 11 - <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 - <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 - class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 - </fieldset> 15 - 16 9 <fieldset class="space-y-3"> 17 10 <legend class="dark:text-white">Select a knot to fork into</legend> 18 11 <div class="space-y-2">
+5 -5
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.org" }} 4 + {{ $knot = "tangled.sh" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group"> ··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" ··· 48 48 <code 49 49 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 50 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 53 <button 54 54 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 55 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+18 -20
appview/pages/templates/repo/fragments/diffOpts.html
··· 5 5 {{ if .Split }} 6 6 {{ $active = "split" }} 7 7 {{ end }} 8 - 9 - {{ $unified := 10 - (dict 11 - "Key" "unified" 12 - "Value" "unified" 13 - "Icon" "square-split-vertical" 14 - "Meta" "") }} 15 - {{ $split := 16 - (dict 17 - "Key" "split" 18 - "Value" "split" 19 - "Icon" "square-split-horizontal" 20 - "Meta" "") }} 21 - {{ $values := list $unified $split }} 22 - 23 - {{ template "fragments/tabSelector" 24 - (dict 25 - "Name" "diff" 26 - "Values" $values 27 - "Active" $active) }} 8 + {{ $values := list "unified" "split" }} 9 + {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 28 10 </section> 29 11 {{ end }} 30 12 13 + {{ define "tabSelector" }} 14 + {{ $name := .Name }} 15 + {{ $all := .Values }} 16 + {{ $active := .Active }} 17 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 + {{ range $index, $value := $all }} 21 + {{ $isActive := eq $value $active }} 22 + <a href="?{{ $name }}={{ $value }}" 23 + class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 + {{ $value }} 25 + </a> 26 + {{ end }} 27 + </div> 28 + {{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
··· 1 + {{ define "repo/fragments/editRepoDescription" }} 2 + <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 + <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 + <button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm"> 5 + {{ i "check" "w-3 h-3" }} save 6 + </button> 7 + <button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 + {{ i "x" "w-3 h-3" }} cancel 9 + </button> 10 + </form> 11 + {{ end }}
-48
appview/pages/templates/repo/fragments/externalLinkPanel.html
··· 1 - {{ define "repo/fragments/externalLinkPanel" }} 2 - <div id="at-uri-panel" class="px-2 md:px-0"> 3 - <div class="flex justify-between items-center gap-2"> 4 - <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span> 5 - <div class="flex items-center gap-2"> 6 - <button 7 - onclick="copyToClipboard(this)" 8 - class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 9 - title="Copy to clipboard"> 10 - {{ i "copy" "w-4 h-4" }} 11 - </button> 12 - <a 13 - href="https://pdsls.dev/{{.}}" 14 - class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 15 - title="View in PDSls"> 16 - {{ i "arrow-up-right" "w-4 h-4" }} 17 - </a> 18 - </div> 19 - </div> 20 - <span 21 - class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600" 22 - onclick="window.getSelection().selectAllChildren(this)" 23 - title="{{.}}" 24 - data-aturi="{{ . | string | safeUrl }}" 25 - >{{.}}</span> 26 - 27 - 28 - </div> 29 - 30 - <script> 31 - function copyToClipboard(button) { 32 - const container = document.getElementById("at-uri-panel"); 33 - const urlSpan = container?.querySelector('[data-aturi]'); 34 - const text = urlSpan?.getAttribute('data-aturi'); 35 - console.log("copying to clipboard", text) 36 - if (!text) return; 37 - 38 - navigator.clipboard.writeText(text).then(() => { 39 - const originalContent = button.innerHTML; 40 - button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 41 - setTimeout(() => { 42 - button.innerHTML = originalContent; 43 - }, 2000); 44 - }); 45 - } 46 - </script> 47 - {{ end }} 48 -
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 2 + <div id="label-panel" class="flex flex-col gap-6"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+1 -9
appview/pages/templates/repo/fragments/og.html
··· 2 2 {{ $title := or .Title .RepoInfo.FullName }} 3 3 {{ $description := or .Description .RepoInfo.Description }} 4 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 5 + 6 6 7 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 8 <meta property="og:type" content="object" /> 9 9 <meta property="og:url" content="{{ $url }}" /> 10 10 <meta property="og:description" content="{{ $description }}" /> 11 - <meta property="og:image" content="{{ $imageUrl }}" /> 12 - <meta property="og:image:width" content="1200" /> 13 - <meta property="og:image:height" content="600" /> 14 - 15 - <meta name="twitter:card" content="summary_large_image" /> 16 - <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 - <meta name="twitter:description" content="{{ $description }}" /> 18 - <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 11 {{ end }}
-26
appview/pages/templates/repo/fragments/participants.html
··· 1 - {{ define "repo/fragments/participants" }} 2 - {{ $all := . }} 3 - {{ $ps := take $all 5 }} 4 - <div class="px-2 md:px-0"> 5 - <div class="py-1 flex items-center text-sm"> 6 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 - </div> 9 - <div class="flex items-center -space-x-3 mt-2"> 10 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 - {{ range $i, $p := $ps }} 12 - <img 13 - src="{{ tinyAvatar . }}" 14 - alt="" 15 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 - /> 17 - {{ end }} 18 - 19 - {{ if gt (len $all) 5 }} 20 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 - +{{ sub (len $all) 5 }} 22 - </span> 23 - {{ end }} 24 - </div> 25 - </div> 26 - {{ end }}
+1 -6
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 relative group 5 + leading-4 px-3 gap-1 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 - {{ if gt (length .Users) 0 }} 24 - title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 - {{ else }} 26 - title="{{ .Kind }}" 27 - {{ end }} 28 23 {{ if .IsReacted }} 29 24 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 25 {{ else }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 + {{ define "repo/fragments/repoDescription" }} 2 + <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 + {{ if .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 + {{ else }} 6 + <span class="italic">this repo has no description</span> 7 + {{ end }} 8 + 9 + {{ if .RepoInfo.Roles.IsOwner }} 10 + <button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 + {{ i "pencil" "w-3 h-3" }} 12 + </button> 13 + {{ end }} 14 + </span> 15 + {{ end }}
+14 -11
appview/pages/templates/repo/index.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <details class="group -my-4 -m-6 mb-4"> 38 + <details class="group -m-6 mb-4"> 39 39 <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 40 {{ range $value := .Languages }} 41 41 <div ··· 129 129 {{ $icon := "folder" }} 130 130 {{ $iconStyle := "size-4 fill-current" }} 131 131 132 - {{ if .IsSubmodule }} 133 - {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 - {{ $icon = "folder-input" }} 135 - {{ $iconStyle = "size-4" }} 136 - {{ end }} 137 - 138 132 {{ if .IsFile }} 139 133 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 140 134 {{ $icon = "file" }} 141 135 {{ $iconStyle = "size-4" }} 142 136 {{ end }} 143 - 144 137 <a href="{{ $link }}" class="{{ $linkstyle }}"> 145 138 <div class="flex items-center gap-2"> 146 139 {{ i $icon $iconStyle "flex-shrink-0" }} ··· 229 222 class="mx-1 before:content-['·'] before:select-none" 230 223 ></span> 231 224 <span> 232 - {{ $did := index $.EmailToDid .Author.Email }} 233 - <a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 225 + {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 226 + <a 227 + href="{{ if $didOrHandle }} 228 + /{{ $didOrHandle }} 229 + {{ else }} 230 + mailto:{{ .Author.Email }} 231 + {{ end }}" 234 232 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 235 - >{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a> 233 + >{{ if $didOrHandle }} 234 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 235 + {{ else }} 236 + {{ .Author.Name }} 237 + {{ end }}</a 238 + > 236 239 </span> 237 240 <div class="inline-block px-1 select-none after:content-['·']"></div> 238 241 {{ template "repo/fragments/time" .Committer.When }}
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 - {{ define "repo/issues/fragments/globalIssueListing" }} 2 - <div class="flex flex-col gap-2"> 3 - {{ range .Issues }} 4 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 - <div class="pb-2 mb-3"> 6 - <div class="flex items-center gap-3 mb-2"> 7 - <a 8 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 - class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 - > 11 - {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 - </a> 13 - </div> 14 - <a 15 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 - class="no-underline hover:underline" 17 - > 18 - {{ .Title | description }} 19 - <span class="text-gray-500">#{{ .IssueId }}</span> 20 - </a> 21 - </div> 22 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 - {{ $icon := "ban" }} 25 - {{ $state := "closed" }} 26 - {{ if .Open }} 27 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 - {{ $icon = "circle-dot" }} 29 - {{ $state = "open" }} 30 - {{ end }} 31 - 32 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 - <span class="text-white dark:text-white">{{ $state }}</span> 35 - </span> 36 - 37 - <span class="ml-1"> 38 - {{ template "user/fragments/picHandleLink" .Did }} 39 - </span> 40 - 41 - <span class="before:content-['·']"> 42 - {{ template "repo/fragments/time" .Created }} 43 - </span> 44 - 45 - <span class="before:content-['·']"> 46 - {{ $s := "s" }} 47 - {{ if eq (len .Comments) 1 }} 48 - {{ $s = "" }} 49 - {{ end }} 50 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 - </span> 52 - 53 - {{ $state := .Labels }} 54 - {{ range $k, $d := $.LabelDefs }} 55 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 - {{ end }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - {{ end }} 62 - </div> 63 - {{ end }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 34 34 35 35 {{ define "editIssueComment" }} 36 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 39 hx-swap="outerHTML" 40 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 44 45 45 {{ define "deleteIssueComment" }} 46 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 49 hx-confirm="Are you sure you want to delete your comment?" 50 50 hx-swap="outerHTML"
-55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 - {{ define "repo/issues/fragments/issueListing" }} 2 - <div class="flex flex-col gap-2"> 3 - {{ range .Issues }} 4 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 - <div class="pb-2"> 6 - <a 7 - href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 - class="no-underline hover:underline" 9 - > 10 - {{ .Title | description }} 11 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 12 - </a> 13 - </div> 14 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 - {{ $icon := "ban" }} 17 - {{ $state := "closed" }} 18 - {{ if .Open }} 19 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 - {{ $icon = "circle-dot" }} 21 - {{ $state = "open" }} 22 - {{ end }} 23 - 24 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 - <span class="text-white dark:text-white">{{ $state }}</span> 27 - </span> 28 - 29 - <span class="ml-1"> 30 - {{ template "user/fragments/picHandleLink" .Did }} 31 - </span> 32 - 33 - <span class="before:content-['·']"> 34 - {{ template "repo/fragments/time" .Created }} 35 - </span> 36 - 37 - <span class="before:content-['·']"> 38 - {{ $s := "s" }} 39 - {{ if eq (len .Comments) 1 }} 40 - {{ $s = "" }} 41 - {{ end }} 42 - <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 - </span> 44 - 45 - {{ $state := .Labels }} 46 - {{ range $k, $d := $.LabelDefs }} 47 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 - {{ end }} 50 - {{ end }} 51 - </div> 52 - </div> 53 - {{ end }} 54 - </div> 55 - {{ end }}
+2 -7
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 - sign up 144 - </a> 145 - <span class="text-gray-500 dark:text-gray-400">or</span> 146 - <a href="/login" class="underline">login</a> 147 - to add to the discussion 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 148 143 </div> 149 144 {{ end }} 150 145 {{ end }}
-19
appview/pages/templates/repo/issues/fragments/og.html
··· 1 - {{ define "repo/issues/fragments/og" }} 2 - {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 - {{ $description := or .Issue.Body .RepoInfo.Description }} 4 - {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 - {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 - 7 - <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 - <meta property="og:type" content="object" /> 9 - <meta property="og:url" content="{{ $url }}" /> 10 - <meta property="og:description" content="{{ $description }}" /> 11 - <meta property="og:image" content="{{ $imageUrl }}" /> 12 - <meta property="og:image:width" content="1200" /> 13 - <meta property="og:image:height" content="600" /> 14 - 15 - <meta name="twitter:card" content="summary_large_image" /> 16 - <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 - <meta name="twitter:description" content="{{ $description }}" /> 18 - <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 - {{ end }}
+35 -9
appview/pages/templates/repo/issues/issue.html
··· 2 2 3 3 4 4 {{ define "extrameta" }} 5 - {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 5 + {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 + 8 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 6 9 {{ end }} 7 10 8 11 {{ define "repoContentLayout" }} ··· 19 22 "Defs" $.LabelDefs 20 23 "Subject" $.Issue.AtUri 21 24 "State" $.Issue.Labels) }} 22 - {{ template "repo/fragments/participants" $.Issue.Participants }} 23 - {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 25 + {{ template "issueParticipants" . }} 24 26 </div> 25 27 </div> 26 28 {{ end }} ··· 85 87 86 88 {{ define "editIssue" }} 87 89 <a 88 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 90 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 89 91 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 90 92 hx-swap="innerHTML" 91 93 hx-target="#issue-{{.Issue.IssueId}}"> ··· 95 97 96 98 {{ define "deleteIssue" }} 97 99 <a 98 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 100 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 99 101 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 100 102 hx-confirm="Are you sure you want to delete your issue?" 101 103 hx-swap="none"> ··· 108 110 <div class="flex items-center gap-2"> 109 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 110 112 {{ range $kind := .OrderedReactionKinds }} 111 - {{ $reactionData := index $.Reactions $kind }} 112 113 {{ 113 114 template "repo/fragments/reaction" 114 115 (dict 115 116 "Kind" $kind 116 - "Count" $reactionData.Count 117 + "Count" (index $.Reactions $kind) 117 118 "IsReacted" (index $.UserReacted $kind) 118 - "ThreadAt" $.Issue.AtUri 119 - "Users" $reactionData.Users) 119 + "ThreadAt" $.Issue.AtUri) 120 120 }} 121 121 {{ end }} 122 122 </div> 123 123 {{ end }} 124 124 125 + {{ define "issueParticipants" }} 126 + {{ $all := .Issue.Participants }} 127 + {{ $ps := take $all 5 }} 128 + <div> 129 + <div class="py-1 flex items-center text-sm"> 130 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 + </div> 133 + <div class="flex items-center -space-x-3 mt-2"> 134 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 + {{ range $i, $p := $ps }} 136 + <img 137 + src="{{ tinyAvatar . }}" 138 + alt="" 139 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 + /> 141 + {{ end }} 142 + 143 + {{ if gt (len $all) 5 }} 144 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 + +{{ sub (len $all) 5 }} 146 + </span> 147 + {{ end }} 148 + </div> 149 + </div> 150 + {{ end }} 125 151 126 152 {{ define "repoAfter" }} 127 153 <div class="flex flex-col gap-4 mt-4">
+76 -45
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringByOpen }} 13 - {{ $active = "open" }} 14 - {{ end }} 15 - 16 - {{ $open := 17 - (dict 18 - "Key" "open" 19 - "Value" "open" 20 - "Icon" "circle-dot" 21 - "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 - {{ $closed := 23 - (dict 24 - "Key" "closed" 25 - "Value" "closed" 26 - "Icon" "ban" 27 - "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 28 - {{ $values := list $open $closed }} 29 - 30 - <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 - <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 - <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 - <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 34 - {{ i "search" "w-4 h-4" }} 35 - </div> 36 - <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 37 - <a 38 - href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 39 - class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 40 - > 41 - {{ i "x" "w-4 h-4" }} 42 - </a> 43 - </form> 44 - <div class="sm:row-start-1"> 45 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 46 - </div> 11 + <div class="flex justify-between items-center gap-4"> 12 + <div class="flex gap-4"> 13 + <a 14 + href="?state=open" 15 + class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 + > 17 + {{ i "circle-dot" "w-4 h-4" }} 18 + <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 19 + </a> 47 20 <a 21 + href="?state=closed" 22 + class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 + > 24 + {{ i "ban" "w-4 h-4" }} 25 + <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 + </a> 27 + </div> 28 + <a 48 29 href="/{{ .RepoInfo.FullName }}/issues/new" 49 - class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 50 - > 30 + class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 31 + > 51 32 {{ i "circle-plus" "w-4 h-4" }} 52 33 <span>new</span> 53 - </a> 54 - </div> 55 - <div class="error" id="issues"></div> 34 + </a> 35 + </div> 36 + <div class="error" id="issues"></div> 56 37 {{ end }} 57 38 58 39 {{ define "repoAfter" }} 59 - <div class="mt-2"> 60 - {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 + 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 + 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 + 71 + <span class="before:content-['·']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 + 75 + <span class="before:content-['·']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + 83 + {{ $state := .Labels }} 84 + {{ range $k, $d := $.LabelDefs }} 85 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 + {{ end }} 88 + {{ end }} 89 + </div> 90 + </div> 91 + {{ end }} 61 92 </div> 62 93 {{ block "pagination" . }} {{ end }} 63 94 {{ end }} ··· 74 105 <a 75 106 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 76 107 hx-boost="true" 77 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 108 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 78 109 > 79 110 {{ i "chevron-left" "w-4 h-4" }} 80 111 previous ··· 88 119 <a 89 120 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 90 121 hx-boost="true" 91 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 122 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 92 123 > 93 124 next 94 125 {{ i "chevron-right" "w-4 h-4" }}
+6 -6
appview/pages/templates/repo/log.html
··· 27 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 28 <div class="{{ $grid }} py-3"> 29 29 <div class="align-top truncate col-span-2"> 30 - {{ $did := index $.EmailToDid $commit.Author.Email }} 31 - {{ if $did }} 32 - {{ template "user/fragments/picHandleLink" $did }} 30 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 + {{ if $didOrHandle }} 32 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 33 {{ else }} 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 35 {{ end }} ··· 153 153 </span> 154 154 <span class="mx-2 before:content-['·'] before:select-none"></span> 155 155 <span> 156 - {{ $did := index $.EmailToDid $commit.Author.Email }} 157 - <a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 156 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 + <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }} 159 + {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 160 </a> 161 161 </span> 162 162 <div class="inline-block px-1 select-none after:content-['·']"></div>
+6 -7
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 2 <div id="lines" hx-swap-oob="beforeend"> 3 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 - <div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div> 6 - <div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div> 5 + <div class="group-open:hidden flex items-center gap-1"> 6 + {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 + </div> 8 + <div class="hidden group-open:flex items-center gap-1"> 9 + {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 + </div> 7 11 </summary> 8 12 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 9 13 </details> 10 14 </div> 11 15 {{ end }} 12 - 13 - {{ define "stepHeader" }} 14 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 15 - <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 - {{ end }}
-9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
··· 1 - {{ define "repo/pipelines/fragments/logBlockEnd" }} 2 - <span 3 - class="ml-auto text-sm text-gray-500 tabular-nums" 4 - data-timer="{{ .Id }}" 5 - data-start="{{ .StartTime.Unix }}" 6 - data-end="{{ .EndTime.Unix }}" 7 - hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span> 8 - {{ end }} 9 -
+3 -15
appview/pages/templates/repo/pipelines/pipelines.html
··· 12 12 {{ range .Pipelines }} 13 13 {{ block "pipeline" (list $ .) }} {{ end }} 14 14 {{ else }} 15 - <div class="py-6 w-fit flex flex-col gap-4 mx-auto"> 16 - <p> 17 - No pipelines have been run for this repository yet. To get started: 18 - </p> 19 - {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 20 - <p> 21 - <span class="{{ $bullet }}">1</span>First, choose a spindle in your 22 - <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>. 23 - </p> 24 - <p> 25 - <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 27 - </p> 28 - <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 - </div> 15 + <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 16 + No pipelines run for this repository. 17 + </p> 30 18 {{ end }} 31 19 </div> 32 20 </div>
-6
appview/pages/templates/repo/pipelines/workflow.html
··· 15 15 {{ block "logs" . }} {{ end }} 16 16 </div> 17 17 </section> 18 - {{ template "fragments/workflow-timers" }} 19 18 {{ end }} 20 19 21 20 {{ define "sidebar" }} ··· 59 58 hx-ext="ws" 60 59 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 61 60 <div id="lines" class="flex flex-col gap-2"> 62 - <div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded"> 63 - <span class="flex items-center gap-2"> 64 - {{ i "triangle-alert" "size-4" }} No logs for this workflow 65 - </span> 66 - </div> 67 61 </div> 68 62 </div> 69 63 {{ end }}
-19
appview/pages/templates/repo/pulls/fragments/og.html
··· 1 - {{ define "repo/pulls/fragments/og" }} 2 - {{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }} 3 - {{ $description := or .Pull.Body .RepoInfo.Description }} 4 - {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 5 - {{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }} 6 - 7 - <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 - <meta property="og:type" content="object" /> 9 - <meta property="og:url" content="{{ $url }}" /> 10 - <meta property="og:description" content="{{ $description }}" /> 11 - <meta property="og:image" content="{{ $imageUrl }}" /> 12 - <meta property="og:image:width" content="1200" /> 13 - <meta property="og:image:height" content="600" /> 14 - 15 - <meta name="twitter:card" content="summary_large_image" /> 16 - <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 - <meta name="twitter:description" content="{{ $description }}" /> 18 - <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 - {{ end }}
+72 -81
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 26 - <button 27 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 - hx-target="#actions-{{$roundNumber}}" 29 - hx-swap="outerHtml" 30 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 - {{ i "message-square-plus" "w-4 h-4" }} 32 - <span>comment</span> 33 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 - </button> 35 - {{ if .BranchDeleteStatus }} 36 - <button 37 - hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 - hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 - hx-swap="none" 40 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 - {{ i "git-branch" "w-4 h-4" }} 42 - <span>delete branch</span> 43 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 44 - </button> 45 - {{ end }} 46 - {{ if and $isPushAllowed $isOpen $isLastRound }} 47 - {{ $disabled := "" }} 48 - {{ if $isConflicted }} 49 - {{ $disabled = "disabled" }} 50 - {{ end }} 51 - <button 52 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 - hx-swap="none" 54 - hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 - {{ i "git-merge" "w-4 h-4" }} 57 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 58 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 - </button> 60 - {{ end }} 25 + <div class="relative w-fit"> 26 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 27 + <button 28 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 29 + hx-target="#actions-{{$roundNumber}}" 30 + hx-swap="outerHtml" 31 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 32 + {{ i "message-square-plus" "w-4 h-4" }} 33 + <span>comment</span> 34 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 + </button> 36 + {{ if and $isPushAllowed $isOpen $isLastRound }} 37 + {{ $disabled := "" }} 38 + {{ if $isConflicted }} 39 + {{ $disabled = "disabled" }} 40 + {{ end }} 41 + <button 42 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 43 + hx-swap="none" 44 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 45 + class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 46 + {{ i "git-merge" "w-4 h-4" }} 47 + <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 48 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 + </button> 50 + {{ end }} 61 51 62 - {{ if and $isPullAuthor $isOpen $isLastRound }} 63 - {{ $disabled := "" }} 64 - {{ if $isUpToDate }} 65 - {{ $disabled = "disabled" }} 66 - {{ end }} 67 - <button id="resubmitBtn" 68 - {{ if not .Pull.IsPatchBased }} 69 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 70 - {{ else }} 71 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 72 - hx-target="#actions-{{$roundNumber}}" 73 - hx-swap="outerHtml" 52 + {{ if and $isPullAuthor $isOpen $isLastRound }} 53 + {{ $disabled := "" }} 54 + {{ if $isUpToDate }} 55 + {{ $disabled = "disabled" }} 74 56 {{ end }} 57 + <button id="resubmitBtn" 58 + {{ if not .Pull.IsPatchBased }} 59 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 60 + {{ else }} 61 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 62 + hx-target="#actions-{{$roundNumber}}" 63 + hx-swap="outerHtml" 64 + {{ end }} 75 65 76 - hx-disabled-elt="#resubmitBtn" 77 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 66 + hx-disabled-elt="#resubmitBtn" 67 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 68 79 - {{ if $disabled }} 80 - title="Update this branch to resubmit this pull request" 81 - {{ else }} 82 - title="Resubmit this pull request" 83 - {{ end }} 84 - > 85 - {{ i "rotate-ccw" "w-4 h-4" }} 86 - <span>resubmit</span> 87 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 - </button> 89 - {{ end }} 69 + {{ if $disabled }} 70 + title="Update this branch to resubmit this pull request" 71 + {{ else }} 72 + title="Resubmit this pull request" 73 + {{ end }} 74 + > 75 + {{ i "rotate-ccw" "w-4 h-4" }} 76 + <span>resubmit</span> 77 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 78 + </button> 79 + {{ end }} 90 80 91 - {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 92 - <button 93 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 - hx-swap="none" 95 - class="btn p-2 flex items-center gap-2 group"> 96 - {{ i "ban" "w-4 h-4" }} 97 - <span>close</span> 98 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 - </button> 100 - {{ end }} 81 + {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 82 + <button 83 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 84 + hx-swap="none" 85 + class="btn p-2 flex items-center gap-2 group"> 86 + {{ i "ban" "w-4 h-4" }} 87 + <span>close</span> 88 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 89 + </button> 90 + {{ end }} 101 91 102 - {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 103 - <button 104 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 - hx-swap="none" 106 - class="btn p-2 flex items-center gap-2 group"> 107 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 - <span>reopen</span> 109 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 110 - </button> 111 - {{ end }} 92 + {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 93 + <button 94 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 95 + hx-swap="none" 96 + class="btn p-2 flex items-center gap-2 group"> 97 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 98 + <span>reopen</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </button> 101 + {{ end }} 102 + </div> 112 103 </div> 113 104 {{ end }} 114 105
+11 -15
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 42 {{ if not .Pull.IsPatchBased }} 43 43 from 44 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - {{ if not .Pull.IsForkBased }} 46 - {{ $repoPath := .RepoInfo.FullName }} 47 - <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 - {{ else if .Pull.PullSource.Repo }} 49 - {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 - <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 - <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 - {{ else }} 53 - <span class="italic">[deleted fork]</span>: 54 - {{ .Pull.PullSource.Branch }} 55 - {{ end }} 45 + {{ if .Pull.IsForkBased }} 46 + {{ if .Pull.PullSource.Repo }} 47 + {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 + <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 + {{- else -}} 50 + <span class="italic">[deleted fork]</span> 51 + {{- end -}} 52 + {{- end -}} 53 + {{- .Pull.PullSource.Branch -}} 56 54 </span> 57 55 {{ end }} 58 56 </span> ··· 68 66 <div class="flex items-center gap-2 mt-2"> 69 67 {{ template "repo/fragments/reactionsPopUp" . }} 70 68 {{ range $kind := . }} 71 - {{ $reactionData := index $.Reactions $kind }} 72 69 {{ 73 70 template "repo/fragments/reaction" 74 71 (dict 75 72 "Kind" $kind 76 - "Count" $reactionData.Count 73 + "Count" (index $.Reactions $kind) 77 74 "IsReacted" (index $.UserReacted $kind) 78 - "ThreadAt" $.Pull.AtUri 79 - "Users" $reactionData.Users) 75 + "ThreadAt" $.Pull.PullAt) 80 76 }} 81 77 {{ end }} 82 78 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ resolve .LoggedInUser.Did }} 6 + {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+14 -1
appview/pages/templates/repo/pulls/interdiff.html
··· 28 28 29 29 {{ end }} 30 30 31 + {{ define "topbarLayout" }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/fragments/topbar" . }} 34 + </header> 35 + {{ end }} 36 + 31 37 {{ define "mainLayout" }} 32 - <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 33 39 {{ block "contentLayout" . }} 34 40 {{ block "content" . }}{{ end }} 35 41 {{ end }} ··· 46 52 {{ end }} 47 53 </div> 48 54 {{ end }} 55 + 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/fragments/footer" . }} 59 + </footer> 60 + {{ end }} 61 + 49 62 50 63 {{ define "contentAfter" }} 51 64 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+13 -1
appview/pages/templates/repo/pulls/patch.html
··· 34 34 </section> 35 35 {{ end }} 36 36 37 + {{ define "topbarLayout" }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/fragments/topbar" . }} 40 + </header> 41 + {{ end }} 42 + 37 43 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 45 {{ block "contentLayout" . }} 40 46 {{ block "content" . }}{{ end }} 41 47 {{ end }} ··· 51 57 </div> 52 58 {{ end }} 53 59 </div> 60 + {{ end }} 61 + 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/fragments/footer" . }} 65 + </footer> 54 66 {{ end }} 55 67 56 68 {{ define "contentAfter" }}
+20 -49
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "extrameta" }} 6 - {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 6 + {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 + 9 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 10 {{ end }} 8 11 9 - {{ define "repoContentLayout" }} 10 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 11 - <div class="col-span-1 md:col-span-8"> 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 13 - {{ block "repoContent" . }}{{ end }} 14 - </section> 15 - {{ block "repoAfter" . }}{{ end }} 16 - </div> 17 - <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 18 - {{ template "repo/fragments/labelPanel" 19 - (dict "RepoInfo" $.RepoInfo 20 - "Defs" $.LabelDefs 21 - "Subject" $.Pull.AtUri 22 - "State" $.Pull.Labels) }} 23 - {{ template "repo/fragments/participants" $.Pull.Participants }} 24 - {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 25 - </div> 26 - </div> 27 - {{ end }} 28 12 29 13 {{ define "repoContent" }} 30 14 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 55 39 {{ with $item }} 56 40 <details {{ if eq $idx $lastIdx }}open{{ end }}> 57 41 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 58 - <div class="flex flex-wrap gap-2 items-stretch"> 42 + <div class="flex flex-wrap gap-2 items-center"> 59 43 <!-- round number --> 60 44 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 61 45 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 62 46 </div> 63 47 <!-- round summary --> 64 - <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 48 + <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 65 49 <span class="gap-1 flex items-center"> 66 50 {{ $owner := resolve $.Pull.OwnerDid }} 67 51 {{ $re := "re" }} ··· 88 72 <span class="hidden md:inline">diff</span> 89 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 74 </a> 91 - {{ if ne $idx 0 }} 92 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 93 - hx-boost="true" 94 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 95 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 96 - <span class="hidden md:inline">interdiff</span> 97 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 98 - </a> 75 + {{ if not (eq .RoundNumber 0) }} 76 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 + hx-boost="true" 78 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 + <span class="hidden md:inline">interdiff</span> 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </a> 83 + <span id="interdiff-error-{{.RoundNumber}}"></span> 99 84 {{ end }} 100 - <span id="interdiff-error-{{.RoundNumber}}"></span> 101 85 </div> 102 86 </summary> 103 87 ··· 162 146 163 147 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 164 148 {{ range $cidx, $c := .Comments }} 165 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 149 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 166 150 {{ if gt $cidx 0 }} 167 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 168 152 {{ end }} ··· 185 169 {{ end }} 186 170 187 171 {{ if $.LoggedInUser }} 188 - {{ template "repo/pulls/fragments/pullActions" 189 - (dict 190 - "LoggedInUser" $.LoggedInUser 191 - "Pull" $.Pull 192 - "RepoInfo" $.RepoInfo 193 - "RoundNumber" .RoundNumber 194 - "MergeCheck" $.MergeCheck 195 - "ResubmitCheck" $.ResubmitCheck 196 - "BranchDeleteStatus" $.BranchDeleteStatus 197 - "Stack" $.Stack) }} 172 + {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 198 173 {{ else }} 199 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 200 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 201 - sign up 202 - </a> 203 - <span class="text-gray-500 dark:text-gray-400">or</span> 204 - <a href="/login" class="underline">login</a> 205 - to add to the discussion 174 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 175 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 176 + <a href="/login" class="underline">login</a> to join the discussion 206 177 </div> 207 178 {{ end }} 208 179 </div>
+34 -59
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringBy.IsOpen }} 13 - {{ $active = "open" }} 14 - {{ else if .FilteringBy.IsMerged }} 15 - {{ $active = "merged" }} 16 - {{ end }} 17 - {{ $open := 18 - (dict 19 - "Key" "open" 20 - "Value" "open" 21 - "Icon" "git-pull-request" 22 - "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 - {{ $merged := 24 - (dict 25 - "Key" "merged" 26 - "Value" "merged" 27 - "Icon" "git-merge" 28 - "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 - {{ $closed := 30 - (dict 31 - "Key" "closed" 32 - "Value" "closed" 33 - "Icon" "ban" 34 - "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 35 - {{ $values := list $open $merged $closed }} 36 - <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 - <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 - <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 - <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 40 - {{ i "search" "w-4 h-4" }} 41 - </div> 42 - <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 43 - <a 44 - href="?state={{ .FilteringBy.String }}" 45 - class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 46 - > 47 - {{ i "x" "w-4 h-4" }} 48 - </a> 49 - </form> 50 - <div class="sm:row-start-1"> 51 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 11 + <div class="flex justify-between items-center"> 12 + <div class="flex gap-4"> 13 + <a 14 + href="?state=open" 15 + class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 + > 17 + {{ i "git-pull-request" "w-4 h-4" }} 18 + <span>{{ .RepoInfo.Stats.PullCount.Open }} open</span> 19 + </a> 20 + <a 21 + href="?state=merged" 22 + class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 + > 24 + {{ i "git-merge" "w-4 h-4" }} 25 + <span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span> 26 + </a> 27 + <a 28 + href="?state=closed" 29 + class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 30 + > 31 + {{ i "ban" "w-4 h-4" }} 32 + <span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span> 33 + </a> 34 + </div> 35 + <a 36 + href="/{{ .RepoInfo.FullName }}/pulls/new" 37 + class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 38 + > 39 + {{ i "git-pull-request-create" "w-4 h-4" }} 40 + <span>new</span> 41 + </a> 52 42 </div> 53 - <a 54 - href="/{{ .RepoInfo.FullName }}/pulls/new" 55 - class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 56 - > 57 - {{ i "git-pull-request-create" "w-4 h-4" }} 58 - <span>new</span> 59 - </a> 60 - </div> 61 - <div class="error" id="pulls"></div> 43 + <div class="error" id="pulls"></div> 62 44 {{ end }} 63 45 64 46 {{ define "repoAfter" }} ··· 126 108 <span class="before:content-['·']"></span> 127 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 128 110 {{ end }} 129 - 130 - {{ $state := .Labels }} 131 - {{ range $k, $d := $.LabelDefs }} 132 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 133 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 134 - {{ end }} 135 - {{ end }} 136 111 </div> 137 112 </div> 138 113 {{ if .StackId }} ··· 151 126 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 152 127 </div> 153 128 </summary> 154 - {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 129 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 155 130 </details> 156 131 {{ end }} 157 132 {{ end }} ··· 160 135 </div> 161 136 {{ end }} 162 137 163 - {{ define "stackedPullList" }} 138 + {{ define "pullList" }} 164 139 {{ $list := index . 0 }} 165 140 {{ $root := index . 1 }} 166 141 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
+8 -17
appview/pages/templates/repo/settings/access.html
··· 66 66 <div 67 67 id="add-collaborator-modal" 68 68 popover 69 - class=" 70 - bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 71 - dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 72 - w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 69 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 73 70 {{ template "addCollaboratorModal" . }} 74 71 </div> 75 72 {{ end }} ··· 85 82 ADD COLLABORATOR 86 83 </label> 87 84 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 88 - <actor-typeahead> 89 - <input 90 - autocapitalize="none" 91 - autocorrect="off" 92 - autocomplete="off" 93 - type="text" 94 - id="add-collaborator" 95 - name="collaborator" 96 - required 97 - placeholder="user.tngl.sh" 98 - class="w-full" 99 - /> 100 - </actor-typeahead> 85 + <input 86 + type="text" 87 + id="add-collaborator" 88 + name="collaborator" 89 + required 90 + placeholder="@foo.bsky.social" 91 + /> 101 92 <div class="flex gap-2 pt-2"> 102 93 <button 103 94 type="button"
-47
appview/pages/templates/repo/settings/general.html
··· 6 6 {{ template "repo/settings/fragments/sidebar" . }} 7 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 - {{ template "baseSettings" . }} 10 9 {{ template "branchSettings" . }} 11 10 {{ template "defaultLabelSettings" . }} 12 11 {{ template "customLabelSettings" . }} ··· 14 13 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 15 14 </div> 16 15 </section> 17 - {{ end }} 18 - 19 - {{ define "baseSettings" }} 20 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none"> 21 - <fieldset 22 - class="" 23 - {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 24 - > 25 - <h2 class="text-sm pb-2 uppercase font-bold">Description</h2> 26 - <textarea 27 - rows="3" 28 - class="w-full mb-2" 29 - id="base-form-description" 30 - name="description" 31 - >{{ .RepoInfo.Description }}</textarea> 32 - <h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2> 33 - <input 34 - type="text" 35 - class="w-full mb-2" 36 - id="base-form-website" 37 - name="website" 38 - value="{{ .RepoInfo.Website }}" 39 - > 40 - <h2 class="text-sm pb-2 uppercase font-bold">Topics</h2> 41 - <p class="text-gray-500 dark:text-gray-400"> 42 - List of topics separated by spaces. 43 - </p> 44 - <textarea 45 - rows="2" 46 - class="w-full my-2" 47 - id="base-form-topics" 48 - name="topics" 49 - >{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea> 50 - <div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div> 51 - <div class="flex justify-end pt-2"> 52 - <button 53 - type="submit" 54 - class="btn-create flex items-center gap-2 group" 55 - > 56 - {{ i "save" "w-4 h-4" }} 57 - save 58 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 - </button> 60 - </div> 61 - <fieldset> 62 - </form> 63 16 {{ end }} 64 17 65 18 {{ define "branchSettings" }}
-8
appview/pages/templates/repo/tree.html
··· 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 62 - {{ if .IsSubmodule }} 63 - {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 64 - {{ $icon = "folder-input" }} 65 - {{ $iconStyle = "size-4" }} 66 - {{ end }} 67 - 68 62 {{ if .IsFile }} 69 - {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 70 63 {{ $icon = "file" }} 71 64 {{ $iconStyle = "size-4" }} 72 65 {{ end }} 73 - 74 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 75 67 <div class="flex items-center gap-2"> 76 68 {{ i $icon $iconStyle "flex-shrink-0" }}
+8 -16
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class=" 17 - bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 - w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 19 17 {{ block "addSpindleMemberPopover" . }} {{ end }} 20 18 </div> 21 19 {{ end }} ··· 31 29 ADD MEMBER 32 30 </label> 33 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 34 - <actor-typeahead> 35 - <input 36 - autocapitalize="none" 37 - autocorrect="off" 38 - autocomplete="off" 39 - type="text" 40 - id="member-did-{{ .Id }}" 41 - name="member" 42 - required 43 - placeholder="user.tngl.sh" 44 - class="w-full" 45 - /> 46 - </actor-typeahead> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="member" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 47 39 <div class="flex gap-2 pt-2"> 48 40 <button 49 41 type="button"
+3 -3
appview/pages/templates/strings/string.html
··· 47 47 </span> 48 48 </section> 49 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 - <div class="flex flex-col md:flex-row md:justify-between md: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"> 50 + <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"> 51 51 <span> 52 52 {{ .String.Filename }} 53 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> ··· 75 75 </div> 76 76 <div class="overflow-x-auto overflow-y-hidden relative"> 77 77 {{ if .ShowRendered }} 78 - <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 78 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 79 79 {{ else }} 80 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div> 80 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 81 81 {{ end }} 82 82 </div> 83 83 {{ template "fragments/multiline-select" }}
-30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 - {{ define "timeline/fragments/goodfirstissues" }} 2 - {{ if .GfiLabel }} 3 - <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 - <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 - <div class="flex-1 flex flex-col gap-2"> 6 - <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 - <p> 8 - Make your first contribution to an open-source project this October. 9 - <em>good-first-issue</em> helps new contributors find easy ways to 10 - start contributing to open-source projects. 11 - </p> 12 - <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 - Browse issues {{ i "arrow-right" "size-4" }} 14 - </span> 15 - </div> 16 - <div class="hidden md:block relative px-16 scale-150"> 17 - <div class="relative opacity-60"> 18 - {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 - </div> 20 - <div class="relative -mt-4 ml-2 opacity-80"> 21 - {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 - </div> 23 - <div class="relative -mt-4 ml-4"> 24 - {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 - </div> 26 - </div> 27 - </div> 28 - </a> 29 - {{ end }} 30 - {{ end }}
+2 -2
appview/pages/templates/timeline/fragments/hero.html
··· 4 4 <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 5 6 6 <p class="text-lg"> 7 - Tangled is a decentralized Git hosting and collaboration platform. 7 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 8 </p> 9 9 <p class="text-lg"> 10 - We envision a place where developers have complete ownership of their 10 + we envision a place where developers have complete ownership of their 11 11 code, open source communities can freely self-govern and most 12 12 importantly, coding can be social and fun again. 13 13 </p>
-1
appview/pages/templates/timeline/home.html
··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 - {{ template "timeline/fragments/goodfirstissues" . }} 16 15 {{ template "timeline/fragments/trending" . }} 17 16 {{ template "timeline/fragments/timeline" . }} 18 17 <div class="flex justify-end">
-1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 - {{ template "timeline/fragments/goodfirstissues" . }} 17 16 {{ template "timeline/fragments/trending" . }} 18 17 {{ template "timeline/fragments/timeline" . }} 19 18 {{ end }}
-1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 - <link rel="manifest" href="/pwa-manifest.json" /> 24 23 <link 25 24 rel="stylesheet" 26 25 href="/static/tw.css?{{ cssContentHash }}"
-11
appview/pages/templates/user/fragments/editBio.html
··· 20 20 </div> 21 21 22 22 <div class="flex flex-col gap-1"> 23 - <label class="m-0 p-0" for="pronouns">pronouns</label> 24 - <div class="flex items-center gap-2 w-full"> 25 - {{ $pronouns := "" }} 26 - {{ if and .Profile .Profile.Pronouns }} 27 - {{ $pronouns = .Profile.Pronouns }} 28 - {{ end }} 29 - <input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}"> 30 - </div> 31 - </div> 32 - 33 - <div class="flex flex-col gap-1"> 34 23 <label class="m-0 p-0" for="location">location</label> 35 24 <div class="flex items-center gap-2 w-full"> 36 25 {{ $location := "" }}
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 3 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 7 </div> 8 8 9 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+6 -19
appview/pages/templates/user/fragments/profileCard.html
··· 12 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 13 {{ $userIdent }} 14 14 </p> 15 - {{ with .Profile }} 16 - {{ if .Pronouns }} 17 - <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 - {{ end }} 19 - {{ end }} 15 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 20 16 </div> 21 17 22 18 <div class="md:hidden"> ··· 71 67 {{ end }} 72 68 </div> 73 69 {{ end }} 74 - 75 - <div class="flex mt-2 items-center gap-2"> 76 - {{ if ne .FollowStatus.String "IsSelf" }} 77 - {{ template "user/fragments/follow" . }} 78 - {{ else }} 70 + {{ if ne .FollowStatus.String "IsSelf" }} 71 + {{ template "user/fragments/follow" . }} 72 + {{ else }} 79 73 <button id="editBtn" 80 - class="btn w-full flex items-center gap-2 group" 74 + class="btn mt-2 w-full flex items-center gap-2 group" 81 75 hx-target="#profile-bio" 82 76 hx-get="/profile/edit-bio" 83 77 hx-swap="innerHTML"> ··· 85 79 edit 86 80 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 81 </button> 88 - {{ end }} 89 - 90 - <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 - href="/{{ $userIdent }}/feed.atom"> 92 - {{ i "rss" "size-4" }} 93 - </a> 94 - </div> 95 - 82 + {{ end }} 96 83 </div> 97 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 98 85 </div>
+2 -24
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 - <link rel="manifest" href="/pwa-manifest.json" /> 12 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 12 <title>login &middot; tangled</title> 14 13 </head> 15 14 <body class="flex items-center justify-center min-h-screen"> 16 - <main class="max-w-md px-7 mt-4"> 15 + <main class="max-w-md px-6 -mt-4"> 17 16 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 17 {{ template "fragments/logotype" }} 19 18 </h1> ··· 21 20 tightly-knit social coding. 22 21 </h2> 23 22 <form 24 - class="mt-4" 23 + class="mt-4 max-w-sm mx-auto" 25 24 hx-post="/login" 26 25 hx-swap="none" 27 26 hx-disabled-elt="#login-button" ··· 29 28 <div class="flex flex-col"> 30 29 <label for="handle">handle</label> 31 30 <input 32 - autocapitalize="none" 33 - autocorrect="off" 34 - autocomplete="username" 35 31 type="text" 36 32 id="handle" 37 33 name="handle" ··· 56 52 <span>login</span> 57 53 </button> 58 54 </form> 59 - {{ if .ErrorCode }} 60 - <div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300"> 61 - <span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span> 62 - <div> 63 - <h5 class="font-medium">Login error</h5> 64 - <p class="text-sm"> 65 - {{ if eq .ErrorCode "access_denied" }} 66 - You have not authorized the app. 67 - {{ else if eq .ErrorCode "session" }} 68 - Server failed to create user session. 69 - {{ else }} 70 - Internal Server error. 71 - {{ end }} 72 - Please try again. 73 - </p> 74 - </div> 75 - </div> 76 - {{ end }} 77 55 <p class="text-sm text-gray-500"> 78 56 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 79 57 </p>
-14
appview/pages/templates/user/settings/notifications.html
··· 144 144 <div class="flex items-center justify-between p-2"> 145 145 <div class="flex items-center gap-2"> 146 146 <div class="flex flex-col gap-1"> 147 - <span class="font-bold">Mentions</span> 148 - <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 - <span>When someone mentions you.</span> 150 - </div> 151 - </div> 152 - </div> 153 - <label class="flex items-center gap-2"> 154 - <input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 - </label> 156 - </div> 157 - 158 - <div class="flex items-center justify-between p-2"> 159 - <div class="flex items-center gap-2"> 160 - <div class="flex flex-col gap-1"> 161 147 <span class="font-bold">Email notifications</span> 162 148 <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 163 149 <span>Receive notifications via email in addition to in-app notifications.</span>
+3 -1
appview/pages/templates/user/settings/profile.html
··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 + {{ if .LoggedInUser.Handle }} 36 37 <span class="font-bold"> 37 - {{ resolve .LoggedInUser.Did }} 38 + @{{ .LoggedInUser.Handle }} 38 39 </span> 40 + {{ end }} 39 41 </div> 40 42 </div> 41 43 <div class="flex items-center justify-between p-4">
-1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 - <link rel="manifest" href="/pwa-manifest.json" /> 12 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 12 <title>sign up &middot; tangled</title> 14 13
-46
appview/pagination/page.go
··· 1 1 package pagination 2 2 3 - import "context" 4 - 5 3 type Page struct { 6 4 Offset int // where to start from 7 5 Limit int // number of items in a page ··· 12 10 Offset: 0, 13 11 Limit: 30, 14 12 } 15 - } 16 - 17 - type ctxKey struct{} 18 - 19 - func IntoContext(ctx context.Context, page Page) context.Context { 20 - return context.WithValue(ctx, ctxKey{}, page) 21 - } 22 - 23 - func FromContext(ctx context.Context) Page { 24 - if ctx == nil { 25 - return FirstPage() 26 - } 27 - v := ctx.Value(ctxKey{}) 28 - if v == nil { 29 - return FirstPage() 30 - } 31 - page, ok := v.(Page) 32 - if !ok { 33 - return FirstPage() 34 - } 35 - return page 36 13 } 37 14 38 15 func (p Page) Previous() Page { ··· 52 29 Limit: p.Limit, 53 30 } 54 31 } 55 - 56 - func IterateAll[T any]( 57 - fetch func(page Page) ([]T, error), 58 - handle func(items []T) error, 59 - ) error { 60 - page := FirstPage() 61 - for { 62 - items, err := fetch(page) 63 - if err != nil { 64 - return err 65 - } 66 - 67 - err = handle(items) 68 - if err != nil { 69 - return err 70 - } 71 - if len(items) < page.Limit { 72 - break 73 - } 74 - page = page.Next() 75 - } 76 - return nil 77 - }
+17 -37
appview/pipelines/pipelines.go
··· 16 16 "tangled.org/core/appview/reporesolver" 17 17 "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 19 20 "tangled.org/core/rbac" 20 21 spindlemodel "tangled.org/core/spindle/models" 21 22 ··· 35 36 logger *slog.Logger 36 37 } 37 38 38 - func (p *Pipelines) Router() http.Handler { 39 - r := chi.NewRouter() 40 - r.Get("/", p.Index) 41 - r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 42 - r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 43 - 44 - return r 45 - } 46 - 47 39 func New( 48 40 oauth *oauth.OAuth, 49 41 repoResolver *reporesolver.RepoResolver, ··· 53 45 db *db.DB, 54 46 config *config.Config, 55 47 enforcer *rbac.Enforcer, 56 - logger *slog.Logger, 57 48 ) *Pipelines { 58 - return &Pipelines{ 59 - oauth: oauth, 49 + logger := log.New("pipelines") 50 + 51 + return &Pipelines{oauth: oauth, 60 52 repoResolver: repoResolver, 61 53 pages: pages, 62 54 idResolver: idResolver, ··· 236 228 // start a goroutine to read from spindle 237 229 go readLogs(spindleConn, evChan) 238 230 239 - stepStartTimes := make(map[int]time.Time) 231 + stepIdx := 0 240 232 var fragment bytes.Buffer 241 233 for { 242 234 select { ··· 268 260 269 261 switch logLine.Kind { 270 262 case spindlemodel.LogKindControl: 271 - switch logLine.StepStatus { 272 - case spindlemodel.StepStatusStart: 273 - stepStartTimes[logLine.StepId] = logLine.Time 274 - collapsed := false 275 - if logLine.StepKind == spindlemodel.StepKindSystem { 276 - collapsed = true 277 - } 278 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 279 - Id: logLine.StepId, 280 - Name: logLine.Content, 281 - Command: logLine.StepCommand, 282 - Collapsed: collapsed, 283 - StartTime: logLine.Time, 284 - }) 285 - case spindlemodel.StepStatusEnd: 286 - startTime := stepStartTimes[logLine.StepId] 287 - endTime := logLine.Time 288 - err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{ 289 - Id: logLine.StepId, 290 - StartTime: startTime, 291 - EndTime: endTime, 292 - }) 263 + // control messages create a new step block 264 + stepIdx++ 265 + collapsed := false 266 + if logLine.StepKind == spindlemodel.StepKindSystem { 267 + collapsed = true 293 268 } 294 - 269 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 270 + Id: stepIdx, 271 + Name: logLine.Content, 272 + Command: logLine.StepCommand, 273 + Collapsed: collapsed, 274 + }) 295 275 case spindlemodel.LogKindData: 296 276 // data messages simply insert new log lines into current step 297 277 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 298 - Id: logLine.StepId, 278 + Id: stepIdx, 299 279 Content: logLine.Content, 300 280 }) 301 281 }
+17
appview/pipelines/router.go
··· 1 + package pipelines 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 + ) 9 + 10 + func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 11 + r := chi.NewRouter() 12 + r.Get("/", p.Index) 13 + r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 14 + r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 15 + 16 + return r 17 + }
-321
appview/pulls/opengraph.go
··· 1 - package pulls 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "image" 8 - "image/color" 9 - "image/png" 10 - "log" 11 - "net/http" 12 - 13 - "tangled.org/core/appview/db" 14 - "tangled.org/core/appview/models" 15 - "tangled.org/core/appview/ogcard" 16 - "tangled.org/core/patchutil" 17 - "tangled.org/core/types" 18 - ) 19 - 20 - func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) { 21 - width, height := ogcard.DefaultSize() 22 - mainCard, err := ogcard.NewCard(width, height) 23 - if err != nil { 24 - return nil, err 25 - } 26 - 27 - // Split: content area (75%) and status/stats area (25%) 28 - contentCard, statsArea := mainCard.Split(false, 75) 29 - 30 - // Add padding to content 31 - contentCard.SetMargin(50) 32 - 33 - // Split content horizontally: main content (80%) and avatar area (20%) 34 - mainContent, avatarArea := contentCard.Split(true, 80) 35 - 36 - // Add margin to main content 37 - mainContent.SetMargin(10) 38 - 39 - // Use full main content area for repo name and title 40 - bounds := mainContent.Img.Bounds() 41 - startX := bounds.Min.X + mainContent.Margin 42 - startY := bounds.Min.Y + mainContent.Margin 43 - 44 - // Draw full repository name at top (owner/repo format) 45 - var repoOwner string 46 - owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) 47 - if err != nil { 48 - repoOwner = repo.Did 49 - } else { 50 - repoOwner = "@" + owner.Handle.String() 51 - } 52 - 53 - fullRepoName := repoOwner + " / " + repo.Name 54 - if len(fullRepoName) > 60 { 55 - fullRepoName = fullRepoName[:60] + "…" 56 - } 57 - 58 - grayColor := color.RGBA{88, 96, 105, 255} 59 - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 60 - if err != nil { 61 - return nil, err 62 - } 63 - 64 - // Draw pull request title below repo name with wrapping 65 - titleY := startY + 60 66 - titleX := startX 67 - 68 - // Truncate title if too long 69 - pullTitle := pull.Title 70 - maxTitleLength := 80 71 - if len(pullTitle) > maxTitleLength { 72 - pullTitle = pullTitle[:maxTitleLength] + "…" 73 - } 74 - 75 - // Create a temporary card for the title area to enable wrapping 76 - titleBounds := mainContent.Img.Bounds() 77 - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 78 - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID 79 - 80 - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 81 - titleCard := &ogcard.Card{ 82 - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 83 - Font: mainContent.Font, 84 - Margin: 0, 85 - } 86 - 87 - // Draw wrapped title 88 - lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) 89 - if err != nil { 90 - return nil, err 91 - } 92 - 93 - // Calculate where title ends (number of lines * line height) 94 - lineHeight := 60 // Approximate line height for 54pt font 95 - titleEndY := titleY + (len(lines) * lineHeight) + 10 96 - 97 - // Draw pull ID in gray below the title 98 - pullIdText := fmt.Sprintf("#%d", pull.PullId) 99 - err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 100 - if err != nil { 101 - return nil, err 102 - } 103 - 104 - // Get pull author handle (needed for avatar and metadata) 105 - var authorHandle string 106 - author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) 107 - if err != nil { 108 - authorHandle = pull.OwnerDid 109 - } else { 110 - authorHandle = "@" + author.Handle.String() 111 - } 112 - 113 - // Draw avatar circle on the right side 114 - avatarBounds := avatarArea.Img.Bounds() 115 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 116 - if avatarSize > 220 { 117 - avatarSize = 220 118 - } 119 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 120 - avatarY := avatarBounds.Min.Y + 20 121 - 122 - // Get avatar URL for pull author 123 - avatarURL := s.pages.AvatarUrl(authorHandle, "256") 124 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 125 - if err != nil { 126 - log.Printf("failed to draw avatar (non-fatal): %v", err) 127 - } 128 - 129 - // Split stats area: left side for status/stats (80%), right side for dolly (20%) 130 - statusStatsArea, dollyArea := statsArea.Split(true, 80) 131 - 132 - // Draw status and stats 133 - statsBounds := statusStatsArea.Img.Bounds() 134 - statsX := statsBounds.Min.X + 60 // left padding 135 - statsY := statsBounds.Min.Y 136 - 137 - iconColor := color.RGBA{88, 96, 105, 255} 138 - iconSize := 36 139 - textSize := 36.0 140 - labelSize := 28.0 141 - iconBaselineOffset := int(textSize) / 2 142 - 143 - // Draw status (open/merged/closed) with colored icon and text 144 - var statusIcon string 145 - var statusText string 146 - var statusColor color.RGBA 147 - 148 - if pull.State.IsOpen() { 149 - statusIcon = "git-pull-request" 150 - statusText = "open" 151 - statusColor = color.RGBA{34, 139, 34, 255} // green 152 - } else if pull.State.IsMerged() { 153 - statusIcon = "git-merge" 154 - statusText = "merged" 155 - statusColor = color.RGBA{138, 43, 226, 255} // purple 156 - } else { 157 - statusIcon = "git-pull-request-closed" 158 - statusText = "closed" 159 - statusColor = color.RGBA{128, 128, 128, 255} // gray 160 - } 161 - 162 - statusIconSize := 36 163 - 164 - // Draw icon with status color 165 - err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 166 - if err != nil { 167 - log.Printf("failed to draw status icon: %v", err) 168 - } 169 - 170 - // Draw text with status color 171 - textX := statsX + statusIconSize + 12 172 - statusTextSize := 32.0 173 - err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left) 174 - if err != nil { 175 - log.Printf("failed to draw status text: %v", err) 176 - } 177 - 178 - statusTextWidth := len(statusText) * 20 179 - currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 180 - 181 - // Draw comment count 182 - err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 - if err != nil { 184 - log.Printf("failed to draw comment icon: %v", err) 185 - } 186 - 187 - currentX += iconSize + 15 188 - commentText := fmt.Sprintf("%d comments", commentCount) 189 - if commentCount == 1 { 190 - commentText = "1 comment" 191 - } 192 - err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 193 - if err != nil { 194 - log.Printf("failed to draw comment text: %v", err) 195 - } 196 - 197 - commentTextWidth := len(commentText) * 20 198 - currentX += commentTextWidth + 40 199 - 200 - // Draw files changed 201 - err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 - if err != nil { 203 - log.Printf("failed to draw file diff icon: %v", err) 204 - } 205 - 206 - currentX += iconSize + 15 207 - filesText := fmt.Sprintf("%d files", filesChanged) 208 - if filesChanged == 1 { 209 - filesText = "1 file" 210 - } 211 - err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 212 - if err != nil { 213 - log.Printf("failed to draw files text: %v", err) 214 - } 215 - 216 - filesTextWidth := len(filesText) * 20 217 - currentX += filesTextWidth 218 - 219 - // Draw additions (green +) 220 - greenColor := color.RGBA{34, 139, 34, 255} 221 - additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 222 - err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left) 223 - if err != nil { 224 - log.Printf("failed to draw additions text: %v", err) 225 - } 226 - 227 - additionsTextWidth := len(additionsText) * 20 228 - currentX += additionsTextWidth + 30 229 - 230 - // Draw deletions (red -) right next to additions 231 - redColor := color.RGBA{220, 20, 60, 255} 232 - deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 233 - err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left) 234 - if err != nil { 235 - log.Printf("failed to draw deletions text: %v", err) 236 - } 237 - 238 - // Draw dolly logo on the right side 239 - dollyBounds := dollyArea.Img.Bounds() 240 - dollySize := 90 241 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 242 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 243 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 244 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 245 - if err != nil { 246 - log.Printf("dolly silhouette not available (this is ok): %v", err) 247 - } 248 - 249 - // Draw "opened by @author" and date at the bottom with more spacing 250 - labelY := statsY + iconSize + 30 251 - 252 - // Format the opened date 253 - openedDate := pull.Created.Format("Jan 2, 2006") 254 - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 255 - 256 - err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 257 - if err != nil { 258 - log.Printf("failed to draw metadata: %v", err) 259 - } 260 - 261 - return mainCard, nil 262 - } 263 - 264 - func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 265 - f, err := s.repoResolver.Resolve(r) 266 - if err != nil { 267 - log.Println("failed to get repo and knot", err) 268 - return 269 - } 270 - 271 - pull, ok := r.Context().Value("pull").(*models.Pull) 272 - if !ok { 273 - log.Println("pull not found in context") 274 - http.Error(w, "pull not found", http.StatusNotFound) 275 - return 276 - } 277 - 278 - // Get comment count from database 279 - comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID)) 280 - if err != nil { 281 - log.Printf("failed to get pull comments: %v", err) 282 - } 283 - commentCount := len(comments) 284 - 285 - // Calculate diff stats from latest submission using patchutil 286 - var diffStats types.DiffStat 287 - filesChanged := 0 288 - if len(pull.Submissions) > 0 { 289 - latestSubmission := pull.Submissions[len(pull.Submissions)-1] 290 - niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 291 - diffStats.Insertions = int64(niceDiff.Stat.Insertions) 292 - diffStats.Deletions = int64(niceDiff.Stat.Deletions) 293 - filesChanged = niceDiff.Stat.FilesChanged 294 - } 295 - 296 - card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged) 297 - if err != nil { 298 - log.Println("failed to draw pull summary card", err) 299 - http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError) 300 - return 301 - } 302 - 303 - var imageBuffer bytes.Buffer 304 - err = png.Encode(&imageBuffer, card.Img) 305 - if err != nil { 306 - log.Println("failed to encode pull summary card", err) 307 - http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) 308 - return 309 - } 310 - 311 - imageBytes := imageBuffer.Bytes() 312 - 313 - w.Header().Set("Content-Type", "image/png") 314 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 315 - w.WriteHeader(http.StatusOK) 316 - _, err = w.Write(imageBytes) 317 - if err != nil { 318 - log.Println("failed to write pull summary card", err) 319 - return 320 - } 321 - }
+196 -282
appview/pulls/pulls.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "log" 9 - "log/slog" 10 9 "net/http" 11 - "slices" 12 10 "sort" 13 11 "strconv" 14 12 "strings" ··· 17 15 "tangled.org/core/api/tangled" 18 16 "tangled.org/core/appview/config" 19 17 "tangled.org/core/appview/db" 20 - pulls_indexer "tangled.org/core/appview/indexer/pulls" 21 18 "tangled.org/core/appview/models" 22 19 "tangled.org/core/appview/notify" 23 20 "tangled.org/core/appview/oauth" 24 21 "tangled.org/core/appview/pages" 25 22 "tangled.org/core/appview/pages/markup" 26 23 "tangled.org/core/appview/reporesolver" 27 - "tangled.org/core/appview/validator" 28 24 "tangled.org/core/appview/xrpcclient" 29 25 "tangled.org/core/idresolver" 30 26 "tangled.org/core/patchutil" 31 - "tangled.org/core/rbac" 32 27 "tangled.org/core/tid" 33 28 "tangled.org/core/types" 34 29 30 + "github.com/bluekeyes/go-gitdiff/gitdiff" 35 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 36 - "github.com/bluesky-social/indigo/atproto/syntax" 37 32 lexutil "github.com/bluesky-social/indigo/lex/util" 38 33 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 39 34 "github.com/go-chi/chi/v5" ··· 48 43 db *db.DB 49 44 config *config.Config 50 45 notifier notify.Notifier 51 - enforcer *rbac.Enforcer 52 - logger *slog.Logger 53 - validator *validator.Validator 54 - indexer *pulls_indexer.Indexer 55 46 } 56 47 57 48 func New( ··· 62 53 db *db.DB, 63 54 config *config.Config, 64 55 notifier notify.Notifier, 65 - enforcer *rbac.Enforcer, 66 - validator *validator.Validator, 67 - indexer *pulls_indexer.Indexer, 68 - logger *slog.Logger, 69 56 ) *Pulls { 70 57 return &Pulls{ 71 58 oauth: oauth, ··· 75 62 db: db, 76 63 config: config, 77 64 notifier: notifier, 78 - enforcer: enforcer, 79 - logger: logger, 80 - validator: validator, 81 - indexer: indexer, 82 65 } 83 66 } 84 67 ··· 115 98 } 116 99 117 100 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 118 - branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 119 101 resubmitResult := pages.Unknown 120 102 if user.Did == pull.OwnerDid { 121 103 resubmitResult = s.resubmitCheck(r, f, pull, stack) 122 104 } 123 105 124 106 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 125 - LoggedInUser: user, 126 - RepoInfo: f.RepoInfo(user), 127 - Pull: pull, 128 - RoundNumber: roundNumber, 129 - MergeCheck: mergeCheckResponse, 130 - ResubmitCheck: resubmitResult, 131 - BranchDeleteStatus: branchDeleteStatus, 132 - Stack: stack, 107 + LoggedInUser: user, 108 + RepoInfo: f.RepoInfo(user), 109 + Pull: pull, 110 + RoundNumber: roundNumber, 111 + MergeCheck: mergeCheckResponse, 112 + ResubmitCheck: resubmitResult, 113 + Stack: stack, 133 114 }) 134 115 return 135 116 } ··· 154 135 stack, _ := r.Context().Value("stack").(models.Stack) 155 136 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 156 137 138 + totalIdents := 1 139 + for _, submission := range pull.Submissions { 140 + totalIdents += len(submission.Comments) 141 + } 142 + 143 + identsToResolve := make([]string, totalIdents) 144 + 145 + // populate idents 146 + identsToResolve[0] = pull.OwnerDid 147 + idx := 1 148 + for _, submission := range pull.Submissions { 149 + for _, comment := range submission.Comments { 150 + identsToResolve[idx] = comment.OwnerDid 151 + idx += 1 152 + } 153 + } 154 + 157 155 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 158 - branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 159 156 resubmitResult := pages.Unknown 160 157 if user != nil && user.Did == pull.OwnerDid { 161 158 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 192 189 m[p.Sha] = p 193 190 } 194 191 195 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 192 + reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 196 193 if err != nil { 197 194 log.Println("failed to get pull reactions") 198 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 197 201 198 userReactions := map[models.ReactionKind]bool{} 202 199 if user != nil { 203 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 204 - } 205 - 206 - labelDefs, err := db.GetLabelDefinitions( 207 - s.db, 208 - db.FilterIn("at_uri", f.Repo.Labels), 209 - db.FilterContains("scope", tangled.RepoPullNSID), 210 - ) 211 - if err != nil { 212 - log.Println("failed to fetch labels", err) 213 - s.pages.Error503(w) 214 - return 215 - } 216 - 217 - defs := make(map[string]*models.LabelDefinition) 218 - for _, l := range labelDefs { 219 - defs[l.AtUri().String()] = &l 200 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 220 201 } 221 202 222 203 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 223 - LoggedInUser: user, 224 - RepoInfo: repoInfo, 225 - Pull: pull, 226 - Stack: stack, 227 - AbandonedPulls: abandonedPulls, 228 - BranchDeleteStatus: branchDeleteStatus, 229 - MergeCheck: mergeCheckResponse, 230 - ResubmitCheck: resubmitResult, 231 - Pipelines: m, 204 + LoggedInUser: user, 205 + RepoInfo: repoInfo, 206 + Pull: pull, 207 + Stack: stack, 208 + AbandonedPulls: abandonedPulls, 209 + MergeCheck: mergeCheckResponse, 210 + ResubmitCheck: resubmitResult, 211 + Pipelines: m, 232 212 233 213 OrderedReactionKinds: models.OrderedReactionKinds, 234 - Reactions: reactionMap, 214 + Reactions: reactionCountMap, 235 215 UserReacted: userReactions, 236 - 237 - LabelDefs: defs, 238 216 }) 239 217 } 240 218 ··· 305 283 return result 306 284 } 307 285 308 - func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 309 - if pull.State != models.PullMerged { 310 - return nil 311 - } 312 - 313 - user := s.oauth.GetUser(r) 314 - if user == nil { 315 - return nil 316 - } 317 - 318 - var branch string 319 - var repo *models.Repo 320 - // check if the branch exists 321 - // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 322 - if pull.IsBranchBased() { 323 - branch = pull.PullSource.Branch 324 - repo = &f.Repo 325 - } else if pull.IsForkBased() { 326 - branch = pull.PullSource.Branch 327 - repo = pull.PullSource.Repo 328 - } else { 329 - return nil 330 - } 331 - 332 - // deleted fork 333 - if repo == nil { 334 - return nil 335 - } 336 - 337 - // user can only delete branch if they are a collaborator in the repo that the branch belongs to 338 - perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 339 - if !slices.Contains(perms, "repo:push") { 340 - return nil 341 - } 342 - 343 - scheme := "http" 344 - if !s.config.Core.Dev { 345 - scheme = "https" 346 - } 347 - host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 348 - xrpcc := &indigoxrpc.Client{ 349 - Host: host, 350 - } 351 - 352 - resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 353 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 354 - return nil 355 - } 356 - 357 - return &models.BranchDeleteStatus{ 358 - Repo: repo, 359 - Branch: resp.Name, 360 - } 361 - } 362 - 363 286 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 364 287 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 365 288 return pages.Unknown ··· 407 330 408 331 targetBranch := branchResp 409 332 410 - latestSourceRev := pull.LatestSha() 333 + latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 411 334 412 335 if pull.IsStacked() && stack != nil { 413 336 top := stack[0] 414 - latestSourceRev = top.LatestSha() 337 + latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 415 338 } 416 339 417 340 if latestSourceRev != targetBranch.Hash { ··· 451 374 return 452 375 } 453 376 454 - patch := pull.Submissions[roundIdInt].CombinedPatch() 377 + patch := pull.Submissions[roundIdInt].Patch 455 378 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 456 379 457 380 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 502 425 return 503 426 } 504 427 505 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 428 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 506 429 if err != nil { 507 430 log.Println("failed to interdiff; current patch malformed") 508 431 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 509 432 return 510 433 } 511 434 512 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 435 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 513 436 if err != nil { 514 437 log.Println("failed to interdiff; previous patch malformed") 515 438 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 549 472 } 550 473 551 474 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 552 - l := s.logger.With("handler", "RepoPulls") 553 - 554 475 user := s.oauth.GetUser(r) 555 476 params := r.URL.Query() 556 477 ··· 568 489 return 569 490 } 570 491 571 - keyword := params.Get("q") 572 - 573 - var ids []int64 574 - searchOpts := models.PullSearchOptions{ 575 - Keyword: keyword, 576 - RepoAt: f.RepoAt().String(), 577 - State: state, 578 - // Page: page, 579 - } 580 - l.Debug("searching with", "searchOpts", searchOpts) 581 - if keyword != "" { 582 - res, err := s.indexer.Search(r.Context(), searchOpts) 583 - if err != nil { 584 - l.Error("failed to search for pulls", "err", err) 585 - return 586 - } 587 - ids = res.Hits 588 - l.Debug("searched pulls with indexer", "count", len(ids)) 589 - } else { 590 - ids, err = db.GetPullIDs(s.db, searchOpts) 591 - if err != nil { 592 - l.Error("failed to get all pull ids", "err", err) 593 - return 594 - } 595 - l.Debug("indexed all pulls from the db", "count", len(ids)) 596 - } 597 - 598 492 pulls, err := db.GetPulls( 599 493 s.db, 600 - db.FilterIn("id", ids), 494 + db.FilterEq("repo_at", f.RepoAt()), 495 + db.FilterEq("state", state), 601 496 ) 602 497 if err != nil { 603 498 log.Println("failed to get pulls", err) ··· 662 557 m[p.Sha] = p 663 558 } 664 559 665 - labelDefs, err := db.GetLabelDefinitions( 666 - s.db, 667 - db.FilterIn("at_uri", f.Repo.Labels), 668 - db.FilterContains("scope", tangled.RepoPullNSID), 669 - ) 670 - if err != nil { 671 - log.Println("failed to fetch labels", err) 672 - s.pages.Error503(w) 673 - return 674 - } 675 - 676 - defs := make(map[string]*models.LabelDefinition) 677 - for _, l := range labelDefs { 678 - defs[l.AtUri().String()] = &l 679 - } 680 - 681 560 s.pages.RepoPulls(w, pages.RepoPullsParams{ 682 561 LoggedInUser: s.oauth.GetUser(r), 683 562 RepoInfo: f.RepoInfo(user), 684 563 Pulls: pulls, 685 - LabelDefs: defs, 686 564 FilteringBy: state, 687 - FilterQuery: keyword, 688 565 Stacks: stacks, 689 566 Pipelines: m, 690 567 }) 691 568 } 692 569 693 570 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 694 - l := s.logger.With("handler", "PullComment") 695 571 user := s.oauth.GetUser(r) 696 572 f, err := s.repoResolver.Resolve(r) 697 573 if err != nil { ··· 741 617 742 618 createdAt := time.Now().Format(time.RFC3339) 743 619 620 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 621 + if err != nil { 622 + log.Println("failed to get pull at", err) 623 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 624 + return 625 + } 626 + 744 627 client, err := s.oauth.AuthorizedClient(r) 745 628 if err != nil { 746 629 log.Println("failed to get authorized client", err) 747 630 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 748 631 return 749 632 } 750 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 633 + atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 751 634 Collection: tangled.RepoPullCommentNSID, 752 635 Repo: user.Did, 753 636 Rkey: tid.TID(), 754 637 Record: &lexutil.LexiconTypeDecoder{ 755 638 Val: &tangled.RepoPullComment{ 756 - Pull: pull.AtUri().String(), 639 + Pull: string(pullAt), 757 640 Body: body, 758 641 CreatedAt: createdAt, 759 642 }, ··· 789 672 return 790 673 } 791 674 792 - rawMentions := markup.FindUserMentions(comment.Body) 793 - idents := s.idResolver.ResolveIdents(r.Context(), rawMentions) 794 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 795 - var mentions []syntax.DID 796 - for _, ident := range idents { 797 - if ident != nil && !ident.Handle.IsInvalidHandle() { 798 - mentions = append(mentions, ident.DID) 799 - } 800 - } 801 - s.notifier.NewPullComment(r.Context(), comment, mentions) 675 + s.notifier.NewPullComment(r.Context(), comment) 802 676 803 677 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 804 678 return ··· 1010 884 } 1011 885 1012 886 sourceRev := comparison.Rev2 1013 - patch := comparison.FormatPatchRaw 1014 - combined := comparison.CombinedPatchRaw 887 + patch := comparison.Patch 1015 888 1016 - if err := s.validator.ValidatePatch(&patch); err != nil { 1017 - s.logger.Error("failed to validate patch", "err", err) 889 + if !patchutil.IsPatchValid(patch) { 1018 890 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1019 891 return 1020 892 } ··· 1027 899 Sha: comparison.Rev2, 1028 900 } 1029 901 1030 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 902 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1031 903 } 1032 904 1033 905 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1034 - if err := s.validator.ValidatePatch(&patch); err != nil { 1035 - s.logger.Error("patch validation failed", "err", err) 906 + if !patchutil.IsPatchValid(patch) { 1036 907 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1037 908 return 1038 909 } 1039 910 1040 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 911 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 1041 912 } 1042 913 1043 914 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { ··· 1120 991 } 1121 992 1122 993 sourceRev := comparison.Rev2 1123 - patch := comparison.FormatPatchRaw 1124 - combined := comparison.CombinedPatchRaw 994 + patch := comparison.Patch 1125 995 1126 - if err := s.validator.ValidatePatch(&patch); err != nil { 1127 - s.logger.Error("failed to validate patch", "err", err) 996 + if !patchutil.IsPatchValid(patch) { 1128 997 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1129 998 return 1130 999 } ··· 1142 1011 Sha: sourceRev, 1143 1012 } 1144 1013 1145 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1014 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1146 1015 } 1147 1016 1148 1017 func (s *Pulls) createPullRequest( ··· 1152 1021 user *oauth.User, 1153 1022 title, body, targetBranch string, 1154 1023 patch string, 1155 - combined string, 1156 1024 sourceRev string, 1157 1025 pullSource *models.PullSource, 1158 1026 recordPullSource *tangled.RepoPull_Source, ··· 1190 1058 1191 1059 // We've already checked earlier if it's diff-based and title is empty, 1192 1060 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1193 - if title == "" || body == "" { 1061 + if title == "" { 1194 1062 formatPatches, err := patchutil.ExtractPatches(patch) 1195 1063 if err != nil { 1196 1064 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1201 1069 return 1202 1070 } 1203 1071 1204 - if title == "" { 1205 - title = formatPatches[0].Title 1206 - } 1207 - if body == "" { 1208 - body = formatPatches[0].Body 1209 - } 1072 + title = formatPatches[0].Title 1073 + body = formatPatches[0].Body 1210 1074 } 1211 1075 1212 1076 rkey := tid.TID() 1213 1077 initialSubmission := models.PullSubmission{ 1214 1078 Patch: patch, 1215 - Combined: combined, 1216 1079 SourceRev: sourceRev, 1217 1080 } 1218 1081 pull := &models.Pull{ ··· 1240 1103 return 1241 1104 } 1242 1105 1243 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1106 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1244 1107 Collection: tangled.RepoPullNSID, 1245 1108 Repo: user.Did, 1246 1109 Rkey: rkey, ··· 1251 1114 Repo: string(f.RepoAt()), 1252 1115 Branch: targetBranch, 1253 1116 }, 1254 - Patch: patch, 1255 - Source: recordPullSource, 1256 - CreatedAt: time.Now().Format(time.RFC3339), 1117 + Patch: patch, 1118 + Source: recordPullSource, 1257 1119 }, 1258 1120 }, 1259 1121 }) ··· 1338 1200 } 1339 1201 writes = append(writes, &write) 1340 1202 } 1341 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1203 + _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1342 1204 Repo: user.Did, 1343 1205 Writes: writes, 1344 1206 }) ··· 1388 1250 return 1389 1251 } 1390 1252 1391 - if err := s.validator.ValidatePatch(&patch); err != nil { 1392 - s.logger.Error("faield to validate patch", "err", err) 1253 + if patch == "" || !patchutil.IsPatchValid(patch) { 1393 1254 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1394 1255 return 1395 1256 } ··· 1643 1504 1644 1505 patch := r.FormValue("patch") 1645 1506 1646 - s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1507 + s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1647 1508 } 1648 1509 1649 1510 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1704 1565 } 1705 1566 1706 1567 sourceRev := comparison.Rev2 1707 - patch := comparison.FormatPatchRaw 1708 - combined := comparison.CombinedPatchRaw 1568 + patch := comparison.Patch 1709 1569 1710 - s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1570 + s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1711 1571 } 1712 1572 1713 1573 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1739 1599 return 1740 1600 } 1741 1601 1602 + // extract patch by performing compare 1603 + forkScheme := "http" 1604 + if !s.config.Core.Dev { 1605 + forkScheme = "https" 1606 + } 1607 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1608 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1609 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1610 + if err != nil { 1611 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1612 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1613 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1614 + return 1615 + } 1616 + log.Printf("failed to compare branches: %s", err) 1617 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1618 + return 1619 + } 1620 + 1621 + var forkComparison types.RepoFormatPatchResponse 1622 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1623 + log.Println("failed to decode XRPC compare response for fork", err) 1624 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1625 + return 1626 + } 1627 + 1742 1628 // update the hidden tracking branch to latest 1743 1629 client, err := s.oauth.ServiceClient( 1744 1630 r, ··· 1770 1656 return 1771 1657 } 1772 1658 1773 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1774 - // extract patch by performing compare 1775 - forkScheme := "http" 1776 - if !s.config.Core.Dev { 1777 - forkScheme = "https" 1778 - } 1779 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1780 - forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1781 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1782 - if err != nil { 1783 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1784 - log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1785 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1786 - return 1787 - } 1788 - log.Printf("failed to compare branches: %s", err) 1789 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1790 - return 1791 - } 1792 - 1793 - var forkComparison types.RepoFormatPatchResponse 1794 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1795 - log.Println("failed to decode XRPC compare response for fork", err) 1796 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1797 - return 1798 - } 1799 - 1800 1659 // Use the fork comparison we already made 1801 1660 comparison := forkComparison 1802 1661 1803 1662 sourceRev := comparison.Rev2 1804 - patch := comparison.FormatPatchRaw 1805 - combined := comparison.CombinedPatchRaw 1663 + patch := comparison.Patch 1664 + 1665 + s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1666 + } 1806 1667 1807 - s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1668 + // validate a resubmission against a pull request 1669 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1670 + if patch == "" { 1671 + return fmt.Errorf("Patch is empty.") 1672 + } 1673 + 1674 + if patch == pull.LatestPatch() { 1675 + return fmt.Errorf("Patch is identical to previous submission.") 1676 + } 1677 + 1678 + if !patchutil.IsPatchValid(patch) { 1679 + return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1680 + } 1681 + 1682 + return nil 1808 1683 } 1809 1684 1810 1685 func (s *Pulls) resubmitPullHelper( ··· 1814 1689 user *oauth.User, 1815 1690 pull *models.Pull, 1816 1691 patch string, 1817 - combined string, 1818 1692 sourceRev string, 1819 1693 ) { 1820 1694 if pull.IsStacked() { ··· 1823 1697 return 1824 1698 } 1825 1699 1826 - if err := s.validator.ValidatePatch(&patch); err != nil { 1700 + if err := validateResubmittedPatch(pull, patch); err != nil { 1827 1701 s.pages.Notice(w, "resubmit-error", err.Error()) 1828 - return 1829 - } 1830 - 1831 - if patch == pull.LatestPatch() { 1832 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1833 1702 return 1834 1703 } 1835 1704 1836 1705 // validate sourceRev if branch/fork based 1837 1706 if pull.IsBranchBased() || pull.IsForkBased() { 1838 - if sourceRev == pull.LatestSha() { 1707 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1839 1708 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1840 1709 return 1841 1710 } ··· 1849 1718 } 1850 1719 defer tx.Rollback() 1851 1720 1852 - pullAt := pull.AtUri() 1853 - newRoundNumber := len(pull.Submissions) 1854 - newPatch := patch 1855 - newSourceRev := sourceRev 1856 - combinedPatch := combined 1857 - err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1721 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1858 1722 if err != nil { 1859 1723 log.Println("failed to create pull request", err) 1860 1724 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1867 1731 return 1868 1732 } 1869 1733 1870 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1734 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1871 1735 if err != nil { 1872 1736 // failed to get record 1873 1737 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1890 1754 } 1891 1755 } 1892 1756 1893 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1757 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1894 1758 Collection: tangled.RepoPullNSID, 1895 1759 Repo: user.Did, 1896 1760 Rkey: pull.Rkey, ··· 1902 1766 Repo: string(f.RepoAt()), 1903 1767 Branch: pull.TargetBranch, 1904 1768 }, 1905 - Patch: patch, // new patch 1906 - Source: recordPullSource, 1907 - CreatedAt: time.Now().Format(time.RFC3339), 1769 + Patch: patch, // new patch 1770 + Source: recordPullSource, 1908 1771 }, 1909 1772 }, 1910 1773 }) ··· 1955 1818 // commits that got deleted: corresponding pull is closed 1956 1819 // commits that got added: new pull is created 1957 1820 // commits that got updated: corresponding pull is resubmitted & new round begins 1821 + // 1822 + // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1958 1823 additions := make(map[string]*models.Pull) 1959 1824 deletions := make(map[string]*models.Pull) 1825 + unchanged := make(map[string]struct{}) 1960 1826 updated := make(map[string]struct{}) 1961 1827 1962 1828 // pulls in orignal stack but not in new one ··· 1978 1844 for _, np := range newStack { 1979 1845 if op, ok := origById[np.ChangeId]; ok { 1980 1846 // pull exists in both stacks 1981 - updated[op.ChangeId] = struct{}{} 1847 + // TODO: can we avoid reparse? 1848 + origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1849 + newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1850 + 1851 + origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1852 + newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1853 + 1854 + patchutil.SortPatch(newFiles) 1855 + patchutil.SortPatch(origFiles) 1856 + 1857 + // text content of patch may be identical, but a jj rebase might have forwarded it 1858 + // 1859 + // we still need to update the hash in submission.Patch and submission.SourceRev 1860 + if patchutil.Equal(newFiles, origFiles) && 1861 + origHeader.Title == newHeader.Title && 1862 + origHeader.Body == newHeader.Body { 1863 + unchanged[op.ChangeId] = struct{}{} 1864 + } else { 1865 + updated[op.ChangeId] = struct{}{} 1866 + } 1982 1867 } 1983 1868 } 1984 1869 ··· 2045 1930 continue 2046 1931 } 2047 1932 2048 - // resubmit the new pull 2049 - pullAt := op.AtUri() 2050 - newRoundNumber := len(op.Submissions) 2051 - newPatch := np.LatestPatch() 2052 - combinedPatch := np.LatestSubmission().Combined 2053 - newSourceRev := np.LatestSha() 2054 - err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1933 + submission := np.Submissions[np.LastRoundNumber()] 1934 + 1935 + // resubmit the old pull 1936 + err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 1937 + 1938 + if err != nil { 1939 + log.Println("failed to update pull", err, op.PullId) 1940 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1941 + return 1942 + } 1943 + 1944 + record := op.AsRecord() 1945 + record.Patch = submission.Patch 1946 + 1947 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1948 + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1949 + Collection: tangled.RepoPullNSID, 1950 + Rkey: op.Rkey, 1951 + Value: &lexutil.LexiconTypeDecoder{ 1952 + Val: &record, 1953 + }, 1954 + }, 1955 + }) 1956 + } 1957 + 1958 + // unchanged pulls are edited without starting a new round 1959 + // 1960 + // update source-revs & patches without advancing rounds 1961 + for changeId := range unchanged { 1962 + op, _ := origById[changeId] 1963 + np, _ := newById[changeId] 1964 + 1965 + origSubmission := op.Submissions[op.LastRoundNumber()] 1966 + newSubmission := np.Submissions[np.LastRoundNumber()] 1967 + 1968 + log.Println("moving unchanged change id : ", changeId) 1969 + 1970 + err := db.UpdatePull( 1971 + tx, 1972 + newSubmission.Patch, 1973 + newSubmission.SourceRev, 1974 + db.FilterEq("id", origSubmission.ID), 1975 + ) 1976 + 2055 1977 if err != nil { 2056 1978 log.Println("failed to update pull", err, op.PullId) 2057 1979 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2058 1980 return 2059 1981 } 2060 1982 2061 - record := np.AsRecord() 1983 + record := op.AsRecord() 1984 + record.Patch = newSubmission.Patch 2062 1985 2063 1986 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2064 1987 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2103 2026 return 2104 2027 } 2105 2028 2106 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2029 + _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2107 2030 Repo: user.Did, 2108 2031 Writes: writes, 2109 2032 }) ··· 2117 2040 } 2118 2041 2119 2042 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2120 - user := s.oauth.GetUser(r) 2121 2043 f, err := s.repoResolver.Resolve(r) 2122 2044 if err != nil { 2123 2045 log.Println("failed to resolve repo:", err) ··· 2215 2137 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2216 2138 return 2217 2139 } 2218 - p.State = models.PullMerged 2219 2140 } 2220 2141 2221 2142 err = tx.Commit() ··· 2228 2149 2229 2150 // notify about the pull merge 2230 2151 for _, p := range pullsToMerge { 2231 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2152 + s.notifier.NewPullMerged(r.Context(), p) 2232 2153 } 2233 2154 2234 2155 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) ··· 2289 2210 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2290 2211 return 2291 2212 } 2292 - p.State = models.PullClosed 2293 2213 } 2294 2214 2295 2215 // Commit the transaction ··· 2300 2220 } 2301 2221 2302 2222 for _, p := range pullsToClose { 2303 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2223 + s.notifier.NewPullClosed(r.Context(), p) 2304 2224 } 2305 2225 2306 2226 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2362 2282 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2363 2283 return 2364 2284 } 2365 - p.State = models.PullOpen 2366 2285 } 2367 2286 2368 2287 // Commit the transaction ··· 2370 2289 log.Println("failed to commit transaction", err) 2371 2290 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2372 2291 return 2373 - } 2374 - 2375 - for _, p := range pullsToReopen { 2376 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2377 2292 } 2378 2293 2379 2294 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2407 2322 initialSubmission := models.PullSubmission{ 2408 2323 Patch: fp.Raw, 2409 2324 SourceRev: fp.SHA, 2410 - Combined: fp.Raw, 2411 2325 } 2412 2326 pull := models.Pull{ 2413 2327 Title: title,
-1
appview/pulls/router.go
··· 23 23 r.Route("/{pull}", func(r chi.Router) { 24 24 r.Use(mw.ResolvePull()) 25 25 r.Get("/", s.RepoSinglePull) 26 - r.Get("/opengraph", s.PullOpenGraphSummary) 27 26 28 27 r.Route("/round/{round}", func(r chi.Router) { 29 28 r.Get("/", s.RepoPullPatch)
-49
appview/repo/archive.go
··· 1 - package repo 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "net/url" 7 - "strings" 8 - 9 - "tangled.org/core/api/tangled" 10 - xrpcclient "tangled.org/core/appview/xrpcclient" 11 - 12 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - "github.com/go-chi/chi/v5" 14 - "github.com/go-git/go-git/v5/plumbing" 15 - ) 16 - 17 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 - l := rp.logger.With("handler", "DownloadArchive") 19 - ref := chi.URLParam(r, "ref") 20 - ref, _ = url.PathUnescape(ref) 21 - f, err := rp.repoResolver.Resolve(r) 22 - if err != nil { 23 - l.Error("failed to get repo and knot", "err", err) 24 - return 25 - } 26 - scheme := "http" 27 - if !rp.config.Core.Dev { 28 - scheme = "https" 29 - } 30 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 - xrpcc := &indigoxrpc.Client{ 32 - Host: host, 33 - } 34 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 36 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 - rp.pages.Error503(w) 39 - return 40 - } 41 - // Set headers for file download, just pass along whatever the knot specifies 42 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 - w.Header().Set("Content-Type", "application/gzip") 46 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 - // Write the archive data directly 48 - w.Write(archiveBytes) 49 - }
+10 -11
appview/repo/artifact.go
··· 10 10 "net/url" 11 11 "time" 12 12 13 + comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + lexutil "github.com/bluesky-social/indigo/lex/util" 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/dustin/go-humanize" 17 + "github.com/go-chi/chi/v5" 18 + "github.com/go-git/go-git/v5/plumbing" 19 + "github.com/ipfs/go-cid" 13 20 "tangled.org/core/api/tangled" 14 21 "tangled.org/core/appview/db" 15 22 "tangled.org/core/appview/models" ··· 18 25 "tangled.org/core/appview/xrpcclient" 19 26 "tangled.org/core/tid" 20 27 "tangled.org/core/types" 21 - 22 - comatproto "github.com/bluesky-social/indigo/api/atproto" 23 - lexutil "github.com/bluesky-social/indigo/lex/util" 24 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 - "github.com/dustin/go-humanize" 26 - "github.com/go-chi/chi/v5" 27 - "github.com/go-git/go-git/v5/plumbing" 28 - "github.com/ipfs/go-cid" 29 28 ) 30 29 31 30 // TODO: proper statuses here on early exit ··· 61 60 return 62 61 } 63 62 64 - uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 63 + uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 65 64 if err != nil { 66 65 log.Println("failed to upload blob", err) 67 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 72 rkey := tid.TID() 74 73 createdAt := time.Now() 75 74 76 - putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 75 + putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 77 76 Collection: tangled.RepoArtifactNSID, 78 77 Repo: user.Did, 79 78 Rkey: rkey, ··· 250 249 return 251 250 } 252 251 253 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 252 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 254 253 Collection: tangled.RepoArtifactNSID, 255 254 Repo: user.Did, 256 255 Rkey: artifact.Rkey,
-291
appview/repo/blob.go
··· 1 - package repo 2 - 3 - import ( 4 - "encoding/base64" 5 - "fmt" 6 - "io" 7 - "net/http" 8 - "net/url" 9 - "path/filepath" 10 - "slices" 11 - "strings" 12 - 13 - "tangled.org/core/api/tangled" 14 - "tangled.org/core/appview/config" 15 - "tangled.org/core/appview/models" 16 - "tangled.org/core/appview/pages" 17 - "tangled.org/core/appview/pages/markup" 18 - "tangled.org/core/appview/reporesolver" 19 - xrpcclient "tangled.org/core/appview/xrpcclient" 20 - 21 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 - "github.com/go-chi/chi/v5" 23 - ) 24 - 25 - // the content can be one of the following: 26 - // 27 - // - code : text | | raw 28 - // - markup : text | rendered | raw 29 - // - svg : text | rendered | raw 30 - // - png : | rendered | raw 31 - // - video : | rendered | raw 32 - // - submodule : | rendered | 33 - // - rest : | | 34 - func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 35 - l := rp.logger.With("handler", "RepoBlob") 36 - 37 - f, err := rp.repoResolver.Resolve(r) 38 - if err != nil { 39 - l.Error("failed to get repo and knot", "err", err) 40 - return 41 - } 42 - 43 - ref := chi.URLParam(r, "ref") 44 - ref, _ = url.PathUnescape(ref) 45 - 46 - filePath := chi.URLParam(r, "*") 47 - filePath, _ = url.PathUnescape(filePath) 48 - 49 - scheme := "http" 50 - if !rp.config.Core.Dev { 51 - scheme = "https" 52 - } 53 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 54 - xrpcc := &indigoxrpc.Client{ 55 - Host: host, 56 - } 57 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 58 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 61 - rp.pages.Error503(w) 62 - return 63 - } 64 - 65 - // Use XRPC response directly instead of converting to internal types 66 - var breadcrumbs [][]string 67 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 68 - if filePath != "" { 69 - for idx, elem := range strings.Split(filePath, "/") { 70 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 71 - } 72 - } 73 - 74 - // Create the blob view 75 - blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 76 - 77 - user := rp.oauth.GetUser(r) 78 - 79 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 80 - LoggedInUser: user, 81 - RepoInfo: f.RepoInfo(user), 82 - BreadCrumbs: breadcrumbs, 83 - BlobView: blobView, 84 - RepoBlob_Output: resp, 85 - }) 86 - } 87 - 88 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 89 - l := rp.logger.With("handler", "RepoBlobRaw") 90 - 91 - f, err := rp.repoResolver.Resolve(r) 92 - if err != nil { 93 - l.Error("failed to get repo and knot", "err", err) 94 - w.WriteHeader(http.StatusBadRequest) 95 - return 96 - } 97 - 98 - ref := chi.URLParam(r, "ref") 99 - ref, _ = url.PathUnescape(ref) 100 - 101 - filePath := chi.URLParam(r, "*") 102 - filePath, _ = url.PathUnescape(filePath) 103 - 104 - scheme := "http" 105 - if !rp.config.Core.Dev { 106 - scheme = "https" 107 - } 108 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 109 - baseURL := &url.URL{ 110 - Scheme: scheme, 111 - Host: f.Knot, 112 - Path: "/xrpc/sh.tangled.repo.blob", 113 - } 114 - query := baseURL.Query() 115 - query.Set("repo", repo) 116 - query.Set("ref", ref) 117 - query.Set("path", filePath) 118 - query.Set("raw", "true") 119 - baseURL.RawQuery = query.Encode() 120 - blobURL := baseURL.String() 121 - req, err := http.NewRequest("GET", blobURL, nil) 122 - if err != nil { 123 - l.Error("failed to create request", "err", err) 124 - return 125 - } 126 - 127 - // forward the If-None-Match header 128 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 129 - req.Header.Set("If-None-Match", clientETag) 130 - } 131 - client := &http.Client{} 132 - 133 - resp, err := client.Do(req) 134 - if err != nil { 135 - l.Error("failed to reach knotserver", "err", err) 136 - rp.pages.Error503(w) 137 - return 138 - } 139 - 140 - defer resp.Body.Close() 141 - 142 - // forward 304 not modified 143 - if resp.StatusCode == http.StatusNotModified { 144 - w.WriteHeader(http.StatusNotModified) 145 - return 146 - } 147 - 148 - if resp.StatusCode != http.StatusOK { 149 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 150 - w.WriteHeader(resp.StatusCode) 151 - _, _ = io.Copy(w, resp.Body) 152 - return 153 - } 154 - 155 - contentType := resp.Header.Get("Content-Type") 156 - body, err := io.ReadAll(resp.Body) 157 - if err != nil { 158 - l.Error("error reading response body from knotserver", "err", err) 159 - w.WriteHeader(http.StatusInternalServerError) 160 - return 161 - } 162 - 163 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 164 - // serve all textual content as text/plain 165 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 166 - w.Write(body) 167 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 168 - // serve images and videos with their original content type 169 - w.Header().Set("Content-Type", contentType) 170 - w.Write(body) 171 - } else { 172 - w.WriteHeader(http.StatusUnsupportedMediaType) 173 - w.Write([]byte("unsupported content type")) 174 - return 175 - } 176 - } 177 - 178 - // NewBlobView creates a BlobView from the XRPC response 179 - func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { 180 - view := models.BlobView{ 181 - Contents: "", 182 - Lines: 0, 183 - } 184 - 185 - // Set size 186 - if resp.Size != nil { 187 - view.SizeHint = uint64(*resp.Size) 188 - } else if resp.Content != nil { 189 - view.SizeHint = uint64(len(*resp.Content)) 190 - } 191 - 192 - if resp.Submodule != nil { 193 - view.ContentType = models.BlobContentTypeSubmodule 194 - view.HasRenderedView = true 195 - view.ContentSrc = resp.Submodule.Url 196 - return view 197 - } 198 - 199 - // Determine if binary 200 - if resp.IsBinary != nil && *resp.IsBinary { 201 - view.ContentSrc = generateBlobURL(config, f, ref, filePath) 202 - ext := strings.ToLower(filepath.Ext(resp.Path)) 203 - 204 - switch ext { 205 - case ".jpg", ".jpeg", ".png", ".gif", ".webp": 206 - view.ContentType = models.BlobContentTypeImage 207 - view.HasRawView = true 208 - view.HasRenderedView = true 209 - view.ShowingRendered = true 210 - 211 - case ".svg": 212 - view.ContentType = models.BlobContentTypeSvg 213 - view.HasRawView = true 214 - view.HasTextView = true 215 - view.HasRenderedView = true 216 - view.ShowingRendered = queryParams.Get("code") != "true" 217 - if resp.Content != nil { 218 - bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 219 - view.Contents = string(bytes) 220 - view.Lines = strings.Count(view.Contents, "\n") + 1 221 - } 222 - 223 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 224 - view.ContentType = models.BlobContentTypeVideo 225 - view.HasRawView = true 226 - view.HasRenderedView = true 227 - view.ShowingRendered = true 228 - } 229 - 230 - return view 231 - } 232 - 233 - // otherwise, we are dealing with text content 234 - view.HasRawView = true 235 - view.HasTextView = true 236 - 237 - if resp.Content != nil { 238 - view.Contents = *resp.Content 239 - view.Lines = strings.Count(view.Contents, "\n") + 1 240 - } 241 - 242 - // with text, we may be dealing with markdown 243 - format := markup.GetFormat(resp.Path) 244 - if format == markup.FormatMarkdown { 245 - view.ContentType = models.BlobContentTypeMarkup 246 - view.HasRenderedView = true 247 - view.ShowingRendered = queryParams.Get("code") != "true" 248 - } 249 - 250 - return view 251 - } 252 - 253 - func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string { 254 - scheme := "http" 255 - if !config.Core.Dev { 256 - scheme = "https" 257 - } 258 - 259 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 260 - baseURL := &url.URL{ 261 - Scheme: scheme, 262 - Host: f.Knot, 263 - Path: "/xrpc/sh.tangled.repo.blob", 264 - } 265 - query := baseURL.Query() 266 - query.Set("repo", repoName) 267 - query.Set("ref", ref) 268 - query.Set("path", filePath) 269 - query.Set("raw", "true") 270 - baseURL.RawQuery = query.Encode() 271 - blobURL := baseURL.String() 272 - 273 - if !config.Core.Dev { 274 - return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 275 - } 276 - return blobURL 277 - } 278 - 279 - func isTextualMimeType(mimeType string) bool { 280 - textualTypes := []string{ 281 - "application/json", 282 - "application/xml", 283 - "application/yaml", 284 - "application/x-yaml", 285 - "application/toml", 286 - "application/javascript", 287 - "application/ecmascript", 288 - "message/", 289 - } 290 - return slices.Contains(textualTypes, mimeType) 291 - }
-95
appview/repo/branches.go
··· 1 - package repo 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/appview/oauth" 10 - "tangled.org/core/appview/pages" 11 - xrpcclient "tangled.org/core/appview/xrpcclient" 12 - "tangled.org/core/types" 13 - 14 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 - ) 16 - 17 - func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) { 18 - l := rp.logger.With("handler", "RepoBranches") 19 - f, err := rp.repoResolver.Resolve(r) 20 - if err != nil { 21 - l.Error("failed to get repo and knot", "err", err) 22 - return 23 - } 24 - scheme := "http" 25 - if !rp.config.Core.Dev { 26 - scheme = "https" 27 - } 28 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 29 - xrpcc := &indigoxrpc.Client{ 30 - Host: host, 31 - } 32 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 33 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 36 - rp.pages.Error503(w) 37 - return 38 - } 39 - var result types.RepoBranchesResponse 40 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 41 - l.Error("failed to decode XRPC response", "err", err) 42 - rp.pages.Error503(w) 43 - return 44 - } 45 - sortBranches(result.Branches) 46 - user := rp.oauth.GetUser(r) 47 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 - LoggedInUser: user, 49 - RepoInfo: f.RepoInfo(user), 50 - RepoBranchesResponse: result, 51 - }) 52 - } 53 - 54 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 55 - l := rp.logger.With("handler", "DeleteBranch") 56 - f, err := rp.repoResolver.Resolve(r) 57 - if err != nil { 58 - l.Error("failed to get repo and knot", "err", err) 59 - return 60 - } 61 - noticeId := "delete-branch-error" 62 - fail := func(msg string, err error) { 63 - l.Error(msg, "err", err) 64 - rp.pages.Notice(w, noticeId, msg) 65 - } 66 - branch := r.FormValue("branch") 67 - if branch == "" { 68 - fail("No branch provided.", nil) 69 - return 70 - } 71 - client, err := rp.oauth.ServiceClient( 72 - r, 73 - oauth.WithService(f.Knot), 74 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 75 - oauth.WithDev(rp.config.Core.Dev), 76 - ) 77 - if err != nil { 78 - fail("Failed to connect to knotserver", nil) 79 - return 80 - } 81 - err = tangled.RepoDeleteBranch( 82 - r.Context(), 83 - client, 84 - &tangled.RepoDeleteBranch_Input{ 85 - Branch: branch, 86 - Repo: f.RepoAt().String(), 87 - }, 88 - ) 89 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 90 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 91 - return 92 - } 93 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 94 - rp.pages.HxRefresh(w) 95 - }
-214
appview/repo/compare.go
··· 1 - package repo 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - "strings" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/appview/pages" 12 - xrpcclient "tangled.org/core/appview/xrpcclient" 13 - "tangled.org/core/patchutil" 14 - "tangled.org/core/types" 15 - 16 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 - "github.com/go-chi/chi/v5" 18 - ) 19 - 20 - func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 - l := rp.logger.With("handler", "RepoCompareNew") 22 - 23 - user := rp.oauth.GetUser(r) 24 - f, err := rp.repoResolver.Resolve(r) 25 - if err != nil { 26 - l.Error("failed to get repo and knot", "err", err) 27 - return 28 - } 29 - 30 - scheme := "http" 31 - if !rp.config.Core.Dev { 32 - scheme = "https" 33 - } 34 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 - xrpcc := &indigoxrpc.Client{ 36 - Host: host, 37 - } 38 - 39 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 40 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 41 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 42 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 43 - rp.pages.Error503(w) 44 - return 45 - } 46 - 47 - var branchResult types.RepoBranchesResponse 48 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 49 - l.Error("failed to decode XRPC branches response", "err", err) 50 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 51 - return 52 - } 53 - branches := branchResult.Branches 54 - 55 - sortBranches(branches) 56 - 57 - var defaultBranch string 58 - for _, b := range branches { 59 - if b.IsDefault { 60 - defaultBranch = b.Name 61 - } 62 - } 63 - 64 - base := defaultBranch 65 - head := defaultBranch 66 - 67 - params := r.URL.Query() 68 - queryBase := params.Get("base") 69 - queryHead := params.Get("head") 70 - if queryBase != "" { 71 - base = queryBase 72 - } 73 - if queryHead != "" { 74 - head = queryHead 75 - } 76 - 77 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 78 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 79 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 80 - rp.pages.Error503(w) 81 - return 82 - } 83 - 84 - var tags types.RepoTagsResponse 85 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 86 - l.Error("failed to decode XRPC tags response", "err", err) 87 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 88 - return 89 - } 90 - 91 - repoinfo := f.RepoInfo(user) 92 - 93 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 94 - LoggedInUser: user, 95 - RepoInfo: repoinfo, 96 - Branches: branches, 97 - Tags: tags.Tags, 98 - Base: base, 99 - Head: head, 100 - }) 101 - } 102 - 103 - func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 104 - l := rp.logger.With("handler", "RepoCompare") 105 - 106 - user := rp.oauth.GetUser(r) 107 - f, err := rp.repoResolver.Resolve(r) 108 - if err != nil { 109 - l.Error("failed to get repo and knot", "err", err) 110 - return 111 - } 112 - 113 - var diffOpts types.DiffOpts 114 - if d := r.URL.Query().Get("diff"); d == "split" { 115 - diffOpts.Split = true 116 - } 117 - 118 - // if user is navigating to one of 119 - // /compare/{base}/{head} 120 - // /compare/{base}...{head} 121 - base := chi.URLParam(r, "base") 122 - head := chi.URLParam(r, "head") 123 - if base == "" && head == "" { 124 - rest := chi.URLParam(r, "*") // master...feature/xyz 125 - parts := strings.SplitN(rest, "...", 2) 126 - if len(parts) == 2 { 127 - base = parts[0] 128 - head = parts[1] 129 - } 130 - } 131 - 132 - base, _ = url.PathUnescape(base) 133 - head, _ = url.PathUnescape(head) 134 - 135 - if base == "" || head == "" { 136 - l.Error("invalid comparison") 137 - rp.pages.Error404(w) 138 - return 139 - } 140 - 141 - scheme := "http" 142 - if !rp.config.Core.Dev { 143 - scheme = "https" 144 - } 145 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 146 - xrpcc := &indigoxrpc.Client{ 147 - Host: host, 148 - } 149 - 150 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 151 - 152 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 153 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 154 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 155 - rp.pages.Error503(w) 156 - return 157 - } 158 - 159 - var branches types.RepoBranchesResponse 160 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 161 - l.Error("failed to decode XRPC branches response", "err", err) 162 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 163 - return 164 - } 165 - 166 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 167 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 169 - rp.pages.Error503(w) 170 - return 171 - } 172 - 173 - var tags types.RepoTagsResponse 174 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 175 - l.Error("failed to decode XRPC tags response", "err", err) 176 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 177 - return 178 - } 179 - 180 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 181 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 183 - rp.pages.Error503(w) 184 - return 185 - } 186 - 187 - var formatPatch types.RepoFormatPatchResponse 188 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 189 - l.Error("failed to decode XRPC compare response", "err", err) 190 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 191 - return 192 - } 193 - 194 - var diff types.NiceDiff 195 - if formatPatch.CombinedPatchRaw != "" { 196 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 197 - } else { 198 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 199 - } 200 - 201 - repoinfo := f.RepoInfo(user) 202 - 203 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 204 - LoggedInUser: user, 205 - RepoInfo: repoinfo, 206 - Branches: branches.Branches, 207 - Tags: tags.Tags, 208 - Base: base, 209 - Head: head, 210 - Diff: &diff, 211 - DiffOpts: diffOpts, 212 - }) 213 - 214 - }
+1 -1
appview/repo/feed.go
··· 146 146 return fmt.Sprintf("%s in %s", base, repoName) 147 147 } 148 148 149 - func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 149 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 150 150 f, err := rp.repoResolver.Resolve(r) 151 151 if err != nil { 152 152 log.Println("failed to fully resolve repo:", err)
+23 -36
appview/repo/index.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log/slog" 6 + "log" 7 7 "net/http" 8 8 "net/url" 9 9 "slices" ··· 30 30 "github.com/go-enry/go-enry/v2" 31 31 ) 32 32 33 - func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 - l := rp.logger.With("handler", "RepoIndex") 35 - 33 + func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 36 34 ref := chi.URLParam(r, "ref") 37 35 ref, _ = url.PathUnescape(ref) 38 36 39 37 f, err := rp.repoResolver.Resolve(r) 40 38 if err != nil { 41 - l.Error("failed to fully resolve repo", "err", err) 39 + log.Println("failed to fully resolve repo", err) 42 40 return 43 41 } 44 42 ··· 58 56 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 59 57 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 58 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 61 - l.Error("failed to call XRPC repo.index", "err", err) 59 + log.Println("failed to call XRPC repo.index", err) 62 60 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 61 LoggedInUser: user, 64 62 NeedsKnotUpgrade: true, ··· 68 66 } 69 67 70 68 rp.pages.Error503(w) 71 - l.Error("failed to build index response", "err", err) 69 + log.Println("failed to build index response", err) 72 70 return 73 71 } 74 72 ··· 121 119 emails := uniqueEmails(commitsTrunc) 122 120 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 123 121 if err != nil { 124 - l.Error("failed to get email to did map", "err", err) 122 + log.Println("failed to get email to did map", err) 125 123 } 126 124 127 125 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 128 126 if err != nil { 129 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 127 + log.Println(err) 130 128 } 131 129 132 130 // TODO: a bit dirty 133 - languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 131 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 134 132 if err != nil { 135 - l.Warn("failed to compute language percentages", "err", err) 133 + log.Printf("failed to compute language percentages: %s", err) 136 134 // non-fatal 137 135 } 138 136 ··· 142 140 } 143 141 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 144 142 if err != nil { 145 - l.Error("failed to fetch pipeline statuses", "err", err) 143 + log.Printf("failed to fetch pipeline statuses: %s", err) 146 144 // non-fatal 147 145 } 148 146 ··· 154 152 CommitsTrunc: commitsTrunc, 155 153 TagsTrunc: tagsTrunc, 156 154 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 157 - BranchesTrunc: branchesTrunc, 158 - EmailToDid: emailToDidMap, 159 - VerifiedCommits: vc, 160 - Languages: languageInfo, 161 - Pipelines: pipelines, 155 + BranchesTrunc: branchesTrunc, 156 + EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 157 + VerifiedCommits: vc, 158 + Languages: languageInfo, 159 + Pipelines: pipelines, 162 160 }) 163 161 } 164 162 165 163 func (rp *Repo) getLanguageInfo( 166 164 ctx context.Context, 167 - l *slog.Logger, 168 165 f *reporesolver.ResolvedRepo, 169 166 xrpcc *indigoxrpc.Client, 170 167 currentRef string, ··· 183 180 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 184 181 if err != nil { 185 182 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 - l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 183 + log.Println("failed to call XRPC repo.languages", xrpcerr) 187 184 return nil, xrpcerr 188 185 } 189 186 return nil, err ··· 203 200 }) 204 201 } 205 202 206 - tx, err := rp.db.Begin() 207 - if err != nil { 208 - return nil, err 209 - } 210 - defer tx.Rollback() 211 - 212 203 // update appview's cache 213 - err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 204 + err = db.InsertRepoLanguages(rp.db, langs) 214 205 if err != nil { 215 206 // non-fatal 216 - l.Error("failed to cache lang results", "err", err) 217 - } 218 - 219 - err = tx.Commit() 220 - if err != nil { 221 - return nil, err 207 + log.Println("failed to cache lang results", err) 222 208 } 223 209 } 224 210 ··· 351 337 if treeResp != nil && treeResp.Files != nil { 352 338 for _, file := range treeResp.Files { 353 339 niceFile := types.NiceTree{ 354 - Name: file.Name, 355 - Mode: file.Mode, 356 - Size: file.Size, 340 + IsFile: file.Is_file, 341 + IsSubtree: file.Is_subtree, 342 + Name: file.Name, 343 + Mode: file.Mode, 344 + Size: file.Size, 357 345 } 358 - 359 346 if file.Last_commit != nil { 360 347 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 361 348 niceFile.LastCommit = &types.LastCommitInfo{
-223
appview/repo/log.go
··· 1 - package repo 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - "strconv" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/appview/commitverify" 12 - "tangled.org/core/appview/db" 13 - "tangled.org/core/appview/models" 14 - "tangled.org/core/appview/pages" 15 - xrpcclient "tangled.org/core/appview/xrpcclient" 16 - "tangled.org/core/types" 17 - 18 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 - "github.com/go-chi/chi/v5" 20 - "github.com/go-git/go-git/v5/plumbing" 21 - ) 22 - 23 - func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { 24 - l := rp.logger.With("handler", "RepoLog") 25 - 26 - f, err := rp.repoResolver.Resolve(r) 27 - if err != nil { 28 - l.Error("failed to fully resolve repo", "err", err) 29 - return 30 - } 31 - 32 - page := 1 33 - if r.URL.Query().Get("page") != "" { 34 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 35 - if err != nil { 36 - page = 1 37 - } 38 - } 39 - 40 - ref := chi.URLParam(r, "ref") 41 - ref, _ = url.PathUnescape(ref) 42 - 43 - scheme := "http" 44 - if !rp.config.Core.Dev { 45 - scheme = "https" 46 - } 47 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 - xrpcc := &indigoxrpc.Client{ 49 - Host: host, 50 - } 51 - 52 - limit := int64(60) 53 - cursor := "" 54 - if page > 1 { 55 - // Convert page number to cursor (offset) 56 - offset := (page - 1) * int(limit) 57 - cursor = strconv.Itoa(offset) 58 - } 59 - 60 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 61 - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 62 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 - l.Error("failed to call XRPC repo.log", "err", xrpcerr) 64 - rp.pages.Error503(w) 65 - return 66 - } 67 - 68 - var xrpcResp types.RepoLogResponse 69 - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 70 - l.Error("failed to decode XRPC response", "err", err) 71 - rp.pages.Error503(w) 72 - return 73 - } 74 - 75 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 76 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 77 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 78 - rp.pages.Error503(w) 79 - return 80 - } 81 - 82 - tagMap := make(map[string][]string) 83 - if tagBytes != nil { 84 - var tagResp types.RepoTagsResponse 85 - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 86 - for _, tag := range tagResp.Tags { 87 - hash := tag.Hash 88 - if tag.Tag != nil { 89 - hash = tag.Tag.Target.String() 90 - } 91 - tagMap[hash] = append(tagMap[hash], tag.Name) 92 - } 93 - } 94 - } 95 - 96 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 97 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 98 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 99 - rp.pages.Error503(w) 100 - return 101 - } 102 - 103 - if branchBytes != nil { 104 - var branchResp types.RepoBranchesResponse 105 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 106 - for _, branch := range branchResp.Branches { 107 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 108 - } 109 - } 110 - } 111 - 112 - user := rp.oauth.GetUser(r) 113 - 114 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 - if err != nil { 116 - l.Error("failed to fetch email to did mapping", "err", err) 117 - } 118 - 119 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 120 - if err != nil { 121 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 122 - } 123 - 124 - repoInfo := f.RepoInfo(user) 125 - 126 - var shas []string 127 - for _, c := range xrpcResp.Commits { 128 - shas = append(shas, c.Hash.String()) 129 - } 130 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 131 - if err != nil { 132 - l.Error("failed to getPipelineStatuses", "err", err) 133 - // non-fatal 134 - } 135 - 136 - rp.pages.RepoLog(w, pages.RepoLogParams{ 137 - LoggedInUser: user, 138 - TagMap: tagMap, 139 - RepoInfo: repoInfo, 140 - RepoLogResponse: xrpcResp, 141 - EmailToDid: emailToDidMap, 142 - VerifiedCommits: vc, 143 - Pipelines: pipelines, 144 - }) 145 - } 146 - 147 - func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) { 148 - l := rp.logger.With("handler", "RepoCommit") 149 - 150 - f, err := rp.repoResolver.Resolve(r) 151 - if err != nil { 152 - l.Error("failed to fully resolve repo", "err", err) 153 - return 154 - } 155 - ref := chi.URLParam(r, "ref") 156 - ref, _ = url.PathUnescape(ref) 157 - 158 - var diffOpts types.DiffOpts 159 - if d := r.URL.Query().Get("diff"); d == "split" { 160 - diffOpts.Split = true 161 - } 162 - 163 - if !plumbing.IsHash(ref) { 164 - rp.pages.Error404(w) 165 - return 166 - } 167 - 168 - scheme := "http" 169 - if !rp.config.Core.Dev { 170 - scheme = "https" 171 - } 172 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 173 - xrpcc := &indigoxrpc.Client{ 174 - Host: host, 175 - } 176 - 177 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 178 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 179 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 180 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 181 - rp.pages.Error503(w) 182 - return 183 - } 184 - 185 - var result types.RepoCommitResponse 186 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 187 - l.Error("failed to decode XRPC response", "err", err) 188 - rp.pages.Error503(w) 189 - return 190 - } 191 - 192 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 193 - if err != nil { 194 - l.Error("failed to get email to did mapping", "err", err) 195 - } 196 - 197 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 198 - if err != nil { 199 - l.Error("failed to GetVerifiedCommits", "err", err) 200 - } 201 - 202 - user := rp.oauth.GetUser(r) 203 - repoInfo := f.RepoInfo(user) 204 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 205 - if err != nil { 206 - l.Error("failed to getPipelineStatuses", "err", err) 207 - // non-fatal 208 - } 209 - var pipeline *models.Pipeline 210 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 211 - pipeline = &p 212 - } 213 - 214 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 215 - LoggedInUser: user, 216 - RepoInfo: f.RepoInfo(user), 217 - RepoCommitResponse: result, 218 - EmailToDid: emailToDidMap, 219 - VerifiedCommit: vc, 220 - Pipeline: pipeline, 221 - DiffOpts: diffOpts, 222 - }) 223 - }
-402
appview/repo/opengraph.go
··· 1 - package repo 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/hex" 7 - "fmt" 8 - "image/color" 9 - "image/png" 10 - "log" 11 - "net/http" 12 - "sort" 13 - "strings" 14 - 15 - "github.com/go-enry/go-enry/v2" 16 - "tangled.org/core/appview/db" 17 - "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/ogcard" 19 - "tangled.org/core/types" 20 - ) 21 - 22 - func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 23 - width, height := ogcard.DefaultSize() 24 - mainCard, err := ogcard.NewCard(width, height) 25 - if err != nil { 26 - return nil, err 27 - } 28 - 29 - // Split: content area (75%) and language bar + icons (25%) 30 - contentCard, bottomArea := mainCard.Split(false, 75) 31 - 32 - // Add padding to content 33 - contentCard.SetMargin(50) 34 - 35 - // Split content horizontally: main content (80%) and avatar area (20%) 36 - mainContent, avatarArea := contentCard.Split(true, 80) 37 - 38 - // Use main content area for both repo name and description to allow dynamic wrapping. 39 - mainContent.SetMargin(10) 40 - 41 - var ownerHandle string 42 - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 43 - if err != nil { 44 - ownerHandle = repo.Did 45 - } else { 46 - ownerHandle = "@" + owner.Handle.String() 47 - } 48 - 49 - bounds := mainContent.Img.Bounds() 50 - startX := bounds.Min.X + mainContent.Margin 51 - startY := bounds.Min.Y + mainContent.Margin 52 - currentX := startX 53 - currentY := startY 54 - lineHeight := 64 // Font size 54 + padding 55 - textColor := color.RGBA{88, 96, 105, 255} 56 - 57 - // Draw owner handle 58 - ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 59 - if err != nil { 60 - return nil, err 61 - } 62 - currentX += ownerWidth 63 - 64 - // Draw separator 65 - sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 66 - if err != nil { 67 - return nil, err 68 - } 69 - currentX += sepWidth 70 - 71 - words := strings.Fields(repo.Name) 72 - spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 73 - if spaceWidth == 0 { 74 - spaceWidth = 15 75 - } 76 - 77 - for _, word := range words { 78 - // estimate bold width by measuring regular width and adding a multiplier 79 - regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 80 - estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text 81 - 82 - if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { 83 - currentX = startX 84 - currentY += lineHeight 85 - } 86 - 87 - _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) 88 - if err != nil { 89 - return nil, err 90 - } 91 - currentX += estimatedBoldWidth + spaceWidth 92 - } 93 - 94 - // update Y position for the description 95 - currentY += lineHeight 96 - 97 - // draw description 98 - if currentY < bounds.Max.Y-mainContent.Margin { 99 - totalHeight := float64(bounds.Dy()) 100 - repoNameHeight := float64(currentY - bounds.Min.Y) 101 - 102 - if totalHeight > 0 && repoNameHeight < totalHeight { 103 - repoNamePercent := (repoNameHeight / totalHeight) * 100 104 - if repoNamePercent < 95 { // Ensure there's space left for description 105 - _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) 106 - descriptionCard.SetMargin(8) 107 - 108 - description := repo.Description 109 - if len(description) > 70 { 110 - description = description[:70] + "…" 111 - } 112 - 113 - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 114 - if err != nil { 115 - log.Printf("failed to draw description: %v", err) 116 - } 117 - } 118 - } 119 - } 120 - 121 - // Draw avatar circle on the right side 122 - avatarBounds := avatarArea.Img.Bounds() 123 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 124 - if avatarSize > 220 { 125 - avatarSize = 220 126 - } 127 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 128 - avatarY := avatarBounds.Min.Y + 20 129 - 130 - // Get avatar URL and draw it 131 - avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 132 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 133 - if err != nil { 134 - log.Printf("failed to draw avatar (non-fatal): %v", err) 135 - } 136 - 137 - // Split bottom area: icons area (65%) and language bar (35%) 138 - iconsArea, languageBarCard := bottomArea.Split(false, 75) 139 - 140 - // Split icons area: left side for stats (80%), right side for dolly (20%) 141 - statsArea, dollyArea := iconsArea.Split(true, 80) 142 - 143 - // Draw stats with icons in the stats area 144 - starsText := repo.RepoStats.StarCount 145 - issuesText := repo.RepoStats.IssueCount.Open 146 - pullRequestsText := repo.RepoStats.PullCount.Open 147 - 148 - iconColor := color.RGBA{88, 96, 105, 255} 149 - iconSize := 36 150 - textSize := 36.0 151 - 152 - // Position stats in the middle of the stats area 153 - statsBounds := statsArea.Img.Bounds() 154 - statsX := statsBounds.Min.X + 60 // left padding 155 - statsY := statsBounds.Min.Y 156 - currentX = statsX 157 - labelSize := 22.0 158 - // Draw star icon, count, and label 159 - // Align icon baseline with text baseline 160 - iconBaselineOffset := int(textSize) / 2 161 - err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 - if err != nil { 163 - log.Printf("failed to draw star icon: %v", err) 164 - } 165 - starIconX := currentX 166 - currentX += iconSize + 15 167 - 168 - starText := fmt.Sprintf("%d", starsText) 169 - err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 170 - if err != nil { 171 - log.Printf("failed to draw star text: %v", err) 172 - } 173 - starTextWidth := len(starText) * 20 174 - starGroupWidth := iconSize + 15 + starTextWidth 175 - 176 - // Draw "stars" label below and centered under the icon+text group 177 - labelY := statsY + iconSize + 15 178 - labelX := starIconX + starGroupWidth/2 179 - err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 180 - if err != nil { 181 - log.Printf("failed to draw stars label: %v", err) 182 - } 183 - 184 - currentX += starTextWidth + 50 185 - 186 - // Draw issues icon, count, and label 187 - issueStartX := currentX 188 - err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 - if err != nil { 190 - log.Printf("failed to draw circle-dot icon: %v", err) 191 - } 192 - currentX += iconSize + 15 193 - 194 - issueText := fmt.Sprintf("%d", issuesText) 195 - err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 196 - if err != nil { 197 - log.Printf("failed to draw issue text: %v", err) 198 - } 199 - issueTextWidth := len(issueText) * 20 200 - issueGroupWidth := iconSize + 15 + issueTextWidth 201 - 202 - // Draw "issues" label below and centered under the icon+text group 203 - labelX = issueStartX + issueGroupWidth/2 204 - err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 205 - if err != nil { 206 - log.Printf("failed to draw issues label: %v", err) 207 - } 208 - 209 - currentX += issueTextWidth + 50 210 - 211 - // Draw pull request icon, count, and label 212 - prStartX := currentX 213 - err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 214 - if err != nil { 215 - log.Printf("failed to draw git-pull-request icon: %v", err) 216 - } 217 - currentX += iconSize + 15 218 - 219 - prText := fmt.Sprintf("%d", pullRequestsText) 220 - err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 221 - if err != nil { 222 - log.Printf("failed to draw PR text: %v", err) 223 - } 224 - prTextWidth := len(prText) * 20 225 - prGroupWidth := iconSize + 15 + prTextWidth 226 - 227 - // Draw "pulls" label below and centered under the icon+text group 228 - labelX = prStartX + prGroupWidth/2 229 - err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 230 - if err != nil { 231 - log.Printf("failed to draw pulls label: %v", err) 232 - } 233 - 234 - dollyBounds := dollyArea.Img.Bounds() 235 - dollySize := 90 236 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 237 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 238 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 239 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 240 - if err != nil { 241 - log.Printf("dolly silhouette not available (this is ok): %v", err) 242 - } 243 - 244 - // Draw language bar at bottom 245 - err = drawLanguagesCard(languageBarCard, languageStats) 246 - if err != nil { 247 - log.Printf("failed to draw language bar: %v", err) 248 - return nil, err 249 - } 250 - 251 - return mainCard, nil 252 - } 253 - 254 - // hexToColor converts a hex color to a go color 255 - func hexToColor(colorStr string) (*color.RGBA, error) { 256 - colorStr = strings.TrimLeft(colorStr, "#") 257 - 258 - b, err := hex.DecodeString(colorStr) 259 - if err != nil { 260 - return nil, err 261 - } 262 - 263 - if len(b) < 3 { 264 - return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 265 - } 266 - 267 - clr := color.RGBA{b[0], b[1], b[2], 255} 268 - 269 - return &clr, nil 270 - } 271 - 272 - func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 273 - bounds := card.Img.Bounds() 274 - cardWidth := bounds.Dx() 275 - 276 - if len(languageStats) == 0 { 277 - // Draw a light gray bar if no languages detected 278 - card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 279 - return nil 280 - } 281 - 282 - // Limit to top 5 languages for the visual bar 283 - displayLanguages := languageStats 284 - if len(displayLanguages) > 5 { 285 - displayLanguages = displayLanguages[:5] 286 - } 287 - 288 - currentX := bounds.Min.X 289 - 290 - for _, lang := range displayLanguages { 291 - var langColor *color.RGBA 292 - var err error 293 - 294 - if lang.Color != "" { 295 - langColor, err = hexToColor(lang.Color) 296 - if err != nil { 297 - // Fallback to a default color 298 - langColor = &color.RGBA{149, 157, 165, 255} 299 - } 300 - } else { 301 - // Default color if no color specified 302 - langColor = &color.RGBA{149, 157, 165, 255} 303 - } 304 - 305 - langWidth := float32(cardWidth) * (lang.Percentage / 100) 306 - card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 307 - currentX += int(langWidth) 308 - } 309 - 310 - // Fill remaining space with the last color (if any gap due to rounding) 311 - if currentX < bounds.Max.X && len(displayLanguages) > 0 { 312 - lastLang := displayLanguages[len(displayLanguages)-1] 313 - var lastColor *color.RGBA 314 - var err error 315 - 316 - if lastLang.Color != "" { 317 - lastColor, err = hexToColor(lastLang.Color) 318 - if err != nil { 319 - lastColor = &color.RGBA{149, 157, 165, 255} 320 - } 321 - } else { 322 - lastColor = &color.RGBA{149, 157, 165, 255} 323 - } 324 - card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 325 - } 326 - 327 - return nil 328 - } 329 - 330 - func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 331 - f, err := rp.repoResolver.Resolve(r) 332 - if err != nil { 333 - log.Println("failed to get repo and knot", err) 334 - return 335 - } 336 - 337 - // Get language stats directly from database 338 - var languageStats []types.RepoLanguageDetails 339 - langs, err := db.GetRepoLanguages( 340 - rp.db, 341 - db.FilterEq("repo_at", f.RepoAt()), 342 - db.FilterEq("is_default_ref", 1), 343 - ) 344 - if err != nil { 345 - log.Printf("failed to get language stats from db: %v", err) 346 - // non-fatal, continue without language stats 347 - } else if len(langs) > 0 { 348 - var total int64 349 - for _, l := range langs { 350 - total += l.Bytes 351 - } 352 - 353 - for _, l := range langs { 354 - percentage := float32(l.Bytes) / float32(total) * 100 355 - color := enry.GetColor(l.Language) 356 - languageStats = append(languageStats, types.RepoLanguageDetails{ 357 - Name: l.Language, 358 - Percentage: percentage, 359 - Color: color, 360 - }) 361 - } 362 - 363 - sort.Slice(languageStats, func(i, j int) bool { 364 - if languageStats[i].Name == enry.OtherLanguage { 365 - return false 366 - } 367 - if languageStats[j].Name == enry.OtherLanguage { 368 - return true 369 - } 370 - if languageStats[i].Percentage != languageStats[j].Percentage { 371 - return languageStats[i].Percentage > languageStats[j].Percentage 372 - } 373 - return languageStats[i].Name < languageStats[j].Name 374 - }) 375 - } 376 - 377 - card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 378 - if err != nil { 379 - log.Println("failed to draw repo summary card", err) 380 - http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 381 - return 382 - } 383 - 384 - var imageBuffer bytes.Buffer 385 - err = png.Encode(&imageBuffer, card.Img) 386 - if err != nil { 387 - log.Println("failed to encode repo summary card", err) 388 - http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 389 - return 390 - } 391 - 392 - imageBytes := imageBuffer.Bytes() 393 - 394 - w.Header().Set("Content-Type", "image/png") 395 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 396 - w.WriteHeader(http.StatusOK) 397 - _, err = w.Write(imageBytes) 398 - if err != nil { 399 - log.Println("failed to write repo summary card", err) 400 - return 401 - } 402 - }
+1350 -65
appview/repo/repo.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "encoding/json" 6 7 "errors" 7 8 "fmt" 9 + "io" 10 + "log" 8 11 "log/slog" 9 12 "net/http" 10 13 "net/url" 14 + "path/filepath" 11 15 "slices" 16 + "strconv" 12 17 "strings" 13 18 "time" 14 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 23 "tangled.org/core/api/tangled" 24 + "tangled.org/core/appview/commitverify" 16 25 "tangled.org/core/appview/config" 17 26 "tangled.org/core/appview/db" 18 27 "tangled.org/core/appview/models" 19 28 "tangled.org/core/appview/notify" 20 29 "tangled.org/core/appview/oauth" 21 30 "tangled.org/core/appview/pages" 31 + "tangled.org/core/appview/pages/markup" 22 32 "tangled.org/core/appview/reporesolver" 23 33 "tangled.org/core/appview/validator" 24 34 xrpcclient "tangled.org/core/appview/xrpcclient" 25 35 "tangled.org/core/eventconsumer" 26 36 "tangled.org/core/idresolver" 37 + "tangled.org/core/patchutil" 27 38 "tangled.org/core/rbac" 28 39 "tangled.org/core/tid" 40 + "tangled.org/core/types" 29 41 "tangled.org/core/xrpc/serviceauth" 30 42 31 - comatproto "github.com/bluesky-social/indigo/api/atproto" 32 - atpclient "github.com/bluesky-social/indigo/atproto/client" 33 - "github.com/bluesky-social/indigo/atproto/syntax" 34 - lexutil "github.com/bluesky-social/indigo/lex/util" 35 43 securejoin "github.com/cyphar/filepath-securejoin" 36 44 "github.com/go-chi/chi/v5" 45 + "github.com/go-git/go-git/v5/plumbing" 46 + 47 + "github.com/bluesky-social/indigo/atproto/syntax" 37 48 ) 38 49 39 50 type Repo struct { ··· 78 89 } 79 90 } 80 91 92 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 93 + ref := chi.URLParam(r, "ref") 94 + ref, _ = url.PathUnescape(ref) 95 + 96 + f, err := rp.repoResolver.Resolve(r) 97 + if err != nil { 98 + log.Println("failed to get repo and knot", err) 99 + return 100 + } 101 + 102 + scheme := "http" 103 + if !rp.config.Core.Dev { 104 + scheme = "https" 105 + } 106 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 107 + xrpcc := &indigoxrpc.Client{ 108 + Host: host, 109 + } 110 + 111 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 112 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 113 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 114 + log.Println("failed to call XRPC repo.archive", xrpcerr) 115 + rp.pages.Error503(w) 116 + return 117 + } 118 + 119 + // Set headers for file download, just pass along whatever the knot specifies 120 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 121 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 122 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 123 + w.Header().Set("Content-Type", "application/gzip") 124 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 125 + 126 + // Write the archive data directly 127 + w.Write(archiveBytes) 128 + } 129 + 130 + func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 131 + f, err := rp.repoResolver.Resolve(r) 132 + if err != nil { 133 + log.Println("failed to fully resolve repo", err) 134 + return 135 + } 136 + 137 + page := 1 138 + if r.URL.Query().Get("page") != "" { 139 + page, err = strconv.Atoi(r.URL.Query().Get("page")) 140 + if err != nil { 141 + page = 1 142 + } 143 + } 144 + 145 + ref := chi.URLParam(r, "ref") 146 + ref, _ = url.PathUnescape(ref) 147 + 148 + scheme := "http" 149 + if !rp.config.Core.Dev { 150 + scheme = "https" 151 + } 152 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 153 + xrpcc := &indigoxrpc.Client{ 154 + Host: host, 155 + } 156 + 157 + limit := int64(60) 158 + cursor := "" 159 + if page > 1 { 160 + // Convert page number to cursor (offset) 161 + offset := (page - 1) * int(limit) 162 + cursor = strconv.Itoa(offset) 163 + } 164 + 165 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 166 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 167 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 + log.Println("failed to call XRPC repo.log", xrpcerr) 169 + rp.pages.Error503(w) 170 + return 171 + } 172 + 173 + var xrpcResp types.RepoLogResponse 174 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 + log.Println("failed to decode XRPC response", err) 176 + rp.pages.Error503(w) 177 + return 178 + } 179 + 180 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + log.Println("failed to call XRPC repo.tags", xrpcerr) 183 + rp.pages.Error503(w) 184 + return 185 + } 186 + 187 + tagMap := make(map[string][]string) 188 + if tagBytes != nil { 189 + var tagResp types.RepoTagsResponse 190 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 191 + for _, tag := range tagResp.Tags { 192 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 193 + } 194 + } 195 + } 196 + 197 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 + log.Println("failed to call XRPC repo.branches", xrpcerr) 200 + rp.pages.Error503(w) 201 + return 202 + } 203 + 204 + if branchBytes != nil { 205 + var branchResp types.RepoBranchesResponse 206 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 207 + for _, branch := range branchResp.Branches { 208 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 209 + } 210 + } 211 + } 212 + 213 + user := rp.oauth.GetUser(r) 214 + 215 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 216 + if err != nil { 217 + log.Println("failed to fetch email to did mapping", err) 218 + } 219 + 220 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 221 + if err != nil { 222 + log.Println(err) 223 + } 224 + 225 + repoInfo := f.RepoInfo(user) 226 + 227 + var shas []string 228 + for _, c := range xrpcResp.Commits { 229 + shas = append(shas, c.Hash.String()) 230 + } 231 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 232 + if err != nil { 233 + log.Println(err) 234 + // non-fatal 235 + } 236 + 237 + rp.pages.RepoLog(w, pages.RepoLogParams{ 238 + LoggedInUser: user, 239 + TagMap: tagMap, 240 + RepoInfo: repoInfo, 241 + RepoLogResponse: xrpcResp, 242 + EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 243 + VerifiedCommits: vc, 244 + Pipelines: pipelines, 245 + }) 246 + } 247 + 248 + func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 249 + f, err := rp.repoResolver.Resolve(r) 250 + if err != nil { 251 + log.Println("failed to get repo and knot", err) 252 + w.WriteHeader(http.StatusBadRequest) 253 + return 254 + } 255 + 256 + user := rp.oauth.GetUser(r) 257 + rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 258 + RepoInfo: f.RepoInfo(user), 259 + }) 260 + } 261 + 262 + func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 263 + f, err := rp.repoResolver.Resolve(r) 264 + if err != nil { 265 + log.Println("failed to get repo and knot", err) 266 + w.WriteHeader(http.StatusBadRequest) 267 + return 268 + } 269 + 270 + repoAt := f.RepoAt() 271 + rkey := repoAt.RecordKey().String() 272 + if rkey == "" { 273 + log.Println("invalid aturi for repo", err) 274 + w.WriteHeader(http.StatusInternalServerError) 275 + return 276 + } 277 + 278 + user := rp.oauth.GetUser(r) 279 + 280 + switch r.Method { 281 + case http.MethodGet: 282 + rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 283 + RepoInfo: f.RepoInfo(user), 284 + }) 285 + return 286 + case http.MethodPut: 287 + newDescription := r.FormValue("description") 288 + client, err := rp.oauth.AuthorizedClient(r) 289 + if err != nil { 290 + log.Println("failed to get client") 291 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 292 + return 293 + } 294 + 295 + // optimistic update 296 + err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 297 + if err != nil { 298 + log.Println("failed to perferom update-description query", err) 299 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 300 + return 301 + } 302 + 303 + newRepo := f.Repo 304 + newRepo.Description = newDescription 305 + record := newRepo.AsRecord() 306 + 307 + // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 + // 309 + // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 + if err != nil { 312 + // failed to get record 313 + rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 + return 315 + } 316 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 317 + Collection: tangled.RepoNSID, 318 + Repo: newRepo.Did, 319 + Rkey: newRepo.Rkey, 320 + SwapRecord: ex.Cid, 321 + Record: &lexutil.LexiconTypeDecoder{ 322 + Val: &record, 323 + }, 324 + }) 325 + 326 + if err != nil { 327 + log.Println("failed to perferom update-description query", err) 328 + // failed to get record 329 + rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 330 + return 331 + } 332 + 333 + newRepoInfo := f.RepoInfo(user) 334 + newRepoInfo.Description = newDescription 335 + 336 + rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 337 + RepoInfo: newRepoInfo, 338 + }) 339 + return 340 + } 341 + } 342 + 343 + func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 344 + f, err := rp.repoResolver.Resolve(r) 345 + if err != nil { 346 + log.Println("failed to fully resolve repo", err) 347 + return 348 + } 349 + ref := chi.URLParam(r, "ref") 350 + ref, _ = url.PathUnescape(ref) 351 + 352 + var diffOpts types.DiffOpts 353 + if d := r.URL.Query().Get("diff"); d == "split" { 354 + diffOpts.Split = true 355 + } 356 + 357 + if !plumbing.IsHash(ref) { 358 + rp.pages.Error404(w) 359 + return 360 + } 361 + 362 + scheme := "http" 363 + if !rp.config.Core.Dev { 364 + scheme = "https" 365 + } 366 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 367 + xrpcc := &indigoxrpc.Client{ 368 + Host: host, 369 + } 370 + 371 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 372 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 373 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 374 + log.Println("failed to call XRPC repo.diff", xrpcerr) 375 + rp.pages.Error503(w) 376 + return 377 + } 378 + 379 + var result types.RepoCommitResponse 380 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 381 + log.Println("failed to decode XRPC response", err) 382 + rp.pages.Error503(w) 383 + return 384 + } 385 + 386 + emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 387 + if err != nil { 388 + log.Println("failed to get email to did mapping:", err) 389 + } 390 + 391 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 392 + if err != nil { 393 + log.Println(err) 394 + } 395 + 396 + user := rp.oauth.GetUser(r) 397 + repoInfo := f.RepoInfo(user) 398 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 399 + if err != nil { 400 + log.Println(err) 401 + // non-fatal 402 + } 403 + var pipeline *models.Pipeline 404 + if p, ok := pipelines[result.Diff.Commit.This]; ok { 405 + pipeline = &p 406 + } 407 + 408 + rp.pages.RepoCommit(w, pages.RepoCommitParams{ 409 + LoggedInUser: user, 410 + RepoInfo: f.RepoInfo(user), 411 + RepoCommitResponse: result, 412 + EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 413 + VerifiedCommit: vc, 414 + Pipeline: pipeline, 415 + DiffOpts: diffOpts, 416 + }) 417 + } 418 + 419 + func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 420 + f, err := rp.repoResolver.Resolve(r) 421 + if err != nil { 422 + log.Println("failed to fully resolve repo", err) 423 + return 424 + } 425 + 426 + ref := chi.URLParam(r, "ref") 427 + ref, _ = url.PathUnescape(ref) 428 + 429 + // if the tree path has a trailing slash, let's strip it 430 + // so we don't 404 431 + treePath := chi.URLParam(r, "*") 432 + treePath, _ = url.PathUnescape(treePath) 433 + treePath = strings.TrimSuffix(treePath, "/") 434 + 435 + scheme := "http" 436 + if !rp.config.Core.Dev { 437 + scheme = "https" 438 + } 439 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 440 + xrpcc := &indigoxrpc.Client{ 441 + Host: host, 442 + } 443 + 444 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 445 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 446 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 + log.Println("failed to call XRPC repo.tree", xrpcerr) 448 + rp.pages.Error503(w) 449 + return 450 + } 451 + 452 + // Convert XRPC response to internal types.RepoTreeResponse 453 + files := make([]types.NiceTree, len(xrpcResp.Files)) 454 + for i, xrpcFile := range xrpcResp.Files { 455 + file := types.NiceTree{ 456 + Name: xrpcFile.Name, 457 + Mode: xrpcFile.Mode, 458 + Size: int64(xrpcFile.Size), 459 + IsFile: xrpcFile.Is_file, 460 + IsSubtree: xrpcFile.Is_subtree, 461 + } 462 + 463 + // Convert last commit info if present 464 + if xrpcFile.Last_commit != nil { 465 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 466 + file.LastCommit = &types.LastCommitInfo{ 467 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 468 + Message: xrpcFile.Last_commit.Message, 469 + When: commitWhen, 470 + } 471 + } 472 + 473 + files[i] = file 474 + } 475 + 476 + result := types.RepoTreeResponse{ 477 + Ref: xrpcResp.Ref, 478 + Files: files, 479 + } 480 + 481 + if xrpcResp.Parent != nil { 482 + result.Parent = *xrpcResp.Parent 483 + } 484 + if xrpcResp.Dotdot != nil { 485 + result.DotDot = *xrpcResp.Dotdot 486 + } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 491 + 492 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 493 + // so we can safely redirect to the "parent" (which is the same file). 494 + if len(result.Files) == 0 && result.Parent == treePath { 495 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 496 + http.Redirect(w, r, redirectTo, http.StatusFound) 497 + return 498 + } 499 + 500 + user := rp.oauth.GetUser(r) 501 + 502 + var breadcrumbs [][]string 503 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 504 + if treePath != "" { 505 + for idx, elem := range strings.Split(treePath, "/") { 506 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 507 + } 508 + } 509 + 510 + sortFiles(result.Files) 511 + 512 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 513 + LoggedInUser: user, 514 + BreadCrumbs: breadcrumbs, 515 + TreePath: treePath, 516 + RepoInfo: f.RepoInfo(user), 517 + RepoTreeResponse: result, 518 + }) 519 + } 520 + 521 + func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 522 + f, err := rp.repoResolver.Resolve(r) 523 + if err != nil { 524 + log.Println("failed to get repo and knot", err) 525 + return 526 + } 527 + 528 + scheme := "http" 529 + if !rp.config.Core.Dev { 530 + scheme = "https" 531 + } 532 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 533 + xrpcc := &indigoxrpc.Client{ 534 + Host: host, 535 + } 536 + 537 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 538 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 539 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 540 + log.Println("failed to call XRPC repo.tags", xrpcerr) 541 + rp.pages.Error503(w) 542 + return 543 + } 544 + 545 + var result types.RepoTagsResponse 546 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 547 + log.Println("failed to decode XRPC response", err) 548 + rp.pages.Error503(w) 549 + return 550 + } 551 + 552 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 553 + if err != nil { 554 + log.Println("failed grab artifacts", err) 555 + return 556 + } 557 + 558 + // convert artifacts to map for easy UI building 559 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 560 + for _, a := range artifacts { 561 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 562 + } 563 + 564 + var danglingArtifacts []models.Artifact 565 + for _, a := range artifacts { 566 + found := false 567 + for _, t := range result.Tags { 568 + if t.Tag != nil { 569 + if t.Tag.Hash == a.Tag { 570 + found = true 571 + } 572 + } 573 + } 574 + 575 + if !found { 576 + danglingArtifacts = append(danglingArtifacts, a) 577 + } 578 + } 579 + 580 + user := rp.oauth.GetUser(r) 581 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 582 + LoggedInUser: user, 583 + RepoInfo: f.RepoInfo(user), 584 + RepoTagsResponse: result, 585 + ArtifactMap: artifactMap, 586 + DanglingArtifacts: danglingArtifacts, 587 + }) 588 + } 589 + 590 + func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 591 + f, err := rp.repoResolver.Resolve(r) 592 + if err != nil { 593 + log.Println("failed to get repo and knot", err) 594 + return 595 + } 596 + 597 + scheme := "http" 598 + if !rp.config.Core.Dev { 599 + scheme = "https" 600 + } 601 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 602 + xrpcc := &indigoxrpc.Client{ 603 + Host: host, 604 + } 605 + 606 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 607 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 608 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 609 + log.Println("failed to call XRPC repo.branches", xrpcerr) 610 + rp.pages.Error503(w) 611 + return 612 + } 613 + 614 + var result types.RepoBranchesResponse 615 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 616 + log.Println("failed to decode XRPC response", err) 617 + rp.pages.Error503(w) 618 + return 619 + } 620 + 621 + sortBranches(result.Branches) 622 + 623 + user := rp.oauth.GetUser(r) 624 + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 625 + LoggedInUser: user, 626 + RepoInfo: f.RepoInfo(user), 627 + RepoBranchesResponse: result, 628 + }) 629 + } 630 + 631 + func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 632 + f, err := rp.repoResolver.Resolve(r) 633 + if err != nil { 634 + log.Println("failed to get repo and knot", err) 635 + return 636 + } 637 + 638 + ref := chi.URLParam(r, "ref") 639 + ref, _ = url.PathUnescape(ref) 640 + 641 + filePath := chi.URLParam(r, "*") 642 + filePath, _ = url.PathUnescape(filePath) 643 + 644 + scheme := "http" 645 + if !rp.config.Core.Dev { 646 + scheme = "https" 647 + } 648 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 649 + xrpcc := &indigoxrpc.Client{ 650 + Host: host, 651 + } 652 + 653 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 654 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 655 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 656 + log.Println("failed to call XRPC repo.blob", xrpcerr) 657 + rp.pages.Error503(w) 658 + return 659 + } 660 + 661 + // Use XRPC response directly instead of converting to internal types 662 + 663 + var breadcrumbs [][]string 664 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 665 + if filePath != "" { 666 + for idx, elem := range strings.Split(filePath, "/") { 667 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 668 + } 669 + } 670 + 671 + showRendered := false 672 + renderToggle := false 673 + 674 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 675 + renderToggle = true 676 + showRendered = r.URL.Query().Get("code") != "true" 677 + } 678 + 679 + var unsupported bool 680 + var isImage bool 681 + var isVideo bool 682 + var contentSrc string 683 + 684 + if resp.IsBinary != nil && *resp.IsBinary { 685 + ext := strings.ToLower(filepath.Ext(resp.Path)) 686 + switch ext { 687 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 688 + isImage = true 689 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 690 + isVideo = true 691 + default: 692 + unsupported = true 693 + } 694 + 695 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 696 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 697 + 698 + baseURL := &url.URL{ 699 + Scheme: scheme, 700 + Host: f.Knot, 701 + Path: "/xrpc/sh.tangled.repo.blob", 702 + } 703 + query := baseURL.Query() 704 + query.Set("repo", repoName) 705 + query.Set("ref", ref) 706 + query.Set("path", filePath) 707 + query.Set("raw", "true") 708 + baseURL.RawQuery = query.Encode() 709 + blobURL := baseURL.String() 710 + 711 + contentSrc = blobURL 712 + if !rp.config.Core.Dev { 713 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 714 + } 715 + } 716 + 717 + lines := 0 718 + if resp.IsBinary == nil || !*resp.IsBinary { 719 + lines = strings.Count(resp.Content, "\n") + 1 720 + } 721 + 722 + var sizeHint uint64 723 + if resp.Size != nil { 724 + sizeHint = uint64(*resp.Size) 725 + } else { 726 + sizeHint = uint64(len(resp.Content)) 727 + } 728 + 729 + user := rp.oauth.GetUser(r) 730 + 731 + // Determine if content is binary (dereference pointer) 732 + isBinary := false 733 + if resp.IsBinary != nil { 734 + isBinary = *resp.IsBinary 735 + } 736 + 737 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 738 + LoggedInUser: user, 739 + RepoInfo: f.RepoInfo(user), 740 + BreadCrumbs: breadcrumbs, 741 + ShowRendered: showRendered, 742 + RenderToggle: renderToggle, 743 + Unsupported: unsupported, 744 + IsImage: isImage, 745 + IsVideo: isVideo, 746 + ContentSrc: contentSrc, 747 + RepoBlob_Output: resp, 748 + Contents: resp.Content, 749 + Lines: lines, 750 + SizeHint: sizeHint, 751 + IsBinary: isBinary, 752 + }) 753 + } 754 + 755 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 756 + f, err := rp.repoResolver.Resolve(r) 757 + if err != nil { 758 + log.Println("failed to get repo and knot", err) 759 + w.WriteHeader(http.StatusBadRequest) 760 + return 761 + } 762 + 763 + ref := chi.URLParam(r, "ref") 764 + ref, _ = url.PathUnescape(ref) 765 + 766 + filePath := chi.URLParam(r, "*") 767 + filePath, _ = url.PathUnescape(filePath) 768 + 769 + scheme := "http" 770 + if !rp.config.Core.Dev { 771 + scheme = "https" 772 + } 773 + 774 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 775 + baseURL := &url.URL{ 776 + Scheme: scheme, 777 + Host: f.Knot, 778 + Path: "/xrpc/sh.tangled.repo.blob", 779 + } 780 + query := baseURL.Query() 781 + query.Set("repo", repo) 782 + query.Set("ref", ref) 783 + query.Set("path", filePath) 784 + query.Set("raw", "true") 785 + baseURL.RawQuery = query.Encode() 786 + blobURL := baseURL.String() 787 + 788 + req, err := http.NewRequest("GET", blobURL, nil) 789 + if err != nil { 790 + log.Println("failed to create request", err) 791 + return 792 + } 793 + 794 + // forward the If-None-Match header 795 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 796 + req.Header.Set("If-None-Match", clientETag) 797 + } 798 + 799 + client := &http.Client{} 800 + resp, err := client.Do(req) 801 + if err != nil { 802 + log.Println("failed to reach knotserver", err) 803 + rp.pages.Error503(w) 804 + return 805 + } 806 + defer resp.Body.Close() 807 + 808 + // forward 304 not modified 809 + if resp.StatusCode == http.StatusNotModified { 810 + w.WriteHeader(http.StatusNotModified) 811 + return 812 + } 813 + 814 + if resp.StatusCode != http.StatusOK { 815 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 816 + w.WriteHeader(resp.StatusCode) 817 + _, _ = io.Copy(w, resp.Body) 818 + return 819 + } 820 + 821 + contentType := resp.Header.Get("Content-Type") 822 + body, err := io.ReadAll(resp.Body) 823 + if err != nil { 824 + log.Printf("error reading response body from knotserver: %v", err) 825 + w.WriteHeader(http.StatusInternalServerError) 826 + return 827 + } 828 + 829 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 830 + // serve all textual content as text/plain 831 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 832 + w.Write(body) 833 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 834 + // serve images and videos with their original content type 835 + w.Header().Set("Content-Type", contentType) 836 + w.Write(body) 837 + } else { 838 + w.WriteHeader(http.StatusUnsupportedMediaType) 839 + w.Write([]byte("unsupported content type")) 840 + return 841 + } 842 + } 843 + 844 + // isTextualMimeType returns true if the MIME type represents textual content 845 + // that should be served as text/plain 846 + func isTextualMimeType(mimeType string) bool { 847 + textualTypes := []string{ 848 + "application/json", 849 + "application/xml", 850 + "application/yaml", 851 + "application/x-yaml", 852 + "application/toml", 853 + "application/javascript", 854 + "application/ecmascript", 855 + "message/", 856 + } 857 + 858 + return slices.Contains(textualTypes, mimeType) 859 + } 860 + 81 861 // modify the spindle configured for this repo 82 862 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 83 863 user := rp.oauth.GetUser(r) 84 864 l := rp.logger.With("handler", "EditSpindle") 85 865 l = l.With("did", user.Did) 866 + l = l.With("handle", user.Handle) 86 867 87 868 errorId := "operation-error" 88 869 fail := func(msg string, err error) { ··· 135 916 return 136 917 } 137 918 138 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 919 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 139 920 if err != nil { 140 921 fail("Failed to update spindle, no record found on PDS.", err) 141 922 return 142 923 } 143 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 924 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 144 925 Collection: tangled.RepoNSID, 145 926 Repo: newRepo.Did, 146 927 Rkey: newRepo.Rkey, ··· 170 951 user := rp.oauth.GetUser(r) 171 952 l := rp.logger.With("handler", "AddLabel") 172 953 l = l.With("did", user.Did) 954 + l = l.With("handle", user.Handle) 173 955 174 956 f, err := rp.repoResolver.Resolve(r) 175 957 if err != nil { ··· 238 1020 239 1021 // emit a labelRecord 240 1022 labelRecord := label.AsRecord() 241 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1023 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 242 1024 Collection: tangled.LabelDefinitionNSID, 243 1025 Repo: label.Did, 244 1026 Rkey: label.Rkey, ··· 261 1043 newRepo.Labels = append(newRepo.Labels, aturi) 262 1044 repoRecord := newRepo.AsRecord() 263 1045 264 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1046 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 265 1047 if err != nil { 266 1048 fail("Failed to update labels, no record found on PDS.", err) 267 1049 return 268 1050 } 269 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1051 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 270 1052 Collection: tangled.RepoNSID, 271 1053 Repo: newRepo.Did, 272 1054 Rkey: newRepo.Rkey, ··· 329 1111 user := rp.oauth.GetUser(r) 330 1112 l := rp.logger.With("handler", "DeleteLabel") 331 1113 l = l.With("did", user.Did) 1114 + l = l.With("handle", user.Handle) 332 1115 333 1116 f, err := rp.repoResolver.Resolve(r) 334 1117 if err != nil { ··· 358 1141 } 359 1142 360 1143 // delete label record from PDS 361 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1144 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 362 1145 Collection: tangled.LabelDefinitionNSID, 363 1146 Repo: label.Did, 364 1147 Rkey: label.Rkey, ··· 380 1163 newRepo.Labels = updated 381 1164 repoRecord := newRepo.AsRecord() 382 1165 383 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1166 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 384 1167 if err != nil { 385 1168 fail("Failed to update labels, no record found on PDS.", err) 386 1169 return 387 1170 } 388 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1171 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 389 1172 Collection: tangled.RepoNSID, 390 1173 Repo: newRepo.Did, 391 1174 Rkey: newRepo.Rkey, ··· 437 1220 user := rp.oauth.GetUser(r) 438 1221 l := rp.logger.With("handler", "SubscribeLabel") 439 1222 l = l.With("did", user.Did) 1223 + l = l.With("handle", user.Handle) 440 1224 441 1225 f, err := rp.repoResolver.Resolve(r) 442 1226 if err != nil { ··· 477 1261 return 478 1262 } 479 1263 480 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1264 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 481 1265 if err != nil { 482 1266 fail("Failed to update labels, no record found on PDS.", err) 483 1267 return 484 1268 } 485 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1269 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 486 1270 Collection: tangled.RepoNSID, 487 1271 Repo: newRepo.Did, 488 1272 Rkey: newRepo.Rkey, ··· 523 1307 user := rp.oauth.GetUser(r) 524 1308 l := rp.logger.With("handler", "UnsubscribeLabel") 525 1309 l = l.With("did", user.Did) 1310 + l = l.With("handle", user.Handle) 526 1311 527 1312 f, err := rp.repoResolver.Resolve(r) 528 1313 if err != nil { ··· 565 1350 return 566 1351 } 567 1352 568 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1353 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 569 1354 if err != nil { 570 1355 fail("Failed to update labels, no record found on PDS.", err) 571 1356 return 572 1357 } 573 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1358 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 574 1359 Collection: tangled.RepoNSID, 575 1360 Repo: newRepo.Did, 576 1361 Rkey: newRepo.Rkey, ··· 616 1401 db.FilterContains("scope", subject.Collection().String()), 617 1402 ) 618 1403 if err != nil { 619 - l.Error("failed to fetch label defs", "err", err) 1404 + log.Println("failed to fetch label defs", err) 620 1405 return 621 1406 } 622 1407 ··· 627 1412 628 1413 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 629 1414 if err != nil { 630 - l.Error("failed to build label state", "err", err) 1415 + log.Println("failed to build label state", err) 631 1416 return 632 1417 } 633 1418 state := states[subject] ··· 664 1449 db.FilterContains("scope", subject.Collection().String()), 665 1450 ) 666 1451 if err != nil { 667 - l.Error("failed to fetch labels", "err", err) 1452 + log.Println("failed to fetch labels", err) 668 1453 return 669 1454 } 670 1455 ··· 675 1460 676 1461 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 677 1462 if err != nil { 678 - l.Error("failed to build label state", "err", err) 1463 + log.Println("failed to build label state", err) 679 1464 return 680 1465 } 681 1466 state := states[subject] ··· 694 1479 user := rp.oauth.GetUser(r) 695 1480 l := rp.logger.With("handler", "AddCollaborator") 696 1481 l = l.With("did", user.Did) 1482 + l = l.With("handle", user.Handle) 697 1483 698 1484 f, err := rp.repoResolver.Resolve(r) 699 1485 if err != nil { ··· 740 1526 currentUser := rp.oauth.GetUser(r) 741 1527 rkey := tid.TID() 742 1528 createdAt := time.Now() 743 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1529 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 744 1530 Collection: tangled.RepoCollaboratorNSID, 745 1531 Repo: currentUser.Did, 746 1532 Rkey: rkey, ··· 822 1608 823 1609 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 824 1610 user := rp.oauth.GetUser(r) 825 - l := rp.logger.With("handler", "DeleteRepo") 826 1611 827 1612 noticeId := "operation-error" 828 1613 f, err := rp.repoResolver.Resolve(r) 829 1614 if err != nil { 830 - l.Error("failed to get repo and knot", "err", err) 1615 + log.Println("failed to get repo and knot", err) 831 1616 return 832 1617 } 833 1618 834 1619 // remove record from pds 835 - atpClient, err := rp.oauth.AuthorizedClient(r) 1620 + xrpcClient, err := rp.oauth.AuthorizedClient(r) 836 1621 if err != nil { 837 - l.Error("failed to get authorized client", "err", err) 1622 + log.Println("failed to get authorized client", err) 838 1623 return 839 1624 } 840 - _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1625 + _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 841 1626 Collection: tangled.RepoNSID, 842 1627 Repo: user.Did, 843 1628 Rkey: f.Rkey, 844 1629 }) 845 1630 if err != nil { 846 - l.Error("failed to delete record", "err", err) 1631 + log.Printf("failed to delete record: %s", err) 847 1632 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 848 1633 return 849 1634 } 850 - l.Info("removed repo record", "aturi", f.RepoAt().String()) 1635 + log.Println("removed repo record ", f.RepoAt().String()) 851 1636 852 1637 client, err := rp.oauth.ServiceClient( 853 1638 r, ··· 856 1641 oauth.WithDev(rp.config.Core.Dev), 857 1642 ) 858 1643 if err != nil { 859 - l.Error("failed to connect to knot server", "err", err) 1644 + log.Println("failed to connect to knot server:", err) 860 1645 return 861 1646 } 862 1647 ··· 873 1658 rp.pages.Notice(w, noticeId, err.Error()) 874 1659 return 875 1660 } 876 - l.Info("deleted repo from knot") 1661 + log.Println("deleted repo from knot") 877 1662 878 1663 tx, err := rp.db.BeginTx(r.Context(), nil) 879 1664 if err != nil { 880 - l.Error("failed to start tx") 1665 + log.Println("failed to start tx") 881 1666 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 882 1667 return 883 1668 } ··· 885 1670 tx.Rollback() 886 1671 err = rp.enforcer.E.LoadPolicy() 887 1672 if err != nil { 888 - l.Error("failed to rollback policies") 1673 + log.Println("failed to rollback policies") 889 1674 } 890 1675 }() 891 1676 ··· 899 1684 did := c[0] 900 1685 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 901 1686 } 902 - l.Info("removed collaborators") 1687 + log.Println("removed collaborators") 903 1688 904 1689 // remove repo RBAC 905 1690 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 914 1699 rp.pages.Notice(w, noticeId, "Failed to update appview") 915 1700 return 916 1701 } 917 - l.Info("removed repo from db") 1702 + log.Println("removed repo from db") 918 1703 919 1704 err = tx.Commit() 920 1705 if err != nil { 921 - l.Error("failed to commit changes", "err", err) 1706 + log.Println("failed to commit changes", err) 922 1707 http.Error(w, err.Error(), http.StatusInternalServerError) 923 1708 return 924 1709 } 925 1710 926 1711 err = rp.enforcer.E.SavePolicy() 927 1712 if err != nil { 928 - l.Error("failed to update ACLs", "err", err) 1713 + log.Println("failed to update ACLs", err) 929 1714 http.Error(w, err.Error(), http.StatusInternalServerError) 930 1715 return 931 1716 } ··· 933 1718 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 934 1719 } 935 1720 936 - func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 937 - l := rp.logger.With("handler", "SyncRepoFork") 1721 + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1722 + f, err := rp.repoResolver.Resolve(r) 1723 + if err != nil { 1724 + log.Println("failed to get repo and knot", err) 1725 + return 1726 + } 1727 + 1728 + noticeId := "operation-error" 1729 + branch := r.FormValue("branch") 1730 + if branch == "" { 1731 + http.Error(w, "malformed form", http.StatusBadRequest) 1732 + return 1733 + } 1734 + 1735 + client, err := rp.oauth.ServiceClient( 1736 + r, 1737 + oauth.WithService(f.Knot), 1738 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1739 + oauth.WithDev(rp.config.Core.Dev), 1740 + ) 1741 + if err != nil { 1742 + log.Println("failed to connect to knot server:", err) 1743 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1744 + return 1745 + } 1746 + 1747 + xe := tangled.RepoSetDefaultBranch( 1748 + r.Context(), 1749 + client, 1750 + &tangled.RepoSetDefaultBranch_Input{ 1751 + Repo: f.RepoAt().String(), 1752 + DefaultBranch: branch, 1753 + }, 1754 + ) 1755 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1756 + log.Println("xrpc failed", "err", xe) 1757 + rp.pages.Notice(w, noticeId, err.Error()) 1758 + return 1759 + } 1760 + 1761 + rp.pages.HxRefresh(w) 1762 + } 1763 + 1764 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1765 + user := rp.oauth.GetUser(r) 1766 + l := rp.logger.With("handler", "Secrets") 1767 + l = l.With("handle", user.Handle) 1768 + l = l.With("did", user.Did) 1769 + 1770 + f, err := rp.repoResolver.Resolve(r) 1771 + if err != nil { 1772 + log.Println("failed to get repo and knot", err) 1773 + return 1774 + } 1775 + 1776 + if f.Spindle == "" { 1777 + log.Println("empty spindle cannot add/rm secret", err) 1778 + return 1779 + } 1780 + 1781 + lxm := tangled.RepoAddSecretNSID 1782 + if r.Method == http.MethodDelete { 1783 + lxm = tangled.RepoRemoveSecretNSID 1784 + } 1785 + 1786 + spindleClient, err := rp.oauth.ServiceClient( 1787 + r, 1788 + oauth.WithService(f.Spindle), 1789 + oauth.WithLxm(lxm), 1790 + oauth.WithExp(60), 1791 + oauth.WithDev(rp.config.Core.Dev), 1792 + ) 1793 + if err != nil { 1794 + log.Println("failed to create spindle client", err) 1795 + return 1796 + } 1797 + 1798 + key := r.FormValue("key") 1799 + if key == "" { 1800 + w.WriteHeader(http.StatusBadRequest) 1801 + return 1802 + } 1803 + 1804 + switch r.Method { 1805 + case http.MethodPut: 1806 + errorId := "add-secret-error" 1807 + 1808 + value := r.FormValue("value") 1809 + if value == "" { 1810 + w.WriteHeader(http.StatusBadRequest) 1811 + return 1812 + } 1813 + 1814 + err = tangled.RepoAddSecret( 1815 + r.Context(), 1816 + spindleClient, 1817 + &tangled.RepoAddSecret_Input{ 1818 + Repo: f.RepoAt().String(), 1819 + Key: key, 1820 + Value: value, 1821 + }, 1822 + ) 1823 + if err != nil { 1824 + l.Error("Failed to add secret.", "err", err) 1825 + rp.pages.Notice(w, errorId, "Failed to add secret.") 1826 + return 1827 + } 1828 + 1829 + case http.MethodDelete: 1830 + errorId := "operation-error" 1831 + 1832 + err = tangled.RepoRemoveSecret( 1833 + r.Context(), 1834 + spindleClient, 1835 + &tangled.RepoRemoveSecret_Input{ 1836 + Repo: f.RepoAt().String(), 1837 + Key: key, 1838 + }, 1839 + ) 1840 + if err != nil { 1841 + l.Error("Failed to delete secret.", "err", err) 1842 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 1843 + return 1844 + } 1845 + } 1846 + 1847 + rp.pages.HxRefresh(w) 1848 + } 1849 + 1850 + type tab = map[string]any 1851 + 1852 + var ( 1853 + // would be great to have ordered maps right about now 1854 + settingsTabs []tab = []tab{ 1855 + {"Name": "general", "Icon": "sliders-horizontal"}, 1856 + {"Name": "access", "Icon": "users"}, 1857 + {"Name": "pipelines", "Icon": "layers-2"}, 1858 + } 1859 + ) 1860 + 1861 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1862 + tabVal := r.URL.Query().Get("tab") 1863 + if tabVal == "" { 1864 + tabVal = "general" 1865 + } 1866 + 1867 + switch tabVal { 1868 + case "general": 1869 + rp.generalSettings(w, r) 1870 + 1871 + case "access": 1872 + rp.accessSettings(w, r) 1873 + 1874 + case "pipelines": 1875 + rp.pipelineSettings(w, r) 1876 + } 1877 + } 1878 + 1879 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1880 + f, err := rp.repoResolver.Resolve(r) 1881 + user := rp.oauth.GetUser(r) 1882 + 1883 + scheme := "http" 1884 + if !rp.config.Core.Dev { 1885 + scheme = "https" 1886 + } 1887 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1888 + xrpcc := &indigoxrpc.Client{ 1889 + Host: host, 1890 + } 1891 + 1892 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1893 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1894 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1895 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1896 + rp.pages.Error503(w) 1897 + return 1898 + } 1899 + 1900 + var result types.RepoBranchesResponse 1901 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1902 + log.Println("failed to decode XRPC response", err) 1903 + rp.pages.Error503(w) 1904 + return 1905 + } 1906 + 1907 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1908 + if err != nil { 1909 + log.Println("failed to fetch labels", err) 1910 + rp.pages.Error503(w) 1911 + return 1912 + } 1913 + 1914 + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1915 + if err != nil { 1916 + log.Println("failed to fetch labels", err) 1917 + rp.pages.Error503(w) 1918 + return 1919 + } 1920 + // remove default labels from the labels list, if present 1921 + defaultLabelMap := make(map[string]bool) 1922 + for _, dl := range defaultLabels { 1923 + defaultLabelMap[dl.AtUri().String()] = true 1924 + } 1925 + n := 0 1926 + for _, l := range labels { 1927 + if !defaultLabelMap[l.AtUri().String()] { 1928 + labels[n] = l 1929 + n++ 1930 + } 1931 + } 1932 + labels = labels[:n] 1933 + 1934 + subscribedLabels := make(map[string]struct{}) 1935 + for _, l := range f.Repo.Labels { 1936 + subscribedLabels[l] = struct{}{} 1937 + } 1938 + 1939 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1940 + // if all default labels are subbed, show the "unsubscribe all" button 1941 + shouldSubscribeAll := false 1942 + for _, dl := range defaultLabels { 1943 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1944 + // one of the default labels is not subscribed to 1945 + shouldSubscribeAll = true 1946 + break 1947 + } 1948 + } 1949 + 1950 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1951 + LoggedInUser: user, 1952 + RepoInfo: f.RepoInfo(user), 1953 + Branches: result.Branches, 1954 + Labels: labels, 1955 + DefaultLabels: defaultLabels, 1956 + SubscribedLabels: subscribedLabels, 1957 + ShouldSubscribeAll: shouldSubscribeAll, 1958 + Tabs: settingsTabs, 1959 + Tab: "general", 1960 + }) 1961 + } 1962 + 1963 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1964 + f, err := rp.repoResolver.Resolve(r) 1965 + user := rp.oauth.GetUser(r) 1966 + 1967 + repoCollaborators, err := f.Collaborators(r.Context()) 1968 + if err != nil { 1969 + log.Println("failed to get collaborators", err) 1970 + } 1971 + 1972 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1973 + LoggedInUser: user, 1974 + RepoInfo: f.RepoInfo(user), 1975 + Tabs: settingsTabs, 1976 + Tab: "access", 1977 + Collaborators: repoCollaborators, 1978 + }) 1979 + } 1980 + 1981 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1982 + f, err := rp.repoResolver.Resolve(r) 1983 + user := rp.oauth.GetUser(r) 1984 + 1985 + // all spindles that the repo owner is a member of 1986 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1987 + if err != nil { 1988 + log.Println("failed to fetch spindles", err) 1989 + return 1990 + } 1991 + 1992 + var secrets []*tangled.RepoListSecrets_Secret 1993 + if f.Spindle != "" { 1994 + if spindleClient, err := rp.oauth.ServiceClient( 1995 + r, 1996 + oauth.WithService(f.Spindle), 1997 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1998 + oauth.WithExp(60), 1999 + oauth.WithDev(rp.config.Core.Dev), 2000 + ); err != nil { 2001 + log.Println("failed to create spindle client", err) 2002 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2003 + log.Println("failed to fetch secrets", err) 2004 + } else { 2005 + secrets = resp.Secrets 2006 + } 2007 + } 2008 + 2009 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2010 + return strings.Compare(a.Key, b.Key) 2011 + }) 2012 + 2013 + var dids []string 2014 + for _, s := range secrets { 2015 + dids = append(dids, s.CreatedBy) 2016 + } 2017 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 938 2018 2019 + // convert to a more manageable form 2020 + var niceSecret []map[string]any 2021 + for id, s := range secrets { 2022 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2023 + niceSecret = append(niceSecret, map[string]any{ 2024 + "Id": id, 2025 + "Key": s.Key, 2026 + "CreatedAt": when, 2027 + "CreatedBy": resolvedIdents[id].Handle.String(), 2028 + }) 2029 + } 2030 + 2031 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2032 + LoggedInUser: user, 2033 + RepoInfo: f.RepoInfo(user), 2034 + Tabs: settingsTabs, 2035 + Tab: "pipelines", 2036 + Spindles: spindles, 2037 + CurrentSpindle: f.Spindle, 2038 + Secrets: niceSecret, 2039 + }) 2040 + } 2041 + 2042 + func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 939 2043 ref := chi.URLParam(r, "ref") 940 2044 ref, _ = url.PathUnescape(ref) 941 2045 942 2046 user := rp.oauth.GetUser(r) 943 2047 f, err := rp.repoResolver.Resolve(r) 944 2048 if err != nil { 945 - l.Error("failed to resolve source repo", "err", err) 2049 + log.Printf("failed to resolve source repo: %v", err) 946 2050 return 947 2051 } 948 2052 ··· 986 2090 } 987 2091 988 2092 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 989 - l := rp.logger.With("handler", "ForkRepo") 990 - 991 2093 user := rp.oauth.GetUser(r) 992 2094 f, err := rp.repoResolver.Resolve(r) 993 2095 if err != nil { 994 - l.Error("failed to resolve source repo", "err", err) 2096 + log.Printf("failed to resolve source repo: %v", err) 995 2097 return 996 2098 } 997 2099 ··· 1027 2129 } 1028 2130 1029 2131 // choose a name for a fork 1030 - forkName := r.FormValue("repo_name") 1031 - if forkName == "" { 1032 - rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 1033 - return 1034 - } 1035 - 2132 + forkName := f.Name 1036 2133 // this check is *only* to see if the forked repo name already exists 1037 2134 // in the user's account. 1038 2135 existingRepo, err := db.GetRepo( 1039 2136 rp.db, 1040 2137 db.FilterEq("did", user.Did), 1041 - db.FilterEq("name", forkName), 2138 + db.FilterEq("name", f.Name), 1042 2139 ) 1043 2140 if err != nil { 1044 - if !errors.Is(err, sql.ErrNoRows) { 1045 - l.Error("error fetching existing repo from db", "err", err) 2141 + if errors.Is(err, sql.ErrNoRows) { 2142 + // no existing repo with this name found, we can use the name as is 2143 + } else { 2144 + log.Println("error fetching existing repo from db", "err", err) 1046 2145 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1047 2146 return 1048 2147 } 1049 2148 } else if existingRepo != nil { 1050 - // repo with this name already exists 1051 - rp.pages.Notice(w, "repo", "A repository with this name already exists.") 1052 - return 2149 + // repo with this name already exists, append random string 2150 + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1053 2151 } 1054 2152 l = l.With("forkName", forkName) 1055 2153 ··· 1073 2171 Source: sourceAt, 1074 2172 Description: f.Repo.Description, 1075 2173 Created: time.Now(), 1076 - Labels: rp.config.Label.DefaultLabelDefs, 2174 + Labels: models.DefaultLabelDefs(), 1077 2175 } 1078 2176 record := repo.AsRecord() 1079 2177 1080 - atpClient, err := rp.oauth.AuthorizedClient(r) 2178 + xrpcClient, err := rp.oauth.AuthorizedClient(r) 1081 2179 if err != nil { 1082 2180 l.Error("failed to create xrpcclient", "err", err) 1083 2181 rp.pages.Notice(w, "repo", "Failed to fork repository.") 1084 2182 return 1085 2183 } 1086 2184 1087 - atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2185 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1088 2186 Collection: tangled.RepoNSID, 1089 2187 Repo: user.Did, 1090 2188 Rkey: rkey, ··· 1116 2214 rollback := func() { 1117 2215 err1 := tx.Rollback() 1118 2216 err2 := rp.enforcer.E.LoadPolicy() 1119 - err3 := rollbackRecord(context.Background(), aturi, atpClient) 2217 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1120 2218 1121 2219 // ignore txn complete errors, this is okay 1122 2220 if errors.Is(err1, sql.ErrTxDone) { ··· 1157 2255 1158 2256 err = db.AddRepo(tx, repo) 1159 2257 if err != nil { 1160 - l.Error("failed to AddRepo", "err", err) 2258 + log.Println(err) 1161 2259 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1162 2260 return 1163 2261 } ··· 1166 2264 p, _ := securejoin.SecureJoin(user.Did, forkName) 1167 2265 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1168 2266 if err != nil { 1169 - l.Error("failed to add ACLs", "err", err) 2267 + log.Println(err) 1170 2268 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1171 2269 return 1172 2270 } 1173 2271 1174 2272 err = tx.Commit() 1175 2273 if err != nil { 1176 - l.Error("failed to commit changes", "err", err) 2274 + log.Println("failed to commit changes", err) 1177 2275 http.Error(w, err.Error(), http.StatusInternalServerError) 1178 2276 return 1179 2277 } 1180 2278 1181 2279 err = rp.enforcer.E.SavePolicy() 1182 2280 if err != nil { 1183 - l.Error("failed to update ACLs", "err", err) 2281 + log.Println("failed to update ACLs", err) 1184 2282 http.Error(w, err.Error(), http.StatusInternalServerError) 1185 2283 return 1186 2284 } ··· 1189 2287 aturi = "" 1190 2288 1191 2289 rp.notifier.NewRepo(r.Context(), repo) 1192 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 2290 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1193 2291 } 1194 2292 } 1195 2293 1196 2294 // this is used to rollback changes made to the PDS 1197 2295 // 1198 2296 // it is a no-op if the provided ATURI is empty 1199 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2297 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1200 2298 if aturi == "" { 1201 2299 return nil 1202 2300 } ··· 1207 2305 repo := parsed.Authority().String() 1208 2306 rkey := parsed.RecordKey().String() 1209 2307 1210 - _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2308 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1211 2309 Collection: collection, 1212 2310 Repo: repo, 1213 2311 Rkey: rkey, 1214 2312 }) 1215 2313 return err 1216 2314 } 2315 + 2316 + func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2317 + user := rp.oauth.GetUser(r) 2318 + f, err := rp.repoResolver.Resolve(r) 2319 + if err != nil { 2320 + log.Println("failed to get repo and knot", err) 2321 + return 2322 + } 2323 + 2324 + scheme := "http" 2325 + if !rp.config.Core.Dev { 2326 + scheme = "https" 2327 + } 2328 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2329 + xrpcc := &indigoxrpc.Client{ 2330 + Host: host, 2331 + } 2332 + 2333 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2334 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2335 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2336 + log.Println("failed to call XRPC repo.branches", xrpcerr) 2337 + rp.pages.Error503(w) 2338 + return 2339 + } 2340 + 2341 + var branchResult types.RepoBranchesResponse 2342 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2343 + log.Println("failed to decode XRPC branches response", err) 2344 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2345 + return 2346 + } 2347 + branches := branchResult.Branches 2348 + 2349 + sortBranches(branches) 2350 + 2351 + var defaultBranch string 2352 + for _, b := range branches { 2353 + if b.IsDefault { 2354 + defaultBranch = b.Name 2355 + } 2356 + } 2357 + 2358 + base := defaultBranch 2359 + head := defaultBranch 2360 + 2361 + params := r.URL.Query() 2362 + queryBase := params.Get("base") 2363 + queryHead := params.Get("head") 2364 + if queryBase != "" { 2365 + base = queryBase 2366 + } 2367 + if queryHead != "" { 2368 + head = queryHead 2369 + } 2370 + 2371 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2372 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2373 + log.Println("failed to call XRPC repo.tags", xrpcerr) 2374 + rp.pages.Error503(w) 2375 + return 2376 + } 2377 + 2378 + var tags types.RepoTagsResponse 2379 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 2380 + log.Println("failed to decode XRPC tags response", err) 2381 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2382 + return 2383 + } 2384 + 2385 + repoinfo := f.RepoInfo(user) 2386 + 2387 + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2388 + LoggedInUser: user, 2389 + RepoInfo: repoinfo, 2390 + Branches: branches, 2391 + Tags: tags.Tags, 2392 + Base: base, 2393 + Head: head, 2394 + }) 2395 + } 2396 + 2397 + func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2398 + user := rp.oauth.GetUser(r) 2399 + f, err := rp.repoResolver.Resolve(r) 2400 + if err != nil { 2401 + log.Println("failed to get repo and knot", err) 2402 + return 2403 + } 2404 + 2405 + var diffOpts types.DiffOpts 2406 + if d := r.URL.Query().Get("diff"); d == "split" { 2407 + diffOpts.Split = true 2408 + } 2409 + 2410 + // if user is navigating to one of 2411 + // /compare/{base}/{head} 2412 + // /compare/{base}...{head} 2413 + base := chi.URLParam(r, "base") 2414 + head := chi.URLParam(r, "head") 2415 + if base == "" && head == "" { 2416 + rest := chi.URLParam(r, "*") // master...feature/xyz 2417 + parts := strings.SplitN(rest, "...", 2) 2418 + if len(parts) == 2 { 2419 + base = parts[0] 2420 + head = parts[1] 2421 + } 2422 + } 2423 + 2424 + base, _ = url.PathUnescape(base) 2425 + head, _ = url.PathUnescape(head) 2426 + 2427 + if base == "" || head == "" { 2428 + log.Printf("invalid comparison") 2429 + rp.pages.Error404(w) 2430 + return 2431 + } 2432 + 2433 + scheme := "http" 2434 + if !rp.config.Core.Dev { 2435 + scheme = "https" 2436 + } 2437 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2438 + xrpcc := &indigoxrpc.Client{ 2439 + Host: host, 2440 + } 2441 + 2442 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2443 + 2444 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2445 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2446 + log.Println("failed to call XRPC repo.branches", xrpcerr) 2447 + rp.pages.Error503(w) 2448 + return 2449 + } 2450 + 2451 + var branches types.RepoBranchesResponse 2452 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 2453 + log.Println("failed to decode XRPC branches response", err) 2454 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2455 + return 2456 + } 2457 + 2458 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2459 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2460 + log.Println("failed to call XRPC repo.tags", xrpcerr) 2461 + rp.pages.Error503(w) 2462 + return 2463 + } 2464 + 2465 + var tags types.RepoTagsResponse 2466 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 2467 + log.Println("failed to decode XRPC tags response", err) 2468 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2469 + return 2470 + } 2471 + 2472 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2473 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2474 + log.Println("failed to call XRPC repo.compare", xrpcerr) 2475 + rp.pages.Error503(w) 2476 + return 2477 + } 2478 + 2479 + var formatPatch types.RepoFormatPatchResponse 2480 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2481 + log.Println("failed to decode XRPC compare response", err) 2482 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2483 + return 2484 + } 2485 + 2486 + diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2487 + 2488 + repoinfo := f.RepoInfo(user) 2489 + 2490 + rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2491 + LoggedInUser: user, 2492 + RepoInfo: repoinfo, 2493 + Branches: branches.Branches, 2494 + Tags: tags.Tags, 2495 + Base: base, 2496 + Head: head, 2497 + Diff: &diff, 2498 + DiffOpts: diffOpts, 2499 + }) 2500 + 2501 + }
+37 -2
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 4 5 "crypto/rand" 6 + "fmt" 5 7 "math/big" 6 8 "slices" 7 9 "sort" ··· 17 19 18 20 func sortFiles(files []types.NiceTree) { 19 21 sort.Slice(files, func(i, j int) bool { 20 - iIsFile := files[i].IsFile() 21 - jIsFile := files[j].IsFile() 22 + iIsFile := files[i].IsFile 23 + jIsFile := files[j].IsFile 22 24 if iIsFile != jIsFile { 23 25 return !iIsFile 24 26 } ··· 88 90 } 89 91 90 92 return 93 + } 94 + 95 + // emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid 96 + // and resolves all dids to handles and returns a new map[string]string 97 + func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string { 98 + if emailToDidMap == nil { 99 + return nil 100 + } 101 + 102 + var dids []string 103 + for _, v := range emailToDidMap { 104 + dids = append(dids, v) 105 + } 106 + resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids) 107 + 108 + didHandleMap := make(map[string]string) 109 + for _, identity := range resolvedIdents { 110 + if !identity.Handle.IsInvalidHandle() { 111 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 + } else { 113 + didHandleMap[identity.DID.String()] = identity.DID.String() 114 + } 115 + } 116 + 117 + // Create map of email to didOrHandle for commit display 118 + emailToDidOrHandle := make(map[string]string) 119 + for email, did := range emailToDidMap { 120 + if didOrHandle, ok := didHandleMap[did]; ok { 121 + emailToDidOrHandle[email] = didOrHandle 122 + } 123 + } 124 + 125 + return emailToDidOrHandle 91 126 } 92 127 93 128 func randomString(n int) string {
+19 -16
appview/repo/router.go
··· 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 - r.Get("/", rp.Index) 13 - r.Get("/opengraph", rp.Opengraph) 14 - r.Get("/feed.atom", rp.AtomFeed) 15 - r.Get("/commits/{ref}", rp.Log) 12 + r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 14 + r.Get("/commits/{ref}", rp.RepoLog) 16 15 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.Index) 18 - r.Get("/*", rp.Tree) 16 + r.Get("/", rp.RepoIndex) 17 + r.Get("/*", rp.RepoTree) 19 18 }) 20 - r.Get("/commit/{ref}", rp.Commit) 21 - r.Get("/branches", rp.Branches) 22 - r.Delete("/branches", rp.DeleteBranch) 19 + r.Get("/commit/{ref}", rp.RepoCommit) 20 + r.Get("/branches", rp.RepoBranches) 23 21 r.Route("/tags", func(r chi.Router) { 24 - r.Get("/", rp.Tags) 22 + r.Get("/", rp.RepoTags) 25 23 r.Route("/{tag}", func(r chi.Router) { 26 24 r.Get("/download/{file}", rp.DownloadArtifact) 27 25 ··· 37 35 }) 38 36 }) 39 37 }) 40 - r.Get("/blob/{ref}/*", rp.Blob) 38 + r.Get("/blob/{ref}/*", rp.RepoBlob) 41 39 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 40 43 41 // intentionally doesn't use /* as this isn't ··· 54 52 }) 55 53 56 54 r.Route("/compare", func(r chi.Router) { 57 - r.Get("/", rp.CompareNew) // start an new comparison 55 + r.Get("/", rp.RepoCompareNew) // start an new comparison 58 56 59 57 // we have to wildcard here since we want to support GitHub's compare syntax 60 58 // /compare/{ref1}...{ref2} 61 59 // for example: 62 60 // /compare/master...some/feature 63 61 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.Compare) 65 - r.Get("/*", rp.Compare) 62 + r.Get("/{base}/{head}", rp.RepoCompare) 63 + r.Get("/*", rp.RepoCompare) 66 64 }) 67 65 68 66 // label panel in issues/pulls/discussions/tasks ··· 74 72 // settings routes, needs auth 75 73 r.Group(func(r chi.Router) { 76 74 r.Use(middleware.AuthMiddleware(rp.oauth)) 75 + // repo description can only be edited by owner 76 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { 77 + r.Put("/", rp.RepoDescription) 78 + r.Get("/", rp.RepoDescription) 79 + r.Get("/edit", rp.RepoDescriptionEdit) 80 + }) 77 81 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 78 - r.Get("/", rp.Settings) 79 - r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 82 + r.Get("/", rp.RepoSettings) 80 83 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 81 84 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 82 85 r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
-442
appview/repo/settings.go
··· 1 - package repo 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "slices" 8 - "strings" 9 - "time" 10 - 11 - "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/db" 13 - "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 - xrpcclient "tangled.org/core/appview/xrpcclient" 16 - "tangled.org/core/types" 17 - 18 - comatproto "github.com/bluesky-social/indigo/api/atproto" 19 - lexutil "github.com/bluesky-social/indigo/lex/util" 20 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 21 - ) 22 - 23 - type tab = map[string]any 24 - 25 - var ( 26 - // would be great to have ordered maps right about now 27 - settingsTabs []tab = []tab{ 28 - {"Name": "general", "Icon": "sliders-horizontal"}, 29 - {"Name": "access", "Icon": "users"}, 30 - {"Name": "pipelines", "Icon": "layers-2"}, 31 - } 32 - ) 33 - 34 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 35 - l := rp.logger.With("handler", "SetDefaultBranch") 36 - 37 - f, err := rp.repoResolver.Resolve(r) 38 - if err != nil { 39 - l.Error("failed to get repo and knot", "err", err) 40 - return 41 - } 42 - 43 - noticeId := "operation-error" 44 - branch := r.FormValue("branch") 45 - if branch == "" { 46 - http.Error(w, "malformed form", http.StatusBadRequest) 47 - return 48 - } 49 - 50 - client, err := rp.oauth.ServiceClient( 51 - r, 52 - oauth.WithService(f.Knot), 53 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 54 - oauth.WithDev(rp.config.Core.Dev), 55 - ) 56 - if err != nil { 57 - l.Error("failed to connect to knot server", "err", err) 58 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 59 - return 60 - } 61 - 62 - xe := tangled.RepoSetDefaultBranch( 63 - r.Context(), 64 - client, 65 - &tangled.RepoSetDefaultBranch_Input{ 66 - Repo: f.RepoAt().String(), 67 - DefaultBranch: branch, 68 - }, 69 - ) 70 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 71 - l.Error("xrpc failed", "err", xe) 72 - rp.pages.Notice(w, noticeId, err.Error()) 73 - return 74 - } 75 - 76 - rp.pages.HxRefresh(w) 77 - } 78 - 79 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 80 - user := rp.oauth.GetUser(r) 81 - l := rp.logger.With("handler", "Secrets") 82 - l = l.With("did", user.Did) 83 - 84 - f, err := rp.repoResolver.Resolve(r) 85 - if err != nil { 86 - l.Error("failed to get repo and knot", "err", err) 87 - return 88 - } 89 - 90 - if f.Spindle == "" { 91 - l.Error("empty spindle cannot add/rm secret", "err", err) 92 - return 93 - } 94 - 95 - lxm := tangled.RepoAddSecretNSID 96 - if r.Method == http.MethodDelete { 97 - lxm = tangled.RepoRemoveSecretNSID 98 - } 99 - 100 - spindleClient, err := rp.oauth.ServiceClient( 101 - r, 102 - oauth.WithService(f.Spindle), 103 - oauth.WithLxm(lxm), 104 - oauth.WithExp(60), 105 - oauth.WithDev(rp.config.Core.Dev), 106 - ) 107 - if err != nil { 108 - l.Error("failed to create spindle client", "err", err) 109 - return 110 - } 111 - 112 - key := r.FormValue("key") 113 - if key == "" { 114 - w.WriteHeader(http.StatusBadRequest) 115 - return 116 - } 117 - 118 - switch r.Method { 119 - case http.MethodPut: 120 - errorId := "add-secret-error" 121 - 122 - value := r.FormValue("value") 123 - if value == "" { 124 - w.WriteHeader(http.StatusBadRequest) 125 - return 126 - } 127 - 128 - err = tangled.RepoAddSecret( 129 - r.Context(), 130 - spindleClient, 131 - &tangled.RepoAddSecret_Input{ 132 - Repo: f.RepoAt().String(), 133 - Key: key, 134 - Value: value, 135 - }, 136 - ) 137 - if err != nil { 138 - l.Error("Failed to add secret.", "err", err) 139 - rp.pages.Notice(w, errorId, "Failed to add secret.") 140 - return 141 - } 142 - 143 - case http.MethodDelete: 144 - errorId := "operation-error" 145 - 146 - err = tangled.RepoRemoveSecret( 147 - r.Context(), 148 - spindleClient, 149 - &tangled.RepoRemoveSecret_Input{ 150 - Repo: f.RepoAt().String(), 151 - Key: key, 152 - }, 153 - ) 154 - if err != nil { 155 - l.Error("Failed to delete secret.", "err", err) 156 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 157 - return 158 - } 159 - } 160 - 161 - rp.pages.HxRefresh(w) 162 - } 163 - 164 - func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { 165 - tabVal := r.URL.Query().Get("tab") 166 - if tabVal == "" { 167 - tabVal = "general" 168 - } 169 - 170 - switch tabVal { 171 - case "general": 172 - rp.generalSettings(w, r) 173 - 174 - case "access": 175 - rp.accessSettings(w, r) 176 - 177 - case "pipelines": 178 - rp.pipelineSettings(w, r) 179 - } 180 - } 181 - 182 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 183 - l := rp.logger.With("handler", "generalSettings") 184 - 185 - f, err := rp.repoResolver.Resolve(r) 186 - user := rp.oauth.GetUser(r) 187 - 188 - scheme := "http" 189 - if !rp.config.Core.Dev { 190 - scheme = "https" 191 - } 192 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 193 - xrpcc := &indigoxrpc.Client{ 194 - Host: host, 195 - } 196 - 197 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 198 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 199 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 200 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 201 - rp.pages.Error503(w) 202 - return 203 - } 204 - 205 - var result types.RepoBranchesResponse 206 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 207 - l.Error("failed to decode XRPC response", "err", err) 208 - rp.pages.Error503(w) 209 - return 210 - } 211 - 212 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 213 - if err != nil { 214 - l.Error("failed to fetch labels", "err", err) 215 - rp.pages.Error503(w) 216 - return 217 - } 218 - 219 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 220 - if err != nil { 221 - l.Error("failed to fetch labels", "err", err) 222 - rp.pages.Error503(w) 223 - return 224 - } 225 - // remove default labels from the labels list, if present 226 - defaultLabelMap := make(map[string]bool) 227 - for _, dl := range defaultLabels { 228 - defaultLabelMap[dl.AtUri().String()] = true 229 - } 230 - n := 0 231 - for _, l := range labels { 232 - if !defaultLabelMap[l.AtUri().String()] { 233 - labels[n] = l 234 - n++ 235 - } 236 - } 237 - labels = labels[:n] 238 - 239 - subscribedLabels := make(map[string]struct{}) 240 - for _, l := range f.Repo.Labels { 241 - subscribedLabels[l] = struct{}{} 242 - } 243 - 244 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 245 - // if all default labels are subbed, show the "unsubscribe all" button 246 - shouldSubscribeAll := false 247 - for _, dl := range defaultLabels { 248 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 249 - // one of the default labels is not subscribed to 250 - shouldSubscribeAll = true 251 - break 252 - } 253 - } 254 - 255 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 256 - LoggedInUser: user, 257 - RepoInfo: f.RepoInfo(user), 258 - Branches: result.Branches, 259 - Labels: labels, 260 - DefaultLabels: defaultLabels, 261 - SubscribedLabels: subscribedLabels, 262 - ShouldSubscribeAll: shouldSubscribeAll, 263 - Tabs: settingsTabs, 264 - Tab: "general", 265 - }) 266 - } 267 - 268 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 269 - l := rp.logger.With("handler", "accessSettings") 270 - 271 - f, err := rp.repoResolver.Resolve(r) 272 - user := rp.oauth.GetUser(r) 273 - 274 - repoCollaborators, err := f.Collaborators(r.Context()) 275 - if err != nil { 276 - l.Error("failed to get collaborators", "err", err) 277 - } 278 - 279 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 280 - LoggedInUser: user, 281 - RepoInfo: f.RepoInfo(user), 282 - Tabs: settingsTabs, 283 - Tab: "access", 284 - Collaborators: repoCollaborators, 285 - }) 286 - } 287 - 288 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 289 - l := rp.logger.With("handler", "pipelineSettings") 290 - 291 - f, err := rp.repoResolver.Resolve(r) 292 - user := rp.oauth.GetUser(r) 293 - 294 - // all spindles that the repo owner is a member of 295 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 296 - if err != nil { 297 - l.Error("failed to fetch spindles", "err", err) 298 - return 299 - } 300 - 301 - var secrets []*tangled.RepoListSecrets_Secret 302 - if f.Spindle != "" { 303 - if spindleClient, err := rp.oauth.ServiceClient( 304 - r, 305 - oauth.WithService(f.Spindle), 306 - oauth.WithLxm(tangled.RepoListSecretsNSID), 307 - oauth.WithExp(60), 308 - oauth.WithDev(rp.config.Core.Dev), 309 - ); err != nil { 310 - l.Error("failed to create spindle client", "err", err) 311 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 312 - l.Error("failed to fetch secrets", "err", err) 313 - } else { 314 - secrets = resp.Secrets 315 - } 316 - } 317 - 318 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 319 - return strings.Compare(a.Key, b.Key) 320 - }) 321 - 322 - var dids []string 323 - for _, s := range secrets { 324 - dids = append(dids, s.CreatedBy) 325 - } 326 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 327 - 328 - // convert to a more manageable form 329 - var niceSecret []map[string]any 330 - for id, s := range secrets { 331 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 332 - niceSecret = append(niceSecret, map[string]any{ 333 - "Id": id, 334 - "Key": s.Key, 335 - "CreatedAt": when, 336 - "CreatedBy": resolvedIdents[id].Handle.String(), 337 - }) 338 - } 339 - 340 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 341 - LoggedInUser: user, 342 - RepoInfo: f.RepoInfo(user), 343 - Tabs: settingsTabs, 344 - Tab: "pipelines", 345 - Spindles: spindles, 346 - CurrentSpindle: f.Spindle, 347 - Secrets: niceSecret, 348 - }) 349 - } 350 - 351 - func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 352 - l := rp.logger.With("handler", "EditBaseSettings") 353 - 354 - noticeId := "repo-base-settings-error" 355 - 356 - f, err := rp.repoResolver.Resolve(r) 357 - if err != nil { 358 - l.Error("failed to get repo and knot", "err", err) 359 - w.WriteHeader(http.StatusBadRequest) 360 - return 361 - } 362 - 363 - client, err := rp.oauth.AuthorizedClient(r) 364 - if err != nil { 365 - l.Error("failed to get client") 366 - rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 367 - return 368 - } 369 - 370 - var ( 371 - description = r.FormValue("description") 372 - website = r.FormValue("website") 373 - topicStr = r.FormValue("topics") 374 - ) 375 - 376 - err = rp.validator.ValidateURI(website) 377 - if website != "" && err != nil { 378 - l.Error("invalid uri", "err", err) 379 - rp.pages.Notice(w, noticeId, err.Error()) 380 - return 381 - } 382 - 383 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 384 - if err != nil { 385 - l.Error("invalid topics", "err", err) 386 - rp.pages.Notice(w, noticeId, err.Error()) 387 - return 388 - } 389 - l.Debug("got", "topicsStr", topicStr, "topics", topics) 390 - 391 - newRepo := f.Repo 392 - newRepo.Description = description 393 - newRepo.Website = website 394 - newRepo.Topics = topics 395 - record := newRepo.AsRecord() 396 - 397 - tx, err := rp.db.BeginTx(r.Context(), nil) 398 - if err != nil { 399 - l.Error("failed to begin transaction", "err", err) 400 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 401 - return 402 - } 403 - defer tx.Rollback() 404 - 405 - err = db.PutRepo(tx, newRepo) 406 - if err != nil { 407 - l.Error("failed to update repository", "err", err) 408 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 409 - return 410 - } 411 - 412 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 413 - if err != nil { 414 - // failed to get record 415 - l.Error("failed to get repo record", "err", err) 416 - rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 417 - return 418 - } 419 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 420 - Collection: tangled.RepoNSID, 421 - Repo: newRepo.Did, 422 - Rkey: newRepo.Rkey, 423 - SwapRecord: ex.Cid, 424 - Record: &lexutil.LexiconTypeDecoder{ 425 - Val: &record, 426 - }, 427 - }) 428 - 429 - if err != nil { 430 - l.Error("failed to perferom update-repo query", "err", err) 431 - // failed to get record 432 - rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 433 - return 434 - } 435 - 436 - err = tx.Commit() 437 - if err != nil { 438 - l.Error("failed to commit", "err", err) 439 - } 440 - 441 - rp.pages.HxRefresh(w) 442 - }
-79
appview/repo/tags.go
··· 1 - package repo 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/appview/db" 10 - "tangled.org/core/appview/models" 11 - "tangled.org/core/appview/pages" 12 - xrpcclient "tangled.org/core/appview/xrpcclient" 13 - "tangled.org/core/types" 14 - 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/go-git/go-git/v5/plumbing" 17 - ) 18 - 19 - func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { 20 - l := rp.logger.With("handler", "RepoTags") 21 - f, err := rp.repoResolver.Resolve(r) 22 - if err != nil { 23 - l.Error("failed to get repo and knot", "err", err) 24 - return 25 - } 26 - scheme := "http" 27 - if !rp.config.Core.Dev { 28 - scheme = "https" 29 - } 30 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 - xrpcc := &indigoxrpc.Client{ 32 - Host: host, 33 - } 34 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 36 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 38 - rp.pages.Error503(w) 39 - return 40 - } 41 - var result types.RepoTagsResponse 42 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 43 - l.Error("failed to decode XRPC response", "err", err) 44 - rp.pages.Error503(w) 45 - return 46 - } 47 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 48 - if err != nil { 49 - l.Error("failed grab artifacts", "err", err) 50 - return 51 - } 52 - // convert artifacts to map for easy UI building 53 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 54 - for _, a := range artifacts { 55 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 56 - } 57 - var danglingArtifacts []models.Artifact 58 - for _, a := range artifacts { 59 - found := false 60 - for _, t := range result.Tags { 61 - if t.Tag != nil { 62 - if t.Tag.Hash == a.Tag { 63 - found = true 64 - } 65 - } 66 - } 67 - if !found { 68 - danglingArtifacts = append(danglingArtifacts, a) 69 - } 70 - } 71 - user := rp.oauth.GetUser(r) 72 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 73 - LoggedInUser: user, 74 - RepoInfo: f.RepoInfo(user), 75 - RepoTagsResponse: result, 76 - ArtifactMap: artifactMap, 77 - DanglingArtifacts: danglingArtifacts, 78 - }) 79 - }
-106
appview/repo/tree.go
··· 1 - package repo 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "net/url" 7 - "strings" 8 - "time" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/appview/pages" 12 - xrpcclient "tangled.org/core/appview/xrpcclient" 13 - "tangled.org/core/types" 14 - 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/go-chi/chi/v5" 17 - "github.com/go-git/go-git/v5/plumbing" 18 - ) 19 - 20 - func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { 21 - l := rp.logger.With("handler", "RepoTree") 22 - f, err := rp.repoResolver.Resolve(r) 23 - if err != nil { 24 - l.Error("failed to fully resolve repo", "err", err) 25 - return 26 - } 27 - ref := chi.URLParam(r, "ref") 28 - ref, _ = url.PathUnescape(ref) 29 - // if the tree path has a trailing slash, let's strip it 30 - // so we don't 404 31 - treePath := chi.URLParam(r, "*") 32 - treePath, _ = url.PathUnescape(treePath) 33 - treePath = strings.TrimSuffix(treePath, "/") 34 - scheme := "http" 35 - if !rp.config.Core.Dev { 36 - scheme = "https" 37 - } 38 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 39 - xrpcc := &indigoxrpc.Client{ 40 - Host: host, 41 - } 42 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 43 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 44 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 45 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 46 - rp.pages.Error503(w) 47 - return 48 - } 49 - // Convert XRPC response to internal types.RepoTreeResponse 50 - files := make([]types.NiceTree, len(xrpcResp.Files)) 51 - for i, xrpcFile := range xrpcResp.Files { 52 - file := types.NiceTree{ 53 - Name: xrpcFile.Name, 54 - Mode: xrpcFile.Mode, 55 - Size: int64(xrpcFile.Size), 56 - } 57 - // Convert last commit info if present 58 - if xrpcFile.Last_commit != nil { 59 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 60 - file.LastCommit = &types.LastCommitInfo{ 61 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 62 - Message: xrpcFile.Last_commit.Message, 63 - When: commitWhen, 64 - } 65 - } 66 - files[i] = file 67 - } 68 - result := types.RepoTreeResponse{ 69 - Ref: xrpcResp.Ref, 70 - Files: files, 71 - } 72 - if xrpcResp.Parent != nil { 73 - result.Parent = *xrpcResp.Parent 74 - } 75 - if xrpcResp.Dotdot != nil { 76 - result.DotDot = *xrpcResp.Dotdot 77 - } 78 - if xrpcResp.Readme != nil { 79 - result.ReadmeFileName = xrpcResp.Readme.Filename 80 - result.Readme = xrpcResp.Readme.Contents 81 - } 82 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 83 - // so we can safely redirect to the "parent" (which is the same file). 84 - if len(result.Files) == 0 && result.Parent == treePath { 85 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 86 - http.Redirect(w, r, redirectTo, http.StatusFound) 87 - return 88 - } 89 - user := rp.oauth.GetUser(r) 90 - var breadcrumbs [][]string 91 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 92 - if treePath != "" { 93 - for idx, elem := range strings.Split(treePath, "/") { 94 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 95 - } 96 - } 97 - sortFiles(result.Files) 98 - 99 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 100 - LoggedInUser: user, 101 - BreadCrumbs: breadcrumbs, 102 - TreePath: treePath, 103 - RepoInfo: f.RepoInfo(user), 104 - RepoTreeResponse: result, 105 - }) 106 - }
-2
appview/reporesolver/resolver.go
··· 188 188 Rkey: f.Repo.Rkey, 189 189 RepoAt: repoAt, 190 190 Description: f.Description, 191 - Website: f.Website, 192 - Topics: f.Topics, 193 191 IsStarred: isStarred, 194 192 Knot: knot, 195 193 Spindle: f.Spindle,
+4 -6
appview/settings/settings.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 - "github.com/bluesky-social/indigo/atproto/syntax" 26 25 lexutil "github.com/bluesky-social/indigo/lex/util" 27 26 "github.com/gliderlabs/ssh" 28 27 "github.com/google/uuid" ··· 92 91 user := s.OAuth.GetUser(r) 93 92 did := s.OAuth.GetDid(r) 94 93 95 - prefs, err := db.GetNotificationPreference(s.Db, did) 94 + prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 96 95 if err != nil { 97 96 log.Printf("failed to get notification preferences: %s", err) 98 97 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") ··· 111 110 did := s.OAuth.GetDid(r) 112 111 113 112 prefs := &models.NotificationPreferences{ 114 - UserDid: syntax.DID(did), 113 + UserDid: did, 115 114 RepoStarred: r.FormValue("repo_starred") == "on", 116 115 IssueCreated: r.FormValue("issue_created") == "on", 117 116 IssueCommented: r.FormValue("issue_commented") == "on", ··· 120 119 PullCommented: r.FormValue("pull_commented") == "on", 121 120 PullMerged: r.FormValue("pull_merged") == "on", 122 121 Followed: r.FormValue("followed") == "on", 123 - UserMentioned: r.FormValue("user_mentioned") == "on", 124 122 EmailNotifications: r.FormValue("email_notifications") == "on", 125 123 } 126 124 ··· 472 470 } 473 471 474 472 // store in pds too 475 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 473 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 476 474 Collection: tangled.PublicKeyNSID, 477 475 Repo: did, 478 476 Rkey: rkey, ··· 529 527 530 528 if rkey != "" { 531 529 // remove from pds too 532 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 530 + _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 533 531 Collection: tangled.PublicKeyNSID, 534 532 Repo: did, 535 533 Rkey: rkey,
-18
appview/signup/requests.go
··· 102 102 103 103 return result.DID, nil 104 104 } 105 - 106 - func (s *Signup) deleteAccountRequest(did string) error { 107 - body := map[string]string{ 108 - "did": did, 109 - } 110 - 111 - resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true) 112 - if err != nil { 113 - return err 114 - } 115 - defer resp.Body.Close() 116 - 117 - if resp.StatusCode != http.StatusOK { 118 - return s.handlePdsError(resp, "delete account") 119 - } 120 - 121 - return nil 122 - }
+40 -95
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 - "context" 6 5 "encoding/json" 7 6 "errors" 8 7 "fmt" ··· 21 20 "tangled.org/core/appview/models" 22 21 "tangled.org/core/appview/pages" 23 22 "tangled.org/core/appview/state/userutil" 23 + "tangled.org/core/appview/xrpcclient" 24 24 "tangled.org/core/idresolver" 25 25 ) 26 26 ··· 29 29 db *db.DB 30 30 cf *dns.Cloudflare 31 31 posthog posthog.Client 32 + xrpc *xrpcclient.Client 32 33 idResolver *idresolver.Resolver 33 34 pages *pages.Pages 34 35 l *slog.Logger ··· 63 64 disallowed := make(map[string]bool) 64 65 65 66 if filepath == "" { 66 - logger.Warn("no disallowed nicknames file configured") 67 + logger.Debug("no disallowed nicknames file configured") 67 68 return disallowed 68 69 } 69 70 ··· 132 133 noticeId := "signup-msg" 133 134 134 135 if err := s.validateCaptcha(cfToken, r); err != nil { 135 - s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 136 + s.l.Warn("turnstile validation failed", "error", err) 136 137 s.pages.Notice(w, noticeId, "Captcha validation failed.") 137 138 return 138 139 } ··· 217 218 return 218 219 } 219 220 221 + did, err := s.createAccountRequest(username, password, email, code) 222 + if err != nil { 223 + s.l.Error("failed to create account", "error", err) 224 + s.pages.Notice(w, "signup-error", err.Error()) 225 + return 226 + } 227 + 220 228 if s.cf == nil { 221 229 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 222 230 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 223 231 return 224 232 } 225 233 226 - // Execute signup transactionally with rollback capability 227 - err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 234 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 235 + Type: "TXT", 236 + Name: "_atproto." + username, 237 + Content: fmt.Sprintf(`"did=%s"`, did), 238 + TTL: 6400, 239 + Proxied: false, 240 + }) 228 241 if err != nil { 229 - // Error already logged and notice already sent 242 + s.l.Error("failed to create DNS record", "error", err) 243 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 230 244 return 231 245 } 232 - } 233 - } 234 246 235 - // executeSignupTransaction performs the signup process transactionally with rollback 236 - func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 237 - var recordID string 238 - var did string 239 - var emailAdded bool 247 + err = db.AddEmail(s.db, models.Email{ 248 + Did: did, 249 + Address: email, 250 + Verified: true, 251 + Primary: true, 252 + }) 253 + if err != nil { 254 + s.l.Error("failed to add email", "error", err) 255 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 256 + return 257 + } 240 258 241 - success := false 242 - defer func() { 243 - if !success { 244 - s.l.Info("rolling back signup transaction", "username", username, "did", did) 259 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 260 + <a class="underline text-black dark:text-white" href="/login">login</a> 261 + with <code>%s.tngl.sh</code>.`, username)) 245 262 246 - // Rollback DNS record 247 - if recordID != "" { 248 - if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 249 - s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 250 - } else { 251 - s.l.Info("successfully rolled back DNS record", "recordID", recordID) 252 - } 263 + go func() { 264 + err := db.DeleteInflightSignup(s.db, email) 265 + if err != nil { 266 + s.l.Error("failed to delete inflight signup", "error", err) 253 267 } 254 - 255 - // Rollback PDS account 256 - if did != "" { 257 - if err := s.deleteAccountRequest(did); err != nil { 258 - s.l.Error("failed to rollback PDS account", "error", err, "did", did) 259 - } else { 260 - s.l.Info("successfully rolled back PDS account", "did", did) 261 - } 262 - } 263 - 264 - // Rollback email from database 265 - if emailAdded { 266 - if err := db.DeleteEmail(s.db, did, email); err != nil { 267 - s.l.Error("failed to rollback email from database", "error", err, "email", email) 268 - } else { 269 - s.l.Info("successfully rolled back email from database", "email", email) 270 - } 271 - } 272 - } 273 - }() 274 - 275 - // step 1: create account in PDS 276 - did, err := s.createAccountRequest(username, password, email, code) 277 - if err != nil { 278 - s.l.Error("failed to create account", "error", err) 279 - s.pages.Notice(w, "signup-error", err.Error()) 280 - return err 268 + }() 269 + return 281 270 } 282 - 283 - // step 2: create DNS record with actual DID 284 - recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 - Type: "TXT", 286 - Name: "_atproto." + username, 287 - Content: fmt.Sprintf(`"did=%s"`, did), 288 - TTL: 6400, 289 - Proxied: false, 290 - }) 291 - if err != nil { 292 - s.l.Error("failed to create DNS record", "error", err) 293 - s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 - return err 295 - } 296 - 297 - // step 3: add email to database 298 - err = db.AddEmail(s.db, models.Email{ 299 - Did: did, 300 - Address: email, 301 - Verified: true, 302 - Primary: true, 303 - }) 304 - if err != nil { 305 - s.l.Error("failed to add email", "error", err) 306 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 307 - return err 308 - } 309 - emailAdded = true 310 - 311 - // if we get here, we've successfully created the account and added the email 312 - success = true 313 - 314 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 315 - <a class="underline text-black dark:text-white" href="/login">login</a> 316 - with <code>%s.tngl.sh</code>.`, username)) 317 - 318 - // clean up inflight signup asynchronously 319 - go func() { 320 - if err := db.DeleteInflightSignup(s.db, email); err != nil { 321 - s.l.Error("failed to delete inflight signup", "error", err) 322 - } 323 - }() 324 - 325 - return nil 326 271 } 327 272 328 273 type turnstileResponse struct {
+5 -14
appview/spindles/spindles.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 - "strings" 10 9 "time" 11 10 12 11 "github.com/go-chi/chi/v5" ··· 147 146 } 148 147 149 148 instance := r.FormValue("instance") 150 - // Strip protocol, trailing slashes, and whitespace 151 - // Rkey cannot contain slashes 152 - instance = strings.TrimSpace(instance) 153 - instance = strings.TrimPrefix(instance, "https://") 154 - instance = strings.TrimPrefix(instance, "http://") 155 - instance = strings.TrimSuffix(instance, "/") 156 149 if instance == "" { 157 150 s.Pages.Notice(w, noticeId, "Incomplete form.") 158 151 return ··· 196 189 return 197 190 } 198 191 199 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 200 193 var exCid *string 201 194 if ex != nil { 202 195 exCid = ex.Cid 203 196 } 204 197 205 198 // re-announce by registering under same rkey 206 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 199 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 207 200 Collection: tangled.SpindleNSID, 208 201 Repo: user.Did, 209 202 Rkey: instance, ··· 339 332 return 340 333 } 341 334 342 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 335 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 343 336 Collection: tangled.SpindleNSID, 344 337 Repo: user.Did, 345 338 Rkey: instance, ··· 491 484 } 492 485 493 486 member := r.FormValue("member") 494 - member = strings.TrimPrefix(member, "@") 495 487 if member == "" { 496 488 l.Error("empty member") 497 489 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 550 542 return 551 543 } 552 544 553 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 545 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 554 546 Collection: tangled.SpindleMemberNSID, 555 547 Repo: user.Did, 556 548 Rkey: rkey, ··· 621 613 } 622 614 623 615 member := r.FormValue("member") 624 - member = strings.TrimPrefix(member, "@") 625 616 if member == "" { 626 617 l.Error("empty member") 627 618 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") ··· 692 683 } 693 684 694 685 // remove from pds 695 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 686 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 696 687 Collection: tangled.SpindleMemberNSID, 697 688 Repo: user.Did, 698 689 Rkey: members[0].Rkey,
+2 -3
appview/state/follow.go
··· 26 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 27 if err != nil { 28 28 log.Println("failed to follow, invalid did") 29 - return 30 29 } 31 30 32 31 if currentUser.Did == subjectIdent.DID.String() { ··· 44 43 case http.MethodPost: 45 44 createdAt := time.Now().Format(time.RFC3339) 46 45 rkey := tid.TID() 47 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 46 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 48 47 Collection: tangled.GraphFollowNSID, 49 48 Repo: currentUser.Did, 50 49 Rkey: rkey, ··· 89 88 return 90 89 } 91 90 92 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 91 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 93 92 Collection: tangled.GraphFollowNSID, 94 93 Repo: currentUser.Did, 95 94 Rkey: follow.Rkey,
-154
appview/state/gfi.go
··· 1 - package state 2 - 3 - import ( 4 - "log" 5 - "net/http" 6 - "sort" 7 - 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - "tangled.org/core/appview/db" 10 - "tangled.org/core/appview/models" 11 - "tangled.org/core/appview/pages" 12 - "tangled.org/core/appview/pagination" 13 - "tangled.org/core/consts" 14 - ) 15 - 16 - func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 17 - user := s.oauth.GetUser(r) 18 - 19 - page := pagination.FromContext(r.Context()) 20 - 21 - goodFirstIssueLabel := s.config.Label.GoodFirstIssue 22 - 23 - gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel)) 24 - if err != nil { 25 - log.Println("failed to get gfi label def", err) 26 - s.pages.Error500(w) 27 - return 28 - } 29 - 30 - repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 31 - if err != nil { 32 - log.Println("failed to get repo labels", err) 33 - s.pages.Error503(w) 34 - return 35 - } 36 - 37 - if len(repoLabels) == 0 { 38 - s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 39 - LoggedInUser: user, 40 - RepoGroups: []*models.RepoGroup{}, 41 - LabelDefs: make(map[string]*models.LabelDefinition), 42 - Page: page, 43 - GfiLabel: gfiLabelDef, 44 - }) 45 - return 46 - } 47 - 48 - repoUris := make([]string, 0, len(repoLabels)) 49 - for _, rl := range repoLabels { 50 - repoUris = append(repoUris, rl.RepoAt.String()) 51 - } 52 - 53 - allIssues, err := db.GetIssuesPaginated( 54 - s.db, 55 - pagination.Page{ 56 - Limit: 500, 57 - }, 58 - db.FilterIn("repo_at", repoUris), 59 - db.FilterEq("open", 1), 60 - ) 61 - if err != nil { 62 - log.Println("failed to get issues", err) 63 - s.pages.Error503(w) 64 - return 65 - } 66 - 67 - var goodFirstIssues []models.Issue 68 - for _, issue := range allIssues { 69 - if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 70 - goodFirstIssues = append(goodFirstIssues, issue) 71 - } 72 - } 73 - 74 - repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 75 - for _, issue := range goodFirstIssues { 76 - if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 77 - group.Issues = append(group.Issues, issue) 78 - } else { 79 - repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 80 - Repo: issue.Repo, 81 - Issues: []models.Issue{issue}, 82 - } 83 - } 84 - } 85 - 86 - var sortedGroups []*models.RepoGroup 87 - for _, group := range repoGroups { 88 - sortedGroups = append(sortedGroups, group) 89 - } 90 - 91 - sort.Slice(sortedGroups, func(i, j int) bool { 92 - iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 93 - jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 94 - 95 - // If one is tangled and the other isn't, non-tangled comes first 96 - if iIsTangled != jIsTangled { 97 - return jIsTangled // true if j is tangled (i should come first) 98 - } 99 - 100 - // Both tangled or both not tangled: sort by name 101 - return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 102 - }) 103 - 104 - groupStart := page.Offset 105 - groupEnd := page.Offset + page.Limit 106 - if groupStart > len(sortedGroups) { 107 - groupStart = len(sortedGroups) 108 - } 109 - if groupEnd > len(sortedGroups) { 110 - groupEnd = len(sortedGroups) 111 - } 112 - 113 - paginatedGroups := sortedGroups[groupStart:groupEnd] 114 - 115 - var allIssuesFromGroups []models.Issue 116 - for _, group := range paginatedGroups { 117 - allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 118 - } 119 - 120 - var allLabelDefs []models.LabelDefinition 121 - if len(allIssuesFromGroups) > 0 { 122 - labelDefUris := make(map[string]bool) 123 - for _, issue := range allIssuesFromGroups { 124 - for labelDefUri := range issue.Labels.Inner() { 125 - labelDefUris[labelDefUri] = true 126 - } 127 - } 128 - 129 - uriList := make([]string, 0, len(labelDefUris)) 130 - for uri := range labelDefUris { 131 - uriList = append(uriList, uri) 132 - } 133 - 134 - if len(uriList) > 0 { 135 - allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 136 - if err != nil { 137 - log.Println("failed to fetch labels", err) 138 - } 139 - } 140 - } 141 - 142 - labelDefsMap := make(map[string]*models.LabelDefinition) 143 - for i := range allLabelDefs { 144 - labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 145 - } 146 - 147 - s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 148 - LoggedInUser: user, 149 - RepoGroups: paginatedGroups, 150 - LabelDefs: labelDefsMap, 151 - Page: page, 152 - GfiLabel: gfiLabelDef, 153 - }) 154 - }
+2 -17
appview/state/knotstream.go
··· 25 25 ) 26 26 27 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 - logger := log.FromContext(ctx) 29 - logger = log.SubLogger(logger, "knotstream") 30 - 31 28 knots, err := db.GetRegistrations( 32 29 d, 33 30 db.FilterIsNot("registered", "null"), ··· 42 39 srcs[s] = struct{}{} 43 40 } 44 41 42 + logger := log.New("knotstream") 45 43 cache := cache.New(c.Redis.Addr) 46 44 cursorStore := cursor.NewRedisCursorStore(cache) 47 45 ··· 174 172 }) 175 173 } 176 174 177 - tx, err := d.Begin() 178 - if err != nil { 179 - return err 180 - } 181 - defer tx.Rollback() 182 - 183 - // update appview's cache 184 - err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 185 - if err != nil { 186 - fmt.Printf("failed; %s\n", err) 187 - // non-fatal 188 - } 189 - 190 - return tx.Commit() 175 + return db.InsertRepoLanguages(d, langs) 191 176 } 192 177 193 178 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
-69
appview/state/login.go
··· 1 - package state 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "strings" 7 - 8 - "tangled.org/core/appview/pages" 9 - ) 10 - 11 - func (s *State) Login(w http.ResponseWriter, r *http.Request) { 12 - l := s.logger.With("handler", "Login") 13 - 14 - switch r.Method { 15 - case http.MethodGet: 16 - returnURL := r.URL.Query().Get("return_url") 17 - errorCode := r.URL.Query().Get("error") 18 - s.pages.Login(w, pages.LoginParams{ 19 - ReturnUrl: returnURL, 20 - ErrorCode: errorCode, 21 - }) 22 - case http.MethodPost: 23 - handle := r.FormValue("handle") 24 - 25 - // when users copy their handle from bsky.app, it tends to have these characters around it: 26 - // 27 - // @nelind.dk: 28 - // \u202a ensures that the handle is always rendered left to right and 29 - // \u202c reverts that so the rest of the page renders however it should 30 - handle = strings.TrimPrefix(handle, "\u202a") 31 - handle = strings.TrimSuffix(handle, "\u202c") 32 - 33 - // `@` is harmless 34 - handle = strings.TrimPrefix(handle, "@") 35 - 36 - // basic handle validation 37 - if !strings.Contains(handle, ".") { 38 - l.Error("invalid handle format", "raw", handle) 39 - s.pages.Notice( 40 - w, 41 - "login-msg", 42 - fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 43 - ) 44 - return 45 - } 46 - 47 - redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 - if err != nil { 49 - l.Error("failed to start auth", "err", err) 50 - http.Error(w, err.Error(), http.StatusInternalServerError) 51 - return 52 - } 53 - 54 - s.pages.HxRedirect(w, redirectURL) 55 - } 56 - } 57 - 58 - func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 - l := s.logger.With("handler", "Logout") 60 - 61 - err := s.oauth.DeleteSession(w, r) 62 - if err != nil { 63 - l.Error("failed to logout", "err", err) 64 - } else { 65 - l.Info("logged out successfully") 66 - } 67 - 68 - s.pages.HxRedirect(w, "/login") 69 - }
+2 -4
appview/state/profile.go
··· 538 538 profile.Description = r.FormValue("description") 539 539 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 540 540 profile.Location = r.FormValue("location") 541 - profile.Pronouns = r.FormValue("pronouns") 542 541 543 542 var links [5]string 544 543 for i := range 5 { ··· 635 634 vanityStats = append(vanityStats, string(v.Kind)) 636 635 } 637 636 638 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 639 638 var cid *string 640 639 if ex != nil { 641 640 cid = ex.Cid 642 641 } 643 642 644 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 643 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 645 644 Collection: tangled.ActorProfileNSID, 646 645 Repo: user.Did, 647 646 Rkey: "self", ··· 653 652 Location: &profile.Location, 654 653 PinnedRepositories: pinnedRepoStrings, 655 654 Stats: vanityStats[:], 656 - Pronouns: &profile.Pronouns, 657 655 }}, 658 656 SwapRecord: cid, 659 657 })
+9 -11
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - lexutil "github.com/bluesky-social/indigo/lex/util" 11 10 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" ··· 47 47 case http.MethodPost: 48 48 createdAt := time.Now().Format(time.RFC3339) 49 49 rkey := tid.TID() 50 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 50 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 52 Repo: currentUser.Did, 53 53 Rkey: rkey, ··· 70 70 return 71 71 } 72 72 73 - reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 73 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 74 74 if err != nil { 75 - log.Println("failed to get reactions for ", subjectUri) 75 + log.Println("failed to get reaction count for ", subjectUri) 76 76 } 77 77 78 78 log.Println("created atproto record: ", resp.Uri) ··· 80 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 81 ThreadAt: subjectUri, 82 82 Kind: reactionKind, 83 - Count: reactionMap[reactionKind].Count, 84 - Users: reactionMap[reactionKind].Users, 83 + Count: count, 85 84 IsReacted: true, 86 85 }) 87 86 ··· 93 92 return 94 93 } 95 94 96 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 97 96 Collection: tangled.FeedReactionNSID, 98 97 Repo: currentUser.Did, 99 98 Rkey: reaction.Rkey, ··· 110 109 // this is not an issue, the firehose event might have already done this 111 110 } 112 111 113 - reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 112 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 114 113 if err != nil { 115 - log.Println("failed to get reactions for ", subjectUri) 114 + log.Println("failed to get reaction count for ", subjectUri) 116 115 return 117 116 } 118 117 119 118 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 119 ThreadAt: subjectUri, 121 120 Kind: reactionKind, 122 - Count: reactionMap[reactionKind].Count, 123 - Users: reactionMap[reactionKind].Users, 121 + Count: count, 124 122 IsReacted: false, 125 123 }) 126 124
+59 -112
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 + "github.com/gorilla/sessions" 8 9 "tangled.org/core/appview/issues" 9 10 "tangled.org/core/appview/knots" 10 11 "tangled.org/core/appview/labels" 11 12 "tangled.org/core/appview/middleware" 12 13 "tangled.org/core/appview/notifications" 14 + oauthhandler "tangled.org/core/appview/oauth/handler" 13 15 "tangled.org/core/appview/pipelines" 14 16 "tangled.org/core/appview/pulls" 15 17 "tangled.org/core/appview/repo" ··· 32 34 s.pages, 33 35 ) 34 36 37 + router.Use(middleware.TryRefreshSession()) 35 38 router.Get("/favicon.svg", s.Favicon) 36 39 router.Get("/favicon.ico", s.Favicon) 37 - router.Get("/pwa-manifest.json", s.PWAManifest) 38 - router.Get("/robots.txt", s.RobotsTxt) 39 40 40 41 userRouter := s.UserRouter(&middleware) 41 42 standardRouter := s.StandardRouter(&middleware) 42 43 43 44 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 45 pat := chi.URLParam(r, "*") 45 - pathParts := strings.SplitN(pat, "/", 2) 46 - 47 - if len(pathParts) > 0 { 48 - firstPart := pathParts[0] 49 - 50 - // if using a DID or handle, just continue as per usual 51 - if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) { 52 - userRouter.ServeHTTP(w, r) 53 - return 54 - } 55 - 56 - // if using a flattened DID (like you would in go modules), unflatten 57 - if userutil.IsFlattenedDid(firstPart) { 58 - unflattenedDid := userutil.UnflattenDid(firstPart) 59 - redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 60 - 61 - redirectURL := *r.URL 62 - redirectURL.Path = "/" + redirectPath 63 - 64 - http.Redirect(w, r, redirectURL.String(), http.StatusFound) 65 - return 66 - } 67 - 68 - // if using a handle with @, rewrite to work without @ 69 - if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 70 - redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 71 - 72 - redirectURL := *r.URL 73 - redirectURL.Path = "/" + redirectPath 74 - 75 - http.Redirect(w, r, redirectURL.String(), http.StatusFound) 76 - return 46 + if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 47 + userRouter.ServeHTTP(w, r) 48 + } else { 49 + // Check if the first path element is a valid handle without '@' or a flattened DID 50 + pathParts := strings.SplitN(pat, "/", 2) 51 + if len(pathParts) > 0 { 52 + if userutil.IsHandleNoAt(pathParts[0]) { 53 + // Redirect to the same path but with '@' prefixed to the handle 54 + redirectPath := "@" + pat 55 + http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 56 + return 57 + } else if userutil.IsFlattenedDid(pathParts[0]) { 58 + // Redirect to the unflattened DID version 59 + unflattenedDid := userutil.UnflattenDid(pathParts[0]) 60 + var redirectPath string 61 + if len(pathParts) > 1 { 62 + redirectPath = unflattenedDid + "/" + pathParts[1] 63 + } else { 64 + redirectPath = unflattenedDid 65 + } 66 + http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 67 + return 68 + } 77 69 } 78 - 70 + standardRouter.ServeHTTP(w, r) 79 71 } 80 - 81 - standardRouter.ServeHTTP(w, r) 82 72 }) 83 73 84 74 return router ··· 91 81 r.Get("/", s.Profile) 92 82 r.Get("/feed.atom", s.AtomFeedPage) 93 83 84 + // redirect /@handle/repo.git -> /@handle/repo 85 + r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 86 + nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 87 + http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 88 + }) 89 + 94 90 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 95 91 r.Use(mw.GoImport()) 96 92 r.Mount("/", s.RepoRouter(mw)) 97 93 r.Mount("/issues", s.IssuesRouter(mw)) 98 94 r.Mount("/pulls", s.PullsRouter(mw)) 99 - r.Mount("/pipelines", s.PipelinesRouter()) 100 - r.Mount("/labels", s.LabelsRouter()) 95 + r.Mount("/pipelines", s.PipelinesRouter(mw)) 96 + r.Mount("/labels", s.LabelsRouter(mw)) 101 97 102 98 // These routes get proxied to the knot 103 99 r.Get("/info/refs", s.InfoRefs) ··· 126 122 // special-case handler for serving tangled.org/core 127 123 r.Get("/core", s.Core()) 128 124 129 - r.Get("/login", s.Login) 130 - r.Post("/login", s.Login) 131 - r.Post("/logout", s.Logout) 132 - 133 125 r.Route("/repo", func(r chi.Router) { 134 126 r.Route("/new", func(r chi.Router) { 135 127 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 138 130 }) 139 131 // r.Post("/import", s.ImportRepo) 140 132 }) 141 - 142 - r.Get("/goodfirstissues", s.GoodFirstIssues) 143 133 144 134 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 145 135 r.Post("/", s.Follow) ··· 171 161 r.Mount("/notifications", s.NotificationsRouter(mw)) 172 162 173 163 r.Mount("/signup", s.SignupRouter()) 174 - r.Mount("/", s.oauth.Router()) 164 + r.Mount("/", s.OAuthRouter()) 175 165 176 166 r.Get("/keys/{user}", s.Keys) 177 167 r.Get("/terms", s.TermsOfService) ··· 198 188 } 199 189 } 200 190 191 + func (s *State) OAuthRouter() http.Handler { 192 + store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 193 + oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 194 + return oauth.Router() 195 + } 196 + 201 197 func (s *State) SettingsRouter() http.Handler { 202 198 settings := &settings.Settings{ 203 199 Db: s.db, ··· 210 206 } 211 207 212 208 func (s *State) SpindlesRouter() http.Handler { 213 - logger := log.SubLogger(s.logger, "spindles") 209 + logger := log.New("spindles") 214 210 215 211 spindles := &spindles.Spindles{ 216 212 Db: s.db, ··· 226 222 } 227 223 228 224 func (s *State) KnotsRouter() http.Handler { 229 - logger := log.SubLogger(s.logger, "knots") 225 + logger := log.New("knots") 230 226 231 227 knots := &knots.Knots{ 232 228 Db: s.db, ··· 243 239 } 244 240 245 241 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 246 - logger := log.SubLogger(s.logger, "strings") 242 + logger := log.New("strings") 247 243 248 244 strs := &avstrings.Strings{ 249 245 Db: s.db, ··· 258 254 } 259 255 260 256 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 261 - issues := issues.New( 262 - s.oauth, 263 - s.repoResolver, 264 - s.pages, 265 - s.idResolver, 266 - s.db, 267 - s.config, 268 - s.notifier, 269 - s.validator, 270 - s.indexer.Issues, 271 - log.SubLogger(s.logger, "issues"), 272 - ) 257 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 273 258 return issues.Router(mw) 274 259 } 275 260 276 261 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 277 - pulls := pulls.New( 278 - s.oauth, 279 - s.repoResolver, 280 - s.pages, 281 - s.idResolver, 282 - s.db, 283 - s.config, 284 - s.notifier, 285 - s.enforcer, 286 - s.validator, 287 - s.indexer.Pulls, 288 - log.SubLogger(s.logger, "pulls"), 289 - ) 262 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 290 263 return pulls.Router(mw) 291 264 } 292 265 293 266 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 294 - repo := repo.New( 295 - s.oauth, 296 - s.repoResolver, 297 - s.pages, 298 - s.spindlestream, 299 - s.idResolver, 300 - s.db, 301 - s.config, 302 - s.notifier, 303 - s.enforcer, 304 - log.SubLogger(s.logger, "repo"), 305 - s.validator, 306 - ) 267 + logger := log.New("repo") 268 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 307 269 return repo.Router(mw) 308 270 } 309 271 310 - func (s *State) PipelinesRouter() http.Handler { 311 - pipes := pipelines.New( 312 - s.oauth, 313 - s.repoResolver, 314 - s.pages, 315 - s.spindlestream, 316 - s.idResolver, 317 - s.db, 318 - s.config, 319 - s.enforcer, 320 - log.SubLogger(s.logger, "pipelines"), 321 - ) 322 - return pipes.Router() 272 + func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 273 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 274 + return pipes.Router(mw) 323 275 } 324 276 325 - func (s *State) LabelsRouter() http.Handler { 326 - ls := labels.New( 327 - s.oauth, 328 - s.pages, 329 - s.db, 330 - s.validator, 331 - s.enforcer, 332 - log.SubLogger(s.logger, "labels"), 333 - ) 334 - return ls.Router() 277 + func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 278 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 279 + return ls.Router(mw) 335 280 } 336 281 337 282 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 338 - notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 283 + notifs := notifications.New(s.db, s.oauth, s.pages) 339 284 return notifs.Router(mw) 340 285 } 341 286 342 287 func (s *State) SignupRouter() http.Handler { 343 - sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 288 + logger := log.New("signup") 289 + 290 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 344 291 return sig.Router() 345 292 }
+1 -3
appview/state/spindlestream.go
··· 22 22 ) 23 23 24 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 - logger := log.FromContext(ctx) 26 - logger = log.SubLogger(logger, "spindlestream") 27 - 28 25 spindles, err := db.GetSpindles( 29 26 d, 30 27 db.FilterIsNot("verified", "null"), ··· 39 36 srcs[src] = struct{}{} 40 37 } 41 38 39 + logger := log.New("spindlestream") 42 40 cache := cache.New(c.Redis.Addr) 43 41 cursorStore := cursor.NewRedisCursorStore(cache) 44 42
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 43 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+47 -101
appview/state/state.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 + "log" 8 9 "log/slog" 9 10 "net/http" 10 11 "strings" 11 12 "time" 12 13 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + lexutil "github.com/bluesky-social/indigo/lex/util" 17 + securejoin "github.com/cyphar/filepath-securejoin" 18 + "github.com/go-chi/chi/v5" 19 + "github.com/posthog/posthog-go" 13 20 "tangled.org/core/api/tangled" 14 21 "tangled.org/core/appview" 22 + "tangled.org/core/appview/cache" 23 + "tangled.org/core/appview/cache/session" 15 24 "tangled.org/core/appview/config" 16 25 "tangled.org/core/appview/db" 17 - "tangled.org/core/appview/indexer" 18 26 "tangled.org/core/appview/models" 19 27 "tangled.org/core/appview/notify" 20 28 dbnotify "tangled.org/core/appview/notify/db" ··· 27 35 "tangled.org/core/eventconsumer" 28 36 "tangled.org/core/idresolver" 29 37 "tangled.org/core/jetstream" 30 - "tangled.org/core/log" 31 38 tlog "tangled.org/core/log" 32 39 "tangled.org/core/rbac" 33 40 "tangled.org/core/tid" 34 - 35 - comatproto "github.com/bluesky-social/indigo/api/atproto" 36 - atpclient "github.com/bluesky-social/indigo/atproto/client" 37 - "github.com/bluesky-social/indigo/atproto/syntax" 38 - lexutil "github.com/bluesky-social/indigo/lex/util" 39 - securejoin "github.com/cyphar/filepath-securejoin" 40 - "github.com/go-chi/chi/v5" 41 - "github.com/posthog/posthog-go" 42 41 ) 43 42 44 43 type State struct { 45 44 db *db.DB 46 45 notifier notify.Notifier 47 - indexer *indexer.Indexer 48 46 oauth *oauth.OAuth 49 47 enforcer *rbac.Enforcer 50 48 pages *pages.Pages 49 + sess *session.SessionStore 51 50 idResolver *idresolver.Resolver 52 51 posthog posthog.Client 53 52 jc *jetstream.JetstreamClient ··· 60 59 } 61 60 62 61 func Make(ctx context.Context, config *config.Config) (*State, error) { 63 - logger := tlog.FromContext(ctx) 64 - 65 - d, err := db.Make(ctx, config.Core.DbPath) 62 + d, err := db.Make(config.Core.DbPath) 66 63 if err != nil { 67 64 return nil, fmt.Errorf("failed to create db: %w", err) 68 65 } 69 66 70 - indexer := indexer.New(log.SubLogger(logger, "indexer")) 71 - err = indexer.Init(ctx, d) 72 - if err != nil { 73 - return nil, fmt.Errorf("failed to create indexer: %w", err) 74 - } 75 - 76 67 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 77 68 if err != nil { 78 69 return nil, fmt.Errorf("failed to create enforcer: %w", err) 79 70 } 80 71 81 - res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 72 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 82 73 if err != nil { 83 - logger.Error("failed to create redis resolver", "err", err) 84 - res = idresolver.DefaultResolver(config.Plc.PLCURL) 74 + log.Printf("failed to create redis resolver: %v", err) 75 + res = idresolver.DefaultResolver() 85 76 } 86 77 78 + pgs := pages.NewPages(config, res) 79 + cache := cache.New(config.Redis.Addr) 80 + sess := session.New(cache) 81 + oauth := oauth.NewOAuth(config, sess) 82 + validator := validator.New(d, res, enforcer) 83 + 87 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 88 85 if err != nil { 89 86 return nil, fmt.Errorf("failed to create posthog client: %w", err) 90 87 } 91 88 92 - pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 93 - oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 94 - if err != nil { 95 - return nil, fmt.Errorf("failed to start oauth handler: %w", err) 96 - } 97 - validator := validator.New(d, res, enforcer) 98 - 99 89 repoResolver := reporesolver.New(config, enforcer, res, d) 100 90 101 91 wrapper := db.DbWrapper{Execer: d} ··· 117 107 tangled.LabelOpNSID, 118 108 }, 119 109 nil, 120 - tlog.SubLogger(logger, "jetstream"), 110 + slog.Default(), 121 111 wrapper, 122 112 false, 123 113 ··· 129 119 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 130 120 } 131 121 132 - if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 122 + if err := BackfillDefaultDefs(d, res); err != nil { 133 123 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 134 124 } 135 125 ··· 138 128 Enforcer: enforcer, 139 129 IdResolver: res, 140 130 Config: config, 141 - Logger: log.SubLogger(logger, "ingester"), 131 + Logger: tlog.New("ingester"), 142 132 Validator: validator, 143 133 } 144 134 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 167 157 if !config.Core.Dev { 168 158 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 169 159 } 170 - notifiers = append(notifiers, indexer) 171 - notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 160 + notifier := notify.NewMergedNotifier(notifiers...) 172 161 173 162 state := &State{ 174 163 d, 175 164 notifier, 176 - indexer, 177 165 oauth, 178 166 enforcer, 179 - pages, 167 + pgs, 168 + sess, 180 169 res, 181 170 posthog, 182 171 jc, ··· 184 173 repoResolver, 185 174 knotstream, 186 175 spindlestream, 187 - logger, 176 + slog.Default(), 188 177 validator, 189 178 } 190 179 ··· 209 198 s.pages.Favicon(w) 210 199 } 211 200 212 - func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 213 - w.Header().Set("Content-Type", "text/plain") 214 - w.Header().Set("Cache-Control", "public, max-age=86400") // one day 215 - 216 - robotsTxt := `User-agent: * 217 - Allow: / 218 - ` 219 - w.Write([]byte(robotsTxt)) 220 - } 221 - 222 - // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 223 - const manifestJson = `{ 224 - "name": "tangled", 225 - "description": "tightly-knit social coding.", 226 - "icons": [ 227 - { 228 - "src": "/favicon.svg", 229 - "sizes": "144x144" 230 - } 231 - ], 232 - "start_url": "/", 233 - "id": "org.tangled", 234 - 235 - "display": "standalone", 236 - "background_color": "#111827", 237 - "theme_color": "#111827" 238 - }` 239 - 240 - func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 241 - w.Header().Set("Content-Type", "application/json") 242 - w.Write([]byte(manifestJson)) 243 - } 244 - 245 201 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 246 202 user := s.oauth.GetUser(r) 247 203 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 274 230 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 275 231 user := s.oauth.GetUser(r) 276 232 277 - // TODO: set this flag based on the UI 278 - filtered := false 279 - 280 233 var userDid string 281 234 if user != nil { 282 235 userDid = user.Did 283 236 } 284 - timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 237 + timeline, err := db.MakeTimeline(s.db, 50, userDid) 285 238 if err != nil { 286 - s.logger.Error("failed to make timeline", "err", err) 239 + log.Println(err) 287 240 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 288 241 } 289 242 290 243 repos, err := db.GetTopStarredReposLastWeek(s.db) 291 244 if err != nil { 292 - s.logger.Error("failed to get top starred repos", "err", err) 245 + log.Println(err) 293 246 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 294 247 return 295 248 } 296 249 297 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 - if err != nil { 299 - // non-fatal 300 - } 301 - 302 250 s.pages.Timeline(w, pages.TimelineParams{ 303 251 LoggedInUser: user, 304 252 Timeline: timeline, 305 253 Repos: repos, 306 - GfiLabel: gfiLabel, 307 254 }) 308 255 } 309 256 ··· 315 262 316 263 l := s.logger.With("handler", "UpgradeBanner") 317 264 l = l.With("did", user.Did) 265 + l = l.With("handle", user.Handle) 318 266 319 267 regs, err := db.GetRegistrations( 320 268 s.db, ··· 345 293 } 346 294 347 295 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 348 - // TODO: set this flag based on the UI 349 - filtered := false 350 - 351 - timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 296 + timeline, err := db.MakeTimeline(s.db, 5, "") 352 297 if err != nil { 353 - s.logger.Error("failed to make timeline", "err", err) 298 + log.Println(err) 354 299 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 355 300 return 356 301 } 357 302 358 303 repos, err := db.GetTopStarredReposLastWeek(s.db) 359 304 if err != nil { 360 - s.logger.Error("failed to get top starred repos", "err", err) 305 + log.Println(err) 361 306 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 362 307 return 363 308 } ··· 386 331 387 332 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 388 333 if err != nil { 389 - s.logger.Error("failed to get public keys", "err", err) 390 - http.Error(w, "failed to get public keys", http.StatusInternalServerError) 334 + w.WriteHeader(http.StatusNotFound) 391 335 return 392 336 } 393 337 394 338 if len(pubKeys) == 0 { 395 - w.WriteHeader(http.StatusNoContent) 339 + w.WriteHeader(http.StatusNotFound) 396 340 return 397 341 } 398 342 ··· 458 402 459 403 user := s.oauth.GetUser(r) 460 404 l = l.With("did", user.Did) 405 + l = l.With("handle", user.Handle) 461 406 462 407 // form validation 463 408 domain := r.FormValue("domain") ··· 517 462 Rkey: rkey, 518 463 Description: description, 519 464 Created: time.Now(), 520 - Labels: s.config.Label.DefaultLabelDefs, 465 + Labels: models.DefaultLabelDefs(), 521 466 } 522 467 record := repo.AsRecord() 523 468 524 - atpClient, err := s.oauth.AuthorizedClient(r) 469 + xrpcClient, err := s.oauth.AuthorizedClient(r) 525 470 if err != nil { 526 471 l.Info("PDS write failed", "err", err) 527 472 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 528 473 return 529 474 } 530 475 531 - atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 476 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 532 477 Collection: tangled.RepoNSID, 533 478 Repo: user.Did, 534 479 Rkey: rkey, ··· 560 505 rollback := func() { 561 506 err1 := tx.Rollback() 562 507 err2 := s.enforcer.E.LoadPolicy() 563 - err3 := rollbackRecord(context.Background(), aturi, atpClient) 508 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 564 509 565 510 // ignore txn complete errors, this is okay 566 511 if errors.Is(err1, sql.ErrTxDone) { ··· 633 578 aturi = "" 634 579 635 580 s.notifier.NewRepo(r.Context(), repo) 636 - s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 581 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 637 582 } 638 583 } 639 584 640 585 // this is used to rollback changes made to the PDS 641 586 // 642 587 // it is a no-op if the provided ATURI is empty 643 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 588 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 644 589 if aturi == "" { 645 590 return nil 646 591 } ··· 651 596 repo := parsed.Authority().String() 652 597 rkey := parsed.RecordKey().String() 653 598 654 - _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 599 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 655 600 Collection: collection, 656 601 Repo: repo, 657 602 Rkey: rkey, ··· 659 604 return err 660 605 } 661 606 662 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 607 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 608 + defaults := models.DefaultLabelDefs() 663 609 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 664 610 if err != nil { 665 611 return err ··· 669 615 return nil 670 616 } 671 617 672 - labelDefs, err := models.FetchLabelDefs(r, defaults) 618 + labelDefs, err := models.FetchDefaultDefs(r) 673 619 if err != nil { 674 620 return err 675 621 }
+6 -6
appview/state/userutil/userutil.go
··· 10 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 11 ) 12 12 13 - func IsHandle(s string) bool { 13 + func IsHandleNoAt(s string) bool { 14 14 // ref: https://atproto.com/specs/handle 15 15 return handleRegex.MatchString(s) 16 - } 17 - 18 - // IsDid checks if the given string is a standard DID. 19 - func IsDid(s string) bool { 20 - return didRegex.MatchString(s) 21 16 } 22 17 23 18 func UnflattenDid(s string) string { ··· 50 45 return strings.Replace(s, ":", "-", 2) 51 46 } 52 47 return s 48 + } 49 + 50 + // IsDid checks if the given string is a standard DID. 51 + func IsDid(s string) bool { 52 + return didRegex.MatchString(s) 53 53 } 54 54 55 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+7 -9
appview/strings/strings.go
··· 22 22 "github.com/bluesky-social/indigo/api/atproto" 23 23 "github.com/bluesky-social/indigo/atproto/identity" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - "github.com/go-chi/chi/v5" 26 - 27 - comatproto "github.com/bluesky-social/indigo/api/atproto" 28 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26 + "github.com/go-chi/chi/v5" 29 27 ) 30 28 31 29 type Strings struct { ··· 256 254 } 257 255 258 256 // first replace the existing record in the PDS 259 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 257 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 260 258 if err != nil { 261 259 fail("Failed to updated existing record.", err) 262 260 return 263 261 } 264 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 262 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 265 263 Collection: tangled.StringNSID, 266 264 Repo: entry.Did.String(), 267 265 Rkey: entry.Rkey, ··· 286 284 s.Notifier.EditString(r.Context(), &entry) 287 285 288 286 // if that went okay, redir to the string 289 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 287 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 290 288 } 291 289 292 290 } ··· 338 336 return 339 337 } 340 338 341 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 339 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 342 340 Collection: tangled.StringNSID, 343 341 Repo: user.Did, 344 342 Rkey: string.Rkey, ··· 362 360 s.Notifier.NewString(r.Context(), &string) 363 361 364 362 // successful 365 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 363 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 366 364 } 367 365 } 368 366 ··· 405 403 406 404 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 405 408 - s.Pages.HxRedirect(w, "/strings/"+user.Did) 406 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 409 407 } 410 408 411 409 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-25
appview/validator/patch.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/patchutil" 8 - ) 9 - 10 - func (v *Validator) ValidatePatch(patch *string) error { 11 - if patch == nil || *patch == "" { 12 - return fmt.Errorf("patch is empty") 13 - } 14 - 15 - // add newline if not present to diff style patches 16 - if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 - *patch = *patch + "\n" 18 - } 19 - 20 - if err := patchutil.IsPatchValid(*patch); err != nil { 21 - return err 22 - } 23 - 24 - return nil 25 - }
-53
appview/validator/repo_topics.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "maps" 6 - "regexp" 7 - "slices" 8 - "strings" 9 - ) 10 - 11 - const ( 12 - maxTopicLen = 50 13 - maxTopics = 20 14 - ) 15 - 16 - var ( 17 - topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 18 - ) 19 - 20 - // ValidateRepoTopicStr parses and validates whitespace-separated topic string. 21 - // 22 - // Rules: 23 - // - topics are separated by whitespace 24 - // - each topic may contain lowercase letters, digits, and hyphens only 25 - // - each topic must be <= 50 characters long 26 - // - no more than 20 topics allowed 27 - // - duplicates are removed 28 - func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { 29 - topicsStr = strings.TrimSpace(topicsStr) 30 - if topicsStr == "" { 31 - return nil, nil 32 - } 33 - parts := strings.Fields(topicsStr) 34 - if len(parts) > maxTopics { 35 - return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 36 - } 37 - 38 - topicSet := make(map[string]struct{}) 39 - 40 - for _, t := range parts { 41 - if _, exists := topicSet[t]; exists { 42 - continue 43 - } 44 - if len(t) > maxTopicLen { 45 - return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 46 - } 47 - if !topicRE.MatchString(t) { 48 - return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 49 - } 50 - topicSet[t] = struct{}{} 51 - } 52 - return slices.Collect(maps.Keys(topicSet)), nil 53 - }
-17
appview/validator/uri.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "net/url" 6 - ) 7 - 8 - func (v *Validator) ValidateURI(uri string) error { 9 - parsed, err := url.Parse(uri) 10 - if err != nil { 11 - return fmt.Errorf("invalid uri format") 12 - } 13 - if parsed.Scheme == "" { 14 - return fmt.Errorf("uri scheme missing") 15 - } 16 - return nil 17 - }
+99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "errors" 7 + "io" 5 8 "net/http" 6 9 10 + "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 7 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + oauth "tangled.sh/icyphox.sh/atproto-oauth" 8 14 ) 9 15 10 16 var ( ··· 13 19 ErrXrpcFailed = errors.New("xrpc request failed") 14 20 ErrXrpcInvalid = errors.New("invalid xrpc request") 15 21 ) 22 + 23 + type Client struct { 24 + *oauth.XrpcClient 25 + authArgs *oauth.XrpcAuthedRequestArgs 26 + } 27 + 28 + func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 + return &Client{ 30 + XrpcClient: client, 31 + authArgs: authArgs, 32 + } 33 + } 34 + 35 + func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 + var out atproto.RepoPutRecord_Output 37 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 + return nil, err 39 + } 40 + 41 + return &out, nil 42 + } 43 + 44 + func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 + var out atproto.RepoApplyWrites_Output 46 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 + return nil, err 48 + } 49 + 50 + return &out, nil 51 + } 52 + 53 + func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 + var out atproto.RepoGetRecord_Output 55 + 56 + params := map[string]interface{}{ 57 + "cid": cid, 58 + "collection": collection, 59 + "repo": repo, 60 + "rkey": rkey, 61 + } 62 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 + return nil, err 64 + } 65 + 66 + return &out, nil 67 + } 68 + 69 + func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 + var out atproto.RepoUploadBlob_Output 71 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 + return nil, err 73 + } 74 + 75 + return &out, nil 76 + } 77 + 78 + func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 + buf := new(bytes.Buffer) 80 + 81 + params := map[string]interface{}{ 82 + "cid": cid, 83 + "did": did, 84 + } 85 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 + return nil, err 87 + } 88 + 89 + return buf.Bytes(), nil 90 + } 91 + 92 + func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 + var out atproto.RepoDeleteRecord_Output 94 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 + return nil, err 96 + } 97 + 98 + return &out, nil 99 + } 100 + 101 + func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 + var out atproto.ServerGetServiceAuth_Output 103 + 104 + params := map[string]interface{}{ 105 + "aud": aud, 106 + "exp": exp, 107 + "lxm": lxm, 108 + } 109 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 + return nil, err 111 + } 112 + 113 + return &out, nil 114 + } 16 115 17 116 // produces a more manageable error 18 117 func HandleXrpcErr(err error) error {
+9 -14
cmd/appview/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log" 6 + "log/slog" 5 7 "net/http" 6 8 "os" 7 9 8 10 "tangled.org/core/appview/config" 9 11 "tangled.org/core/appview/state" 10 - tlog "tangled.org/core/log" 11 12 ) 12 13 13 14 func main() { 15 + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 + 14 17 ctx := context.Background() 15 - logger := tlog.New("appview") 16 - ctx = tlog.IntoContext(ctx, logger) 17 18 18 19 c, err := config.LoadConfig(ctx) 19 20 if err != nil { 20 - logger.Error("failed to load config", "error", err) 21 + log.Println("failed to load config", "error", err) 21 22 return 22 23 } 23 24 24 25 state, err := state.Make(ctx, c) 25 26 defer func() { 26 - if err := state.Close(); err != nil { 27 - logger.Error("failed to close state", "err", err) 28 - } 27 + log.Println(state.Close()) 29 28 }() 30 29 31 30 if err != nil { 32 - logger.Error("failed to start appview", "err", err) 33 - os.Exit(-1) 31 + log.Fatal(err) 34 32 } 35 33 36 - logger.Info("starting server", "address", c.Core.ListenAddr) 37 - 38 - if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 - logger.Error("failed to start appview", "err", err) 40 - } 34 + log.Println("starting server on", c.Core.ListenAddr) 35 + log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 41 36 }
-62
cmd/cborgen/cborgen.go
··· 1 - package main 2 - 3 - import ( 4 - cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.org/core/api/tangled" 6 - ) 7 - 8 - func main() { 9 - 10 - genCfg := cbg.Gen{ 11 - MaxStringLength: 1_000_000, 12 - } 13 - 14 - if err := genCfg.WriteMapEncodersToFile( 15 - "api/tangled/cbor_gen.go", 16 - "tangled", 17 - tangled.ActorProfile{}, 18 - tangled.FeedReaction{}, 19 - tangled.FeedStar{}, 20 - tangled.GitRefUpdate{}, 21 - tangled.GitRefUpdate_CommitCountBreakdown{}, 22 - tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_IndividualLanguageSize{}, 24 - tangled.GitRefUpdate_LangBreakdown{}, 25 - tangled.GitRefUpdate_Meta{}, 26 - tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 - tangled.KnotMember{}, 29 - tangled.LabelDefinition{}, 30 - tangled.LabelDefinition_ValueType{}, 31 - tangled.LabelOp{}, 32 - tangled.LabelOp_Operand{}, 33 - tangled.Pipeline{}, 34 - tangled.Pipeline_CloneOpts{}, 35 - tangled.Pipeline_ManualTriggerData{}, 36 - tangled.Pipeline_Pair{}, 37 - tangled.Pipeline_PullRequestTriggerData{}, 38 - tangled.Pipeline_PushTriggerData{}, 39 - tangled.PipelineStatus{}, 40 - tangled.Pipeline_TriggerMetadata{}, 41 - tangled.Pipeline_TriggerRepo{}, 42 - tangled.Pipeline_Workflow{}, 43 - tangled.PublicKey{}, 44 - tangled.Repo{}, 45 - tangled.RepoArtifact{}, 46 - tangled.RepoCollaborator{}, 47 - tangled.RepoIssue{}, 48 - tangled.RepoIssueComment{}, 49 - tangled.RepoIssueState{}, 50 - tangled.RepoPull{}, 51 - tangled.RepoPullComment{}, 52 - tangled.RepoPull_Source{}, 53 - tangled.RepoPullStatus{}, 54 - tangled.RepoPull_Target{}, 55 - tangled.Spindle{}, 56 - tangled.SpindleMember{}, 57 - tangled.String{}, 58 - ); err != nil { 59 - panic(err) 60 - } 61 - 62 - }
+62
cmd/gen.go
··· 1 + package main 2 + 3 + import ( 4 + cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.org/core/api/tangled" 6 + ) 7 + 8 + func main() { 9 + 10 + genCfg := cbg.Gen{ 11 + MaxStringLength: 1_000_000, 12 + } 13 + 14 + if err := genCfg.WriteMapEncodersToFile( 15 + "api/tangled/cbor_gen.go", 16 + "tangled", 17 + tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 19 + tangled.FeedStar{}, 20 + tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 + tangled.GitRefUpdate_Meta{}, 26 + tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 + tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 33 + tangled.Pipeline{}, 34 + tangled.Pipeline_CloneOpts{}, 35 + tangled.Pipeline_ManualTriggerData{}, 36 + tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_PullRequestTriggerData{}, 38 + tangled.Pipeline_PushTriggerData{}, 39 + tangled.PipelineStatus{}, 40 + tangled.Pipeline_TriggerMetadata{}, 41 + tangled.Pipeline_TriggerRepo{}, 42 + tangled.Pipeline_Workflow{}, 43 + tangled.PublicKey{}, 44 + tangled.Repo{}, 45 + tangled.RepoArtifact{}, 46 + tangled.RepoCollaborator{}, 47 + tangled.RepoIssue{}, 48 + tangled.RepoIssueComment{}, 49 + tangled.RepoIssueState{}, 50 + tangled.RepoPull{}, 51 + tangled.RepoPullComment{}, 52 + tangled.RepoPull_Source{}, 53 + tangled.RepoPullStatus{}, 54 + tangled.RepoPull_Target{}, 55 + tangled.Spindle{}, 56 + tangled.SpindleMember{}, 57 + tangled.String{}, 58 + ); err != nil { 59 + panic(err) 60 + } 61 + 62 + }
+43
cmd/genjwks/main.go
··· 1 + // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 2 + 3 + package main 4 + 5 + import ( 6 + "crypto/ecdsa" 7 + "crypto/elliptic" 8 + "crypto/rand" 9 + "encoding/json" 10 + "fmt" 11 + "time" 12 + 13 + "github.com/lestrrat-go/jwx/v2/jwk" 14 + ) 15 + 16 + func main() { 17 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 18 + if err != nil { 19 + panic(err) 20 + } 21 + 22 + key, err := jwk.FromRaw(privKey) 23 + if err != nil { 24 + panic(err) 25 + } 26 + 27 + kid := fmt.Sprintf("%d", time.Now().Unix()) 28 + 29 + if err := key.Set(jwk.KeyIDKey, kid); err != nil { 30 + panic(err) 31 + } 32 + 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 37 + b, err := json.Marshal(key) 38 + if err != nil { 39 + panic(err) 40 + } 41 + 42 + fmt.Println(string(b)) 43 + }
+3 -6
cmd/knot/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log/slog" 6 5 "os" 7 6 8 7 "github.com/urfave/cli/v3" ··· 10 9 "tangled.org/core/hook" 11 10 "tangled.org/core/keyfetch" 12 11 "tangled.org/core/knotserver" 13 - tlog "tangled.org/core/log" 12 + "tangled.org/core/log" 14 13 ) 15 14 16 15 func main() { ··· 25 24 }, 26 25 } 27 26 28 - logger := tlog.New("knot") 29 - slog.SetDefault(logger) 30 - 31 27 ctx := context.Background() 32 - ctx = tlog.IntoContext(ctx, logger) 28 + logger := log.New("knot") 29 + ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 33 30 34 31 if err := cmd.Run(ctx, os.Args); err != nil { 35 32 logger.Error(err.Error())
+49
cmd/punchcardPopulate/main.go
··· 1 + package main 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log" 7 + "math/rand" 8 + "time" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 12 + 13 + func main() { 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 + if err != nil { 16 + log.Fatal("Failed to open database:", err) 17 + } 18 + defer db.Close() 19 + 20 + const did = "did:plc:qfpnj4og54vl56wngdriaxug" 21 + 22 + now := time.Now() 23 + start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 24 + 25 + tx, err := db.Begin() 26 + if err != nil { 27 + log.Fatal(err) 28 + } 29 + stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)") 30 + if err != nil { 31 + log.Fatal(err) 32 + } 33 + defer stmt.Close() 34 + 35 + for day := start; !day.After(now); day = day.AddDate(0, 0, 1) { 36 + count := rand.Intn(16) // 0–5 37 + dateStr := day.Format("2006-01-02") 38 + _, err := stmt.Exec(did, dateStr, count) 39 + if err != nil { 40 + log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 + } 42 + } 43 + 44 + if err := tx.Commit(); err != nil { 45 + log.Fatal("Failed to commit:", err) 46 + } 47 + 48 + fmt.Println("Done populating punchcard.") 49 + }
+4 -9
cmd/spindle/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log/slog" 6 5 "os" 7 6 8 - tlog "tangled.org/core/log" 7 + "tangled.org/core/log" 9 8 "tangled.org/core/spindle" 9 + _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() { 13 - logger := tlog.New("spindle") 14 - slog.SetDefault(logger) 15 - 16 - ctx := context.Background() 17 - ctx = tlog.IntoContext(ctx, logger) 18 - 13 + ctx := log.NewContext(context.Background(), "spindle") 19 14 err := spindle.Run(ctx) 20 15 if err != nil { 21 - logger.Error("error running spindle", "error", err) 16 + log.FromContext(ctx).Error("error running spindle", "error", err) 22 17 os.Exit(-1) 23 18 } 24 19 }
+6 -16
docs/hacking.md
··· 37 37 38 38 ``` 39 39 # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 40 + echo $TANGLED_OAUTH_JWKS 41 + {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 45 42 46 43 # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 44 + go build -o genjwks.out ./cmd/genjwks 45 + export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 56 46 57 47 # run redis in at a new shell to store oauth sessions 58 48 redis-server ··· 168 158 169 159 If for any reason you wish to disable either one of the 170 160 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled.spindle.enable` (or 172 - `services.tangled.knot.enable`) to `false`. 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
+1 -2
docs/knot-hosting.md
··· 39 39 ``` 40 40 41 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 42 + `/usr/local/bin/knot` is a good choice: 43 43 44 44 ``` 45 45 sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 46 ``` 48 47 49 48 This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1 -1
docs/migrations.md
··· 49 49 latest revision, and change your config block like so: 50 50 51 51 ```diff 52 - services.tangled.knot = { 52 + services.tangled-knot = { 53 53 enable = true; 54 54 server = { 55 55 - secretFile = /path/to/secret;
+2 -20
docs/spindle/pipeline.md
··· 19 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 21 - `manual`: The workflow can be triggered manually. 22 - - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 22 + - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 24 23 25 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 25 27 26 ```yaml 28 27 when: ··· 30 29 branch: ["main", "develop"] 31 30 - event: ["pull_request"] 32 31 branch: ["main"] 33 - ``` 34 - 35 - You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 - 37 - ```yaml 38 - when: 39 - - event: ["push"] 40 - tag: ["v*"] 41 - ``` 42 - 43 - You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 - 45 - ```yaml 46 - when: 47 - - event: ["push"] 48 - branch: ["main", "release-*"] 49 - tag: ["v*", "stable"] 50 32 ``` 51 33 52 34 ## Engine
-17
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "actor-typeahead-src": { 4 - "flake": false, 5 - "locked": { 6 - "lastModified": 1762835797, 7 - "narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=", 8 - "ref": "refs/heads/main", 9 - "rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b", 10 - "revCount": 6, 11 - "type": "git", 12 - "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 13 - }, 14 - "original": { 15 - "type": "git", 16 - "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 17 - } 18 - }, 19 3 "flake-compat": { 20 4 "flake": false, 21 5 "locked": { ··· 166 150 }, 167 151 "root": { 168 152 "inputs": { 169 - "actor-typeahead-src": "actor-typeahead-src", 170 153 "flake-compat": "flake-compat", 171 154 "gomod2nix": "gomod2nix", 172 155 "htmx-src": "htmx-src",
+9 -19
flake.nix
··· 33 33 url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 34 34 flake = false; 35 35 }; 36 - actor-typeahead-src = { 37 - url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 - flake = false; 39 - }; 40 36 ibm-plex-mono-src = { 41 37 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 42 38 flake = false; ··· 58 54 inter-fonts-src, 59 55 sqlite-lib-src, 60 56 ibm-plex-mono-src, 61 - actor-typeahead-src, 62 57 ... 63 58 }: let 64 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 83 78 inherit (pkgs) gcc; 84 79 inherit sqlite-lib-src; 85 80 }; 81 + genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 86 82 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 87 - goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 88 83 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 89 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 84 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 90 85 }; 91 86 appview = self.callPackage ./nix/pkgs/appview.nix {}; 92 87 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 95 90 }); 96 91 in { 97 92 overlays.default = final: prev: { 98 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 93 + inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 99 94 }; 100 95 101 96 packages = forAllSystems (system: let ··· 104 99 staticPackages = mkPackageSet pkgs.pkgsStatic; 105 100 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 106 101 in { 107 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 102 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 108 103 109 104 pkgsStatic-appview = staticPackages.appview; 110 105 pkgsStatic-knot = staticPackages.knot; ··· 172 167 mkdir -p appview/pages/static 173 168 # no preserve is needed because watch-tailwind will want to be able to overwrite 174 169 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 175 - export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 176 - export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 170 + export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 177 171 ''; 178 172 env.CGO_ENABLED = 1; 179 173 }; ··· 212 206 watch-knot = { 213 207 type = "app"; 214 208 program = ''${air-watcher "knot" "server"}/bin/run''; 215 - }; 216 - watch-spindle = { 217 - type = "app"; 218 - program = ''${air-watcher "spindle" ""}/bin/run''; 219 209 }; 220 210 watch-tailwind = { 221 211 type = "app"; ··· 272 262 lexgen --build-file lexicon-build-config.json lexicons 273 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 274 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 275 - go run ./cmd/cborgen/ 265 + go run cmd/gen.go 276 266 lexgen --build-file lexicon-build-config.json lexicons 277 267 rm api/tangled/*.bak 278 268 ''; ··· 288 278 }: { 289 279 imports = [./nix/modules/appview.nix]; 290 280 291 - services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 281 + services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 292 282 }; 293 283 nixosModules.knot = { 294 284 lib, ··· 297 287 }: { 298 288 imports = [./nix/modules/knot.nix]; 299 289 300 - services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 290 + services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 301 291 }; 302 292 nixosModules.spindle = { 303 293 lib, ··· 306 296 }: { 307 297 imports = [./nix/modules/spindle.nix]; 308 298 309 - services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 299 + services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 310 300 }; 311 301 }; 312 302 }
+17 -53
go.mod
··· 7 7 github.com/alecthomas/assert/v2 v2.11.0 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 - github.com/blevesearch/bleve/v2 v2.5.3 11 10 github.com/bluekeyes/go-gitdiff v0.8.1 12 - github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 13 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 - github.com/bmatcuk/doublestar/v4 v4.9.1 15 13 github.com/carlmjohnson/versioninfo v0.22.5 16 14 github.com/casbin/casbin/v2 v2.103.0 17 - github.com/charmbracelet/log v0.4.2 18 15 github.com/cloudflare/cloudflare-go v0.115.0 19 16 github.com/cyphar/filepath-securejoin v0.4.1 20 17 github.com/dgraph-io/ristretto v0.2.0 ··· 24 21 github.com/go-chi/chi/v5 v5.2.0 25 22 github.com/go-enry/go-enry/v2 v2.9.2 26 23 github.com/go-git/go-git/v5 v5.14.0 27 - github.com/goki/freetype v1.0.5 28 24 github.com/google/uuid v1.6.0 29 25 github.com/gorilla/feeds v1.2.0 30 26 github.com/gorilla/sessions v1.4.0 ··· 32 28 github.com/hiddeco/sshsig v0.2.0 33 29 github.com/hpcloud/tail v1.0.0 34 30 github.com/ipfs/go-cid v0.5.0 31 + github.com/lestrrat-go/jwx/v2 v2.1.6 35 32 github.com/mattn/go-sqlite3 v1.14.24 36 33 github.com/microcosm-cc/bluemonday v1.0.27 37 34 github.com/openbao/openbao/api/v2 v2.3.0 ··· 39 36 github.com/redis/go-redis/v9 v9.7.3 40 37 github.com/resend/resend-go/v2 v2.15.0 41 38 github.com/sethvargo/go-envconfig v1.1.0 42 - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 43 - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 44 39 github.com/stretchr/testify v1.10.0 45 40 github.com/urfave/cli/v3 v3.3.3 46 41 github.com/whyrusleeping/cbor-gen v0.3.1 47 - github.com/wyatt915/goldmark-treeblood v0.0.1 48 - github.com/yuin/goldmark v1.7.13 42 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.12 49 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 45 golang.org/x/crypto v0.40.0 52 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 - golang.org/x/image v0.31.0 54 46 golang.org/x/net v0.42.0 55 - golang.org/x/sync v0.17.0 47 + golang.org/x/sync v0.16.0 56 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 57 49 gopkg.in/yaml.v3 v3.0.1 50 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 58 51 ) 59 52 60 53 require ( 61 54 dario.cat/mergo v1.0.1 // indirect 62 55 github.com/Microsoft/go-winio v0.6.2 // indirect 63 56 github.com/ProtonMail/go-crypto v1.3.0 // indirect 64 - github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 65 57 github.com/alecthomas/repr v0.4.0 // indirect 66 58 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 67 - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 68 59 github.com/aymerick/douceur v0.2.0 // indirect 69 60 github.com/beorn7/perks v1.0.1 // indirect 70 - github.com/bits-and-blooms/bitset v1.22.0 // indirect 71 - github.com/blevesearch/bleve_index_api v1.2.8 // indirect 72 - github.com/blevesearch/geo v0.2.4 // indirect 73 - github.com/blevesearch/go-faiss v1.0.25 // indirect 74 - github.com/blevesearch/go-porterstemmer v1.0.3 // indirect 75 - github.com/blevesearch/gtreap v0.1.1 // indirect 76 - github.com/blevesearch/mmap-go v1.0.4 // indirect 77 - github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect 78 - github.com/blevesearch/segment v0.9.1 // indirect 79 - github.com/blevesearch/snowballstem v0.9.0 // indirect 80 - github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect 81 - github.com/blevesearch/vellum v1.1.0 // indirect 82 - github.com/blevesearch/zapx/v11 v11.4.2 // indirect 83 - github.com/blevesearch/zapx/v12 v12.4.2 // indirect 84 - github.com/blevesearch/zapx/v13 v13.4.2 // indirect 85 - github.com/blevesearch/zapx/v14 v14.4.2 // indirect 86 - github.com/blevesearch/zapx/v15 v15.4.2 // indirect 87 - github.com/blevesearch/zapx/v16 v16.2.4 // indirect 61 + github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 88 62 github.com/casbin/govaluate v1.3.0 // indirect 89 63 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 90 64 github.com/cespare/xxhash/v2 v2.3.0 // indirect 91 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 92 - github.com/charmbracelet/lipgloss v1.1.0 // indirect 93 - github.com/charmbracelet/x/ansi v0.8.0 // indirect 94 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 95 - github.com/charmbracelet/x/term v0.2.1 // indirect 96 65 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 97 66 github.com/containerd/errdefs v1.0.0 // indirect 98 67 github.com/containerd/errdefs/pkg v0.3.0 // indirect 99 68 github.com/containerd/log v0.1.0 // indirect 100 69 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 70 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 101 71 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 102 72 github.com/distribution/reference v0.6.0 // indirect 103 73 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 110 80 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 111 81 github.com/go-git/go-billy/v5 v5.6.2 // indirect 112 82 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 113 - github.com/go-logfmt/logfmt v0.6.0 // indirect 114 83 github.com/go-logr/logr v1.4.3 // indirect 115 84 github.com/go-logr/stdr v1.2.2 // indirect 116 85 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 120 89 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 121 90 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 122 91 github.com/golang/mock v1.6.0 // indirect 123 - github.com/golang/protobuf v1.5.4 // indirect 124 - github.com/golang/snappy v0.0.4 // indirect 125 92 github.com/google/go-querystring v1.1.0 // indirect 126 93 github.com/gorilla/css v1.0.1 // indirect 127 94 github.com/gorilla/securecookie v1.1.2 // indirect ··· 147 114 github.com/ipfs/go-log v1.0.5 // indirect 148 115 github.com/ipfs/go-log/v2 v2.6.0 // indirect 149 116 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 150 - github.com/json-iterator/go v1.1.12 // indirect 151 117 github.com/kevinburke/ssh_config v1.2.0 // indirect 152 118 github.com/klauspost/compress v1.18.0 // indirect 153 119 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 154 - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 120 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 121 + github.com/lestrrat-go/httpcc v1.0.1 // indirect 122 + github.com/lestrrat-go/httprc v1.0.6 // indirect 123 + github.com/lestrrat-go/iter v1.0.2 // indirect 124 + github.com/lestrrat-go/option v1.0.1 // indirect 155 125 github.com/mattn/go-isatty v0.0.20 // indirect 156 - github.com/mattn/go-runewidth v0.0.16 // indirect 157 126 github.com/minio/sha256-simd v1.0.1 // indirect 158 127 github.com/mitchellh/mapstructure v1.5.0 // indirect 159 128 github.com/moby/docker-image-spec v1.3.1 // indirect 160 129 github.com/moby/sys/atomicwriter v0.1.0 // indirect 161 130 github.com/moby/term v0.5.2 // indirect 162 - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 163 - github.com/modern-go/reflect2 v1.0.2 // indirect 164 131 github.com/morikuni/aec v1.0.0 // indirect 165 132 github.com/mr-tron/base58 v1.2.0 // indirect 166 - github.com/mschoch/smat v0.2.0 // indirect 167 - github.com/muesli/termenv v0.16.0 // indirect 168 133 github.com/multiformats/go-base32 v0.1.0 // indirect 169 134 github.com/multiformats/go-base36 v0.2.0 // indirect 170 135 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 183 148 github.com/prometheus/client_model v0.6.2 // indirect 184 149 github.com/prometheus/common v0.64.0 // indirect 185 150 github.com/prometheus/procfs v0.16.1 // indirect 186 - github.com/rivo/uniseg v0.4.7 // indirect 187 151 github.com/ryanuber/go-glob v1.0.0 // indirect 152 + github.com/segmentio/asm v1.2.0 // indirect 188 153 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 189 154 github.com/spaolacci/murmur3 v1.1.0 // indirect 190 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 191 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 192 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 193 - github.com/wyatt915/treeblood v0.1.16 // indirect 194 - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 195 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 196 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 197 - go.etcd.io/bbolt v1.4.0 // indirect 198 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 199 162 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 200 163 go.opentelemetry.io/otel v1.37.0 // indirect ··· 205 168 go.uber.org/atomic v1.11.0 // indirect 206 169 go.uber.org/multierr v1.11.0 // indirect 207 170 go.uber.org/zap v1.27.0 // indirect 171 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 208 172 golang.org/x/sys v0.34.0 // indirect 209 - golang.org/x/text v0.29.0 // indirect 173 + golang.org/x/text v0.27.0 // indirect 210 174 golang.org/x/time v0.12.0 // indirect 211 175 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 212 176 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+33 -109
go.sum
··· 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 10 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 - github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 - github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 14 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 15 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 16 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 21 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 22 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 23 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 24 - github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 25 - github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 26 22 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 27 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 28 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 29 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 30 - github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 31 - github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= 32 - github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 33 - github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= 34 - github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= 35 - github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y= 36 - github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= 37 - github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= 38 - github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= 39 - github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= 40 - github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= 41 - github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= 42 - github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= 43 - github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= 44 - github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= 45 - github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= 46 - github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= 47 - github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s= 48 - github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8= 49 - github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= 50 - github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= 51 - github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= 52 - github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= 53 - github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= 54 - github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= 55 - github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= 56 - github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= 57 - github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= 58 - github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= 59 - github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= 60 - github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= 61 - github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= 62 - github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= 63 - github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= 64 - github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= 65 - github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= 66 - github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= 67 - github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= 68 - github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= 69 - github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 70 - github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 26 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 71 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 72 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 73 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 31 + github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 74 32 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 75 - github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 76 - github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 77 33 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 78 34 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 79 35 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 92 48 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 93 49 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 94 50 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 95 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 96 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 97 - github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 98 - github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 99 - github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 100 - github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 101 - github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 102 - github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 103 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 104 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 105 - github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 106 - github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 107 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 108 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 109 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 125 69 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 126 70 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 127 71 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 72 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 73 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 128 74 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 129 75 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 130 76 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 174 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 175 121 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 176 122 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 177 - github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 178 - github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 179 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 180 124 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 181 125 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 192 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 193 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 194 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 195 - github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 196 - github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 197 139 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 198 140 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 199 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 210 152 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 211 153 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 212 154 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 213 - github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 214 - github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 215 - github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 216 - github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 217 155 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 218 156 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 219 157 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 225 163 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 226 164 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 227 165 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 228 - github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 229 166 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 230 167 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 231 168 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 306 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 307 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 308 245 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 309 - github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 310 - github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 246 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 247 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 311 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 312 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 313 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 327 264 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 328 265 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 329 266 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 330 - github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 331 - github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 267 + github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 268 + github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 269 + github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 270 + github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 271 + github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 272 + github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 273 + github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 274 + github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 275 + github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 276 + github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 + github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 + github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 332 279 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 333 280 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 334 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 335 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 336 - github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 337 - github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 338 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 339 284 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 340 285 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 351 296 github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 352 297 github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 353 298 github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 354 - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 355 - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 356 - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 357 - github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 358 - github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 359 299 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 360 300 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 361 301 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 362 302 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 363 - github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= 364 - github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 365 - github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 366 - github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 367 303 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 368 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 369 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 441 377 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 442 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 443 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 444 - github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 445 - github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 446 - github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 447 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 448 381 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 449 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 451 384 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 452 385 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 453 386 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 387 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 388 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 454 389 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 455 390 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 456 391 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 464 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 465 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 466 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 467 - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 468 - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 469 - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 470 - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 471 402 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 472 403 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 473 404 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 495 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 496 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 497 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 498 - github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs= 499 - github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208= 500 - github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y= 501 - github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 502 - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 503 - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 429 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 430 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 + github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 + github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 504 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 505 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 506 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 507 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 508 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 509 438 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 510 - github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 511 - github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 439 + github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 + github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 512 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 513 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 514 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 515 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 516 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 517 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 518 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 519 446 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 520 - go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 521 - go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 522 447 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 523 448 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 524 449 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= ··· 564 489 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 565 490 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 566 491 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 567 - golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 568 - golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 569 492 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 570 493 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 571 494 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 605 528 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 606 529 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 607 530 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 608 - golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 609 - golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 531 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 610 533 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 611 534 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 612 535 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 660 583 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 661 584 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 662 585 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 663 - golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 664 - golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 586 + golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 665 588 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 666 589 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 667 590 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 722 645 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 723 646 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 724 647 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 725 - gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 726 648 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 727 649 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 728 650 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= ··· 730 652 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 731 653 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 732 654 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 733 657 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 734 658 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+61 -36
guard/guard.go
··· 12 12 "os/exec" 13 13 "strings" 14 14 15 + "github.com/bluesky-social/indigo/atproto/identity" 15 16 securejoin "github.com/cyphar/filepath-securejoin" 16 17 "github.com/urfave/cli/v3" 18 + "tangled.org/core/idresolver" 17 19 "tangled.org/core/log" 18 20 ) 19 21 ··· 91 93 "command", sshCommand, 92 94 "client", clientIP) 93 95 94 - // TODO: greet user with their resolved handle instead of did 95 96 if sshCommand == "" { 96 97 l.Info("access denied: no interactive shells", "user", incomingUser) 97 98 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 106 107 } 107 108 108 109 gitCommand := cmdParts[0] 109 - repoPath := cmdParts[1] 110 + 111 + // did:foo/repo-name or 112 + // handle/repo-name or 113 + // any of the above with a leading slash (/) 114 + 115 + components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 116 + l.Info("command components", "components", components) 117 + 118 + if len(components) != 2 { 119 + l.Error("invalid repo format", "components", components) 120 + fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 121 + os.Exit(-1) 122 + } 123 + 124 + didOrHandle := components[0] 125 + identity := resolveIdentity(ctx, l, didOrHandle) 126 + did := identity.DID.String() 127 + repoName := components[1] 128 + qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 110 129 111 130 validCommands := map[string]bool{ 112 131 "git-receive-pack": true, ··· 119 138 return fmt.Errorf("access denied: invalid git command") 120 139 } 121 140 122 - // qualify repo path from internal server which holds the knot config 123 - qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 124 - if err != nil { 125 - l.Error("failed to run guard", "err", err) 126 - fmt.Fprintln(os.Stderr, err) 127 - os.Exit(1) 141 + if gitCommand != "git-upload-pack" { 142 + if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 143 + l.Error("access denied: user not allowed", 144 + "did", incomingUser, 145 + "reponame", qualifiedRepoName) 146 + fmt.Fprintln(os.Stderr, "access denied: user not allowed") 147 + os.Exit(-1) 148 + } 128 149 } 129 150 130 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 151 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 131 152 132 153 l.Info("processing command", 133 154 "user", incomingUser, 134 155 "command", gitCommand, 135 - "repo", repoPath, 156 + "repo", repoName, 136 157 "fullPath", fullPath, 137 158 "client", clientIP) 138 159 ··· 156 177 gitCmd.Stdin = os.Stdin 157 178 gitCmd.Env = append(os.Environ(), 158 179 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 180 + fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 159 181 ) 160 182 161 183 if err := gitCmd.Run(); err != nil { ··· 167 189 l.Info("command completed", 168 190 "user", incomingUser, 169 191 "command", gitCommand, 170 - "repo", repoPath, 192 + "repo", repoName, 171 193 "success", true) 172 194 173 195 return nil 174 196 } 175 197 176 - // runs guardAndQualifyRepo logic 177 - func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 - u, _ := url.Parse(endpoint + "/guard") 198 + func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 + resolver := idresolver.DefaultResolver() 200 + ident, err := resolver.ResolveIdent(ctx, didOrHandle) 201 + if err != nil { 202 + l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 203 + fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 204 + os.Exit(1) 205 + } 206 + if ident.Handle.IsInvalidHandle() { 207 + l.Error("Error resolving handle", "invalid handle", didOrHandle) 208 + fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 209 + os.Exit(1) 210 + } 211 + return ident 212 + } 213 + 214 + func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 215 + u, _ := url.Parse(endpoint + "/push-allowed") 179 216 q := u.Query() 180 - q.Add("user", incomingUser) 181 - q.Add("repo", repo) 182 - q.Add("gitCmd", gitCommand) 217 + q.Add("user", user) 218 + q.Add("repo", qualifiedRepoName) 183 219 u.RawQuery = q.Encode() 184 220 185 - resp, err := http.Get(u.String()) 221 + req, err := http.Get(u.String()) 186 222 if err != nil { 187 - return "", err 223 + l.Error("Error verifying permissions", "error", err) 224 + fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 225 + os.Exit(1) 188 226 } 189 - defer resp.Body.Close() 190 - 191 - l.Info("Running guard", "url", u.String(), "status", resp.Status) 192 227 193 - body, err := io.ReadAll(resp.Body) 194 - if err != nil { 195 - return "", err 196 - } 197 - text := string(body) 228 + l.Info("Checking push permission", 229 + "url", u.String(), 230 + "status", req.Status) 198 231 199 - switch resp.StatusCode { 200 - case http.StatusOK: 201 - return text, nil 202 - case http.StatusForbidden: 203 - l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 204 - return text, errors.New("access denied: user not allowed") 205 - default: 206 - return "", errors.New(text) 207 - } 232 + return req.StatusCode == http.StatusNoContent 208 233 }
+8 -17
idresolver/resolver.go
··· 17 17 directory identity.Directory 18 18 } 19 19 20 - func BaseDirectory(plcUrl string) identity.Directory { 20 + func BaseDirectory() identity.Directory { 21 21 base := identity.BaseDirectory{ 22 - PLCURL: plcUrl, 22 + PLCURL: identity.DefaultPLCURL, 23 23 HTTPClient: http.Client{ 24 24 Timeout: time.Second * 10, 25 25 Transport: &http.Transport{ ··· 42 42 return &base 43 43 } 44 44 45 - func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 45 + func RedisDirectory(url string) (identity.Directory, error) { 46 46 hitTTL := time.Hour * 24 47 47 errTTL := time.Second * 30 48 48 invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory( 50 - BaseDirectory(plcUrl), 51 - url, 52 - hitTTL, 53 - errTTL, 54 - invalidHandleTTL, 55 - 10000, 56 - ) 49 + return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 57 50 } 58 51 59 - func DefaultResolver(plcUrl string) *Resolver { 60 - base := BaseDirectory(plcUrl) 61 - cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 52 + func DefaultResolver() *Resolver { 62 53 return &Resolver{ 63 - directory: &cached, 54 + directory: identity.DefaultDirectory(), 64 55 } 65 56 } 66 57 67 - func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 - directory, err := RedisDirectory(redisUrl, plcUrl) 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 69 60 if err != nil { 70 61 return nil, err 71 62 }
+12 -109
input.css
··· 134 134 } 135 135 136 136 .prose hr { 137 - @apply my-2; 137 + @apply my-2; 138 138 } 139 139 140 140 .prose li:has(input) { 141 - @apply list-none; 141 + @apply list-none; 142 142 } 143 143 144 144 .prose ul:has(input) { 145 - @apply pl-2; 145 + @apply pl-2; 146 146 } 147 147 148 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 149 + @apply no-underline mx-2 opacity-0; 150 150 } 151 151 152 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 153 + @apply opacity-70; 154 154 } 155 155 156 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 157 + @apply opacity-70; 158 158 } 159 159 160 160 .prose a.footnote-backref { 161 - @apply no-underline; 162 - } 163 - 164 - .prose a.mention { 165 - @apply no-underline hover:underline; 161 + @apply no-underline; 166 162 } 167 163 168 164 .prose li { 169 - @apply my-0 py-0; 165 + @apply my-0 py-0; 170 166 } 171 167 172 - .prose ul, 173 - .prose ol { 174 - @apply my-1 py-0; 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 175 170 } 176 171 177 172 .prose img { ··· 181 176 } 182 177 183 178 .prose input { 184 - @apply inline-block my-0 mb-1 mx-1; 179 + @apply inline-block my-0 mb-1 mx-1; 185 180 } 186 181 187 182 .prose input[type="checkbox"] { 188 183 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 189 184 } 190 - 191 - /* Base callout */ 192 - details[data-callout] { 193 - @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 194 - } 195 - 196 - details[data-callout] > summary { 197 - @apply font-bold cursor-pointer mb-1; 198 - } 199 - 200 - details[data-callout] > .callout-content { 201 - @apply text-sm leading-snug; 202 - } 203 - 204 - /* Note (blue) */ 205 - details[data-callout="note" i] { 206 - @apply border-blue-400 dark:border-blue-500; 207 - } 208 - details[data-callout="note" i] > summary { 209 - @apply text-blue-700 dark:text-blue-400; 210 - } 211 - 212 - /* Important (purple) */ 213 - details[data-callout="important" i] { 214 - @apply border-purple-400 dark:border-purple-500; 215 - } 216 - details[data-callout="important" i] > summary { 217 - @apply text-purple-700 dark:text-purple-400; 218 - } 219 - 220 - /* Warning (yellow) */ 221 - details[data-callout="warning" i] { 222 - @apply border-yellow-400 dark:border-yellow-500; 223 - } 224 - details[data-callout="warning" i] > summary { 225 - @apply text-yellow-700 dark:text-yellow-400; 226 - } 227 - 228 - /* Caution (red) */ 229 - details[data-callout="caution" i] { 230 - @apply border-red-400 dark:border-red-500; 231 - } 232 - details[data-callout="caution" i] > summary { 233 - @apply text-red-700 dark:text-red-400; 234 - } 235 - 236 - /* Tip (green) */ 237 - details[data-callout="tip" i] { 238 - @apply border-green-400 dark:border-green-500; 239 - } 240 - details[data-callout="tip" i] > summary { 241 - @apply text-green-700 dark:text-green-400; 242 - } 243 - 244 - /* Optional: hide the disclosure arrow like GitHub */ 245 - details[data-callout] > summary::-webkit-details-marker { 246 - display: none; 247 - } 248 - 249 185 } 250 186 @layer utilities { 251 187 .error { ··· 292 228 } 293 229 /* LineHighlight */ 294 230 .chroma .hl { 295 - @apply bg-amber-400/30 dark:bg-amber-500/20; 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 296 232 } 297 233 298 234 /* LineNumbersTable */ ··· 929 865 text-decoration: underline; 930 866 } 931 867 } 932 - 933 - actor-typeahead { 934 - --color-background: #ffffff; 935 - --color-border: #d1d5db; 936 - --color-shadow: #000000; 937 - --color-hover: #f9fafb; 938 - --color-avatar-fallback: #e5e7eb; 939 - --radius: 0.0; 940 - --padding-menu: 0.0rem; 941 - z-index: 1000; 942 - } 943 - 944 - actor-typeahead::part(handle) { 945 - color: #111827; 946 - } 947 - 948 - actor-typeahead::part(menu) { 949 - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 950 - } 951 - 952 - @media (prefers-color-scheme: dark) { 953 - actor-typeahead { 954 - --color-background: #1f2937; 955 - --color-border: #4b5563; 956 - --color-shadow: #000000; 957 - --color-hover: #374151; 958 - --color-avatar-fallback: #4b5563; 959 - } 960 - 961 - actor-typeahead::part(handle) { 962 - color: #f9fafb; 963 - } 964 - }
+1 -1
jetstream/jetstream.go
··· 114 114 115 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 116 117 - client, err := client.NewClient(j.cfg, logger, sched) 117 + client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 118 118 if err != nil { 119 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 120 }
+1 -2
knotserver/config/config.go
··· 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 21 Hostname string `env:"HOSTNAME, required"` 22 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 23 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 24 23 Owner string `env:"OWNER, required"` 25 24 LogDids bool `env:"LOG_DIDS, default=true"` ··· 42 41 Repo Repo `env:",prefix=KNOT_REPO_"` 43 42 Server Server `env:",prefix=KNOT_SERVER_"` 44 43 Git Git `env:",prefix=KNOT_GIT_"` 45 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 46 45 } 47 46 48 47 func Load(ctx context.Context) (*Config, error) {
+3 -2
knotserver/events.go
··· 8 8 "time" 9 9 10 10 "github.com/gorilla/websocket" 11 - "tangled.org/core/log" 12 11 ) 13 12 14 13 var upgrader = websocket.Upgrader{ ··· 17 16 } 18 17 19 18 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 20 - l := log.SubLogger(h.l, "eventstream") 19 + l := h.l.With("handler", "OpLog") 21 20 l.Debug("received new connection") 22 21 23 22 conn, err := upgrader.Upgrade(w, r, nil) ··· 76 75 } 77 76 case <-time.After(30 * time.Second): 78 77 // send a keep-alive 78 + l.Debug("sent keepalive") 79 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 80 l.Error("failed to write control", "err", err) 81 81 } ··· 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 90 return err 91 91 } 92 + h.l.Debug("ops", "ops", events) 92 93 93 94 for _, event := range events { 94 95 // first extract the inner json into a map
-5
knotserver/git/branch.go
··· 110 110 slices.Reverse(branches) 111 111 return branches, nil 112 112 } 113 - 114 - func (g *GitRepo) DeleteBranch(branch string) error { 115 - ref := plumbing.NewBranchReferenceName(branch) 116 - return g.r.Storer.RemoveReference(ref) 117 - }
+2 -71
knotserver/git/git.go
··· 3 3 import ( 4 4 "archive/tar" 5 5 "bytes" 6 - "errors" 7 6 "fmt" 8 7 "io" 9 8 "io/fs" ··· 13 12 "time" 14 13 15 14 "github.com/go-git/go-git/v5" 16 - "github.com/go-git/go-git/v5/config" 17 15 "github.com/go-git/go-git/v5/plumbing" 18 16 "github.com/go-git/go-git/v5/plumbing/object" 19 17 ) 20 18 21 19 var ( 22 - ErrBinaryFile = errors.New("binary file") 23 - ErrNotBinaryFile = errors.New("not binary file") 24 - ErrMissingGitModules = errors.New("no .gitmodules file found") 25 - ErrInvalidGitModules = errors.New("invalid .gitmodules file") 26 - ErrNotSubmodule = errors.New("path is not a submodule") 20 + ErrBinaryFile = fmt.Errorf("binary file") 21 + ErrNotBinaryFile = fmt.Errorf("not binary file") 27 22 ) 28 23 29 24 type GitRepo struct { ··· 74 69 return nil, fmt.Errorf("opening %s: %w", path, err) 75 70 } 76 71 return &g, nil 77 - } 78 - 79 - // re-open a repository and update references 80 - func (g *GitRepo) Refresh() error { 81 - refreshed, err := PlainOpen(g.path) 82 - if err != nil { 83 - return err 84 - } 85 - 86 - *g = *refreshed 87 - return nil 88 72 } 89 73 90 74 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { ··· 193 177 defer reader.Close() 194 178 195 179 return io.ReadAll(reader) 196 - } 197 - 198 - // read and parse .gitmodules 199 - func (g *GitRepo) Submodules() (*config.Modules, error) { 200 - c, err := g.r.CommitObject(g.h) 201 - if err != nil { 202 - return nil, fmt.Errorf("commit object: %w", err) 203 - } 204 - 205 - tree, err := c.Tree() 206 - if err != nil { 207 - return nil, fmt.Errorf("tree: %w", err) 208 - } 209 - 210 - // read .gitmodules file 211 - modulesEntry, err := tree.FindEntry(".gitmodules") 212 - if err != nil { 213 - return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err) 214 - } 215 - 216 - modulesFile, err := tree.TreeEntryFile(modulesEntry) 217 - if err != nil { 218 - return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err) 219 - } 220 - 221 - content, err := modulesFile.Contents() 222 - if err != nil { 223 - return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err) 224 - } 225 - 226 - // parse .gitmodules 227 - modules := config.NewModules() 228 - if err = modules.Unmarshal([]byte(content)); err != nil { 229 - return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err) 230 - } 231 - 232 - return modules, nil 233 - } 234 - 235 - func (g *GitRepo) Submodule(path string) (*config.Submodule, error) { 236 - modules, err := g.Submodules() 237 - if err != nil { 238 - return nil, err 239 - } 240 - 241 - for _, submodule := range modules.Submodules { 242 - if submodule.Path == path { 243 - return submodule, nil 244 - } 245 - } 246 - 247 - // path is not a submodule 248 - return nil, ErrNotSubmodule 249 180 } 250 181 251 182 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+2 -21
knotserver/git/last_commit.go
··· 30 30 commitCache = cache 31 31 } 32 32 33 - // processReader wraps a reader and ensures the associated process is cleaned up 34 - type processReader struct { 35 - io.Reader 36 - cmd *exec.Cmd 37 - stdout io.ReadCloser 38 - } 39 - 40 - func (pr *processReader) Close() error { 41 - if err := pr.stdout.Close(); err != nil { 42 - return err 43 - } 44 - return pr.cmd.Wait() 45 - } 46 - 47 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 33 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 48 34 args := []string{} 49 35 args = append(args, "log") 50 36 args = append(args, g.h.String()) ··· 62 48 return nil, err 63 49 } 64 50 65 - return &processReader{ 66 - Reader: stdout, 67 - cmd: cmd, 68 - stdout: stdout, 69 - }, nil 51 + return stdout, nil 70 52 } 71 53 72 54 type commit struct { ··· 122 104 if err != nil { 123 105 return nil, err 124 106 } 125 - defer output.Close() // Ensure the git process is properly cleaned up 126 107 127 108 reader := bufio.NewReader(output) 128 109 var current commit
+37 -150
knotserver/git/merge.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "fmt" 7 - "log" 8 7 "os" 9 8 "os/exec" 10 9 "regexp" ··· 13 12 "github.com/dgraph-io/ristretto" 14 13 "github.com/go-git/go-git/v5" 15 14 "github.com/go-git/go-git/v5/plumbing" 16 - "tangled.org/core/patchutil" 17 - "tangled.org/core/types" 18 15 ) 19 16 20 17 type MergeCheckCache struct { ··· 35 32 mergeCheckCache = MergeCheckCache{cache} 36 33 } 37 34 38 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 35 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 39 36 sep := byte(':') 40 37 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 41 38 return fmt.Sprintf("%x", hash) ··· 52 49 } 53 50 } 54 51 55 - func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 52 + func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 56 53 key := m.cacheKey(g, patch, targetBranch) 57 54 val := m.cacheVal(mergeCheck) 58 55 m.cache.Set(key, val, 0) 59 56 } 60 57 61 - func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 58 + func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 62 59 key := m.cacheKey(g, patch, targetBranch) 63 60 if val, ok := m.cache.Get(key); ok { 64 61 if val == struct{}{} { ··· 107 104 return fmt.Sprintf("merge failed: %s", e.Message) 108 105 } 109 106 110 - func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 107 + func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 111 108 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 112 109 if err != nil { 113 110 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 114 111 } 115 112 116 - if _, err := tmpFile.Write([]byte(patchData)); err != nil { 113 + if _, err := tmpFile.Write(patchData); err != nil { 117 114 tmpFile.Close() 118 115 os.Remove(tmpFile.Name()) 119 116 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 165 162 return nil 166 163 } 167 164 168 - func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 169 166 var stderr bytes.Buffer 170 167 var cmd *exec.Cmd 171 168 172 169 // configure default git user before merge 173 - exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 - exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 - exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 170 + exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 + exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 176 173 177 174 // if patch is a format-patch, apply using 'git am' 178 175 if opts.FormatPatch { 179 - return g.applyMailbox(patchData) 180 - } 176 + cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 + } else { 178 + // else, apply using 'git apply' and commit it manually 179 + applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 + applyCmd.Stderr = &stderr 181 + if err := applyCmd.Run(); err != nil { 182 + return fmt.Errorf("patch application failed: %s", stderr.String()) 183 + } 181 184 182 - // else, apply using 'git apply' and commit it manually 183 - applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 - applyCmd.Stderr = &stderr 185 - if err := applyCmd.Run(); err != nil { 186 - return fmt.Errorf("patch application failed: %s", stderr.String()) 187 - } 185 + stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 + if err := stageCmd.Run(); err != nil { 187 + return fmt.Errorf("failed to stage changes: %w", err) 188 + } 188 189 189 - stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 - if err := stageCmd.Run(); err != nil { 191 - return fmt.Errorf("failed to stage changes: %w", err) 192 - } 190 + commitArgs := []string{"-C", tmpDir, "commit"} 193 191 194 - commitArgs := []string{"-C", g.path, "commit"} 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 195 195 196 - // Set author if provided 197 - authorName := opts.AuthorName 198 - authorEmail := opts.AuthorEmail 196 + if authorName != "" && authorEmail != "" { 197 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 + } 199 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 199 200 200 - if authorName != "" && authorEmail != "" { 201 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 - } 203 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 204 202 205 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 + } 206 206 207 - if opts.CommitBody != "" { 208 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 207 + cmd = exec.Command("git", commitArgs...) 209 208 } 210 - 211 - cmd = exec.Command("git", commitArgs...) 212 209 213 210 cmd.Stderr = &stderr 214 211 ··· 219 216 return nil 220 217 } 221 218 222 - func (g *GitRepo) applyMailbox(patchData string) error { 223 - fps, err := patchutil.ExtractPatches(patchData) 224 - if err != nil { 225 - return fmt.Errorf("failed to extract patches: %w", err) 226 - } 227 - 228 - // apply each patch one by one 229 - // update the newly created commit object to add the change-id header 230 - total := len(fps) 231 - for i, p := range fps { 232 - newCommit, err := g.applySingleMailbox(p) 233 - if err != nil { 234 - return err 235 - } 236 - 237 - log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 - } 239 - 240 - return nil 241 - } 242 - 243 - func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 - tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 - if err != nil { 246 - return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 - } 248 - 249 - var stderr bytes.Buffer 250 - cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 - cmd.Stderr = &stderr 252 - 253 - head, err := g.r.Head() 254 - if err != nil { 255 - return plumbing.ZeroHash, err 256 - } 257 - log.Println("head before apply", head.Hash().String()) 258 - 259 - if err := cmd.Run(); err != nil { 260 - return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 - } 262 - 263 - if err := g.Refresh(); err != nil { 264 - return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 - } 266 - 267 - head, err = g.r.Head() 268 - if err != nil { 269 - return plumbing.ZeroHash, err 270 - } 271 - log.Println("head after apply", head.Hash().String()) 272 - 273 - newHash := head.Hash() 274 - if changeId, err := singlePatch.ChangeId(); err != nil { 275 - // no change ID 276 - } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 - return plumbing.ZeroHash, err 278 - } else { 279 - newHash = updatedHash 280 - } 281 - 282 - return newHash, nil 283 - } 284 - 285 - func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 - log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 - obj, err := g.r.CommitObject(hash) 288 - if err != nil { 289 - return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 - } 291 - 292 - // write the change-id header 293 - obj.ExtraHeaders["change-id"] = []byte(changeId) 294 - 295 - // create a new object 296 - dest := g.r.Storer.NewEncodedObject() 297 - if err := obj.Encode(dest); err != nil { 298 - return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 - } 300 - 301 - // store the new object 302 - newHash, err := g.r.Storer.SetEncodedObject(dest) 303 - if err != nil { 304 - return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 - } 306 - 307 - log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 - 309 - // find the branch that HEAD is pointing to 310 - ref, err := g.r.Head() 311 - if err != nil { 312 - return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 - } 314 - 315 - // and update that branch to point to new commit 316 - if ref.Name().IsBranch() { 317 - err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 - if err != nil { 319 - return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 - } 321 - } 322 - 323 - // new hash of commit 324 - return newHash, nil 325 - } 326 - 327 - func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 219 + func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 328 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 221 return val 330 222 } ··· 352 244 return result 353 245 } 354 246 355 - func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 356 248 patchFile, err := g.createTempFileWithPatch(patchData) 357 249 if err != nil { 358 250 return &ErrMerge{ ··· 371 263 } 372 264 defer os.RemoveAll(tmpDir) 373 265 374 - tmpRepo, err := PlainOpen(tmpDir) 375 - if err != nil { 376 - return err 377 - } 378 - 379 - if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 380 267 return err 381 268 } 382 269
+13 -4
knotserver/git/tree.go
··· 7 7 "path" 8 8 "time" 9 9 10 - "github.com/go-git/go-git/v5/plumbing/filemode" 11 10 "github.com/go-git/go-git/v5/plumbing/object" 12 11 "tangled.org/core/types" 13 12 ) ··· 54 53 } 55 54 56 55 for _, e := range subtree.Entries { 56 + mode, _ := e.Mode.ToOSFileMode() 57 57 sz, _ := subtree.Size(e.Name) 58 + 58 59 fpath := path.Join(parent, e.Name) 59 60 60 61 var lastCommit *types.LastCommitInfo ··· 68 69 69 70 nts = append(nts, types.NiceTree{ 70 71 Name: e.Name, 71 - Mode: e.Mode.String(), 72 + Mode: mode.String(), 73 + IsFile: e.Mode.IsFile(), 72 74 Size: sz, 73 75 LastCommit: lastCommit, 74 76 }) ··· 124 126 default: 125 127 } 126 128 129 + mode, err := e.Mode.ToOSFileMode() 130 + if err != nil { 131 + // TODO: log this 132 + continue 133 + } 134 + 127 135 if e.Mode.IsFile() { 128 - if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) { 136 + err = cb(e, currentTree, root) 137 + if errors.Is(err, TerminateWalk) { 129 138 return err 130 139 } 131 140 } 132 141 133 142 // e is a directory 134 - if e.Mode == filemode.Dir { 143 + if mode.IsDir() { 135 144 subtree, err := currentTree.Tree(e.Name) 136 145 if err != nil { 137 146 return fmt.Errorf("sub tree %s: %w", e.Name, err)
+18 -18
knotserver/git.go
··· 13 13 "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) 20 20 if err != nil { 21 21 gitError(w, "repository not found", http.StatusNotFound) 22 - h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 22 + d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 23 return 24 24 } 25 25 26 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 26 + repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 27 27 if err != nil { 28 28 gitError(w, "repository not found", http.StatusNotFound) 29 - h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 29 + d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 30 return 31 31 } 32 32 ··· 46 46 47 47 if err := cmd.InfoRefs(); err != nil { 48 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 49 + d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 50 return 51 51 } 52 52 case "git-receive-pack": 53 - h.RejectPush(w, r, name) 53 + d.RejectPush(w, r, name) 54 54 default: 55 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 56 } 57 57 } 58 58 59 - func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 62 + repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 63 63 if err != nil { 64 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 65 + d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 66 return 67 67 } 68 68 ··· 77 77 gzipReader, err := gzip.NewReader(r.Body) 78 78 if err != nil { 79 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 80 + d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 81 return 82 82 } 83 83 defer gzipReader.Close() ··· 88 88 w.Header().Set("Connection", "Keep-Alive") 89 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 90 91 - h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 91 + d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 92 93 93 cmd := service.ServiceCommand{ 94 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 100 w.WriteHeader(http.StatusOK) 101 101 102 102 if err := cmd.UploadPack(); err != nil { 103 - h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 103 + d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 104 return 105 105 } 106 106 } 107 107 108 - func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 111 + _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 112 112 if err != nil { 113 113 gitError(w, err.Error(), http.StatusForbidden) 114 - h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 114 + d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 115 return 116 116 } 117 117 118 - h.RejectPush(w, r, name) 118 + d.RejectPush(w, r, name) 119 119 } 120 120 121 - func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := h.c.Server.Hostname 134 + hostname := d.c.Server.Hostname 135 135 if strings.Contains(hostname, ":") { 136 136 hostname = strings.Split(hostname, ":")[0] 137 137 }
+8 -4
knotserver/ingester.go
··· 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 18 "tangled.org/core/api/tangled" 19 + "tangled.org/core/idresolver" 19 20 "tangled.org/core/knotserver/db" 20 21 "tangled.org/core/knotserver/git" 21 22 "tangled.org/core/log" ··· 119 120 } 120 121 121 122 // resolve this aturi to extract the repo record 122 - ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 + resolver := idresolver.DefaultResolver() 124 + ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 125 if err != nil || ident.Handle.IsInvalidHandle() { 124 126 return fmt.Errorf("failed to resolve handle: %w", err) 125 127 } ··· 161 163 162 164 var pipeline workflow.RawPipeline 163 165 for _, e := range workflowDir { 164 - if !e.IsFile() { 166 + if !e.IsFile { 165 167 continue 166 168 } 167 169 ··· 231 233 return err 232 234 } 233 235 234 - subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 236 + resolver := idresolver.DefaultResolver() 237 + 238 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 235 239 if err != nil || subjectId.Handle.IsInvalidHandle() { 236 240 return err 237 241 } 238 242 239 243 // TODO: fix this for good, we need to fetch the record here unfortunately 240 244 // resolve this aturi to extract the repo record 241 - owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 245 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 242 246 if err != nil || owner.Handle.IsInvalidHandle() { 243 247 return fmt.Errorf("failed to resolve handle: %w", err) 244 248 }
+8 -154
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 - "github.com/go-git/go-git/v5/plumbing" 17 16 "tangled.org/core/api/tangled" 18 17 "tangled.org/core/hook" 19 - "tangled.org/core/idresolver" 20 18 "tangled.org/core/knotserver/config" 21 19 "tangled.org/core/knotserver/db" 22 20 "tangled.org/core/knotserver/git" 23 - "tangled.org/core/log" 24 21 "tangled.org/core/notifier" 25 22 "tangled.org/core/rbac" 26 23 "tangled.org/core/workflow" 27 24 ) 28 25 29 26 type InternalHandle struct { 30 - db *db.DB 31 - c *config.Config 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 35 - res *idresolver.Resolver 27 + db *db.DB 28 + c *config.Config 29 + e *rbac.Enforcer 30 + l *slog.Logger 31 + n *notifier.Notifier 36 32 } 37 33 38 34 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 68 64 writeJSON(w, data) 69 65 } 70 66 71 - // response in text/plain format 72 - // the body will be qualified repository path on success/push-denied 73 - // or an error message when process failed 74 - func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 75 - l := h.l.With("handler", "PostReceiveHook") 76 - 77 - var ( 78 - incomingUser = r.URL.Query().Get("user") 79 - repo = r.URL.Query().Get("repo") 80 - gitCommand = r.URL.Query().Get("gitCmd") 81 - ) 82 - 83 - if incomingUser == "" || repo == "" || gitCommand == "" { 84 - w.WriteHeader(http.StatusBadRequest) 85 - l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 86 - fmt.Fprintln(w, "invalid internal request") 87 - return 88 - } 89 - 90 - // did:foo/repo-name or 91 - // handle/repo-name or 92 - // any of the above with a leading slash (/) 93 - components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 94 - l.Info("command components", "components", components) 95 - 96 - if len(components) != 2 { 97 - w.WriteHeader(http.StatusBadRequest) 98 - l.Error("invalid repo format", "components", components) 99 - fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 100 - return 101 - } 102 - repoOwner := components[0] 103 - repoName := components[1] 104 - 105 - resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl) 106 - 107 - repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner) 108 - if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() { 109 - l.Error("Error resolving handle", "handle", repoOwner, "err", err) 110 - w.WriteHeader(http.StatusInternalServerError) 111 - fmt.Fprintf(w, "error resolving handle: invalid handle\n") 112 - return 113 - } 114 - repoOwnerDid := repoOwnerIdent.DID.String() 115 - 116 - qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName) 117 - 118 - if gitCommand == "git-receive-pack" { 119 - ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 120 - if err != nil || !ok { 121 - w.WriteHeader(http.StatusForbidden) 122 - fmt.Fprint(w, repo) 123 - return 124 - } 125 - } 126 - 127 - w.WriteHeader(http.StatusOK) 128 - fmt.Fprint(w, qualifiedRepo) 129 - } 130 - 131 67 type PushOptions struct { 132 68 skipCi bool 133 69 verboseCi bool ··· 182 118 // non-fatal 183 119 } 184 120 185 - err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 - if err != nil { 187 - l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 - // non-fatal 189 - } 190 - 191 121 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 192 122 if err != nil { 193 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 243 173 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244 174 } 245 175 246 - func (h *InternalHandle) triggerPipeline( 247 - clientMsgs *[]string, 248 - line git.PostReceiveLine, 249 - gitUserDid string, 250 - repoDid string, 251 - repoName string, 252 - pushOptions PushOptions, 253 - ) error { 176 + func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 254 177 if pushOptions.skipCi { 255 178 return nil 256 179 } ··· 277 200 278 201 var pipeline workflow.RawPipeline 279 202 for _, e := range workflowDir { 280 - if !e.IsFile() { 203 + if !e.IsFile { 281 204 continue 282 205 } 283 206 ··· 345 268 return h.db.InsertEvent(event, h.n) 346 269 } 347 270 348 - func (h *InternalHandle) emitCompareLink( 349 - clientMsgs *[]string, 350 - line git.PostReceiveLine, 351 - repoDid string, 352 - repoName string, 353 - ) error { 354 - // this is a second push to a branch, don't reply with the link again 355 - if !line.OldSha.IsZero() { 356 - return nil 357 - } 358 - 359 - // the ref was not updated to a new hash, don't reply with the link 360 - // 361 - // NOTE: do we need this? 362 - if line.NewSha.String() == line.OldSha.String() { 363 - return nil 364 - } 365 - 366 - pushedRef := plumbing.ReferenceName(line.Ref) 367 - 368 - userIdent, err := h.res.ResolveIdent(context.Background(), repoDid) 369 - user := repoDid 370 - if err == nil { 371 - user = userIdent.Handle.String() 372 - } 373 - 374 - didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 375 - if err != nil { 376 - return err 377 - } 378 - 379 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 380 - if err != nil { 381 - return err 382 - } 383 - 384 - gr, err := git.PlainOpen(repoPath) 385 - if err != nil { 386 - return err 387 - } 388 - 389 - defaultBranch, err := gr.FindMainBranch() 390 - if err != nil { 391 - return err 392 - } 393 - 394 - // pushing to default branch 395 - if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 396 - return nil 397 - } 398 - 399 - // pushing a tag, don't prompt the user the open a PR 400 - if pushedRef.IsTag() { 401 - return nil 402 - } 403 - 404 - ZWS := "\u200B" 405 - *clientMsgs = append(*clientMsgs, ZWS) 406 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 407 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 408 - *clientMsgs = append(*clientMsgs, ZWS) 409 - return nil 410 - } 411 - 412 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 271 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 413 272 r := chi.NewRouter() 414 - l := log.FromContext(ctx) 415 - l = log.SubLogger(l, "internal") 416 - res := idresolver.DefaultResolver(c.Server.PlcUrl) 417 273 418 274 h := InternalHandle{ 419 275 db, ··· 421 277 e, 422 278 l, 423 279 n, 424 - res, 425 280 } 426 281 427 282 r.Get("/push-allowed", h.PushAllowed) 428 283 r.Get("/keys", h.InternalKeys) 429 - r.Get("/guard", h.Guard) 430 284 r.Post("/hooks/post-receive", h.PostReceiveHook) 431 285 r.Mount("/debug", middleware.Profiler()) 432 286
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "log/slog" 5 - "net/http" 6 - "time" 7 - ) 8 - 9 - func (h *Knot) RequestLogger(next http.Handler) http.Handler { 10 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 - start := time.Now() 12 - 13 - next.ServeHTTP(w, r) 14 - 15 - // Build query params as slog.Attrs for the group 16 - queryParams := r.URL.Query() 17 - queryAttrs := make([]any, 0, len(queryParams)) 18 - for key, values := range queryParams { 19 - if len(values) == 1 { 20 - queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 - } else { 22 - queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 - } 24 - } 25 - 26 - h.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 - slog.Group("request", 28 - slog.String("method", r.Method), 29 - slog.String("path", r.URL.Path), 30 - slog.Group("query", queryAttrs...), 31 - slog.Duration("duration", time.Since(start)), 32 - ), 33 - ) 34 - }) 35 - } 36 - 37 - func (h *Knot) CORS(next http.Handler) http.Handler { 38 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 - // Set CORS headers 40 - w.Header().Set("Access-Control-Allow-Origin", "*") 41 - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 42 - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 43 - w.Header().Set("Access-Control-Max-Age", "86400") 44 - 45 - // Handle preflight requests 46 - if r.Method == "OPTIONS" { 47 - w.WriteHeader(http.StatusOK) 48 - return 49 - } 50 - 51 - next.ServeHTTP(w, r) 52 - }) 53 - }
+10 -18
knotserver/router.go
··· 12 12 "tangled.org/core/knotserver/config" 13 13 "tangled.org/core/knotserver/db" 14 14 "tangled.org/core/knotserver/xrpc" 15 - "tangled.org/core/log" 15 + tlog "tangled.org/core/log" 16 16 "tangled.org/core/notifier" 17 17 "tangled.org/core/rbac" 18 18 "tangled.org/core/xrpc/serviceauth" ··· 28 28 resolver *idresolver.Resolver 29 29 } 30 30 31 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 + r := chi.NewRouter() 33 + 32 34 h := Knot{ 33 35 c: c, 34 36 db: db, 35 37 e: e, 36 - l: log.FromContext(ctx), 38 + l: l, 37 39 jc: jc, 38 40 n: n, 39 - resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 41 + resolver: idresolver.DefaultResolver(), 40 42 } 41 43 42 44 err := e.AddKnot(rbac.ThisServer) ··· 65 67 return nil, fmt.Errorf("failed to start jetstream: %w", err) 66 68 } 67 69 68 - return h.Router(), nil 69 - } 70 - 71 - func (h *Knot) Router() http.Handler { 72 - r := chi.NewRouter() 73 - 74 - r.Use(h.CORS) 75 - r.Use(h.RequestLogger) 76 - 77 70 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 78 71 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 79 72 }) ··· 93 86 // Socket that streams git oplogs 94 87 r.Get("/events", h.Events) 95 88 96 - return r 89 + return r, nil 97 90 } 98 91 99 92 func (h *Knot) XrpcRouter() http.Handler { 100 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 93 + logger := tlog.New("knots") 101 94 102 - l := log.SubLogger(h.l, "xrpc") 95 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 103 96 104 97 xrpc := &xrpc.Xrpc{ 105 98 Config: h.c, 106 99 Db: h.db, 107 100 Ingester: h.jc, 108 101 Enforcer: h.e, 109 - Logger: l, 102 + Logger: logger, 110 103 Notifier: h.n, 111 104 Resolver: h.resolver, 112 105 ServiceAuth: serviceAuth, 113 106 } 114 - 115 107 return xrpc.Router() 116 108 } 117 109
+4 -5
knotserver/server.go
··· 43 43 44 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 45 logger := log.FromContext(ctx) 46 - logger = log.SubLogger(logger, cmd.Name) 47 - ctx = log.IntoContext(ctx, logger) 46 + iLogger := log.New("knotserver/internal") 48 47 49 48 c, err := config.Load(ctx) 50 49 if err != nil { ··· 81 80 tangled.KnotMemberNSID, 82 81 tangled.RepoPullNSID, 83 82 tangled.RepoCollaboratorNSID, 84 - }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 83 + }, nil, logger, db, true, c.Server.LogDids) 85 84 if err != nil { 86 85 logger.Error("failed to setup jetstream", "error", err) 87 86 } 88 87 89 88 notifier := notifier.New() 90 89 91 - mux, err := Setup(ctx, c, db, e, jc, &notifier) 90 + mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 92 91 if err != nil { 93 92 return fmt.Errorf("failed to setup server: %w", err) 94 93 } 95 94 96 - imux := Internal(ctx, c, db, e, &notifier) 95 + imux := Internal(ctx, c, db, e, iLogger, &notifier) 97 96 98 97 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 99 98 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
-87
knotserver/xrpc/delete_branch.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - 8 - comatproto "github.com/bluesky-social/indigo/api/atproto" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/bluesky-social/indigo/xrpc" 11 - securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.org/core/api/tangled" 13 - "tangled.org/core/knotserver/git" 14 - "tangled.org/core/rbac" 15 - 16 - xrpcerr "tangled.org/core/xrpc/errors" 17 - ) 18 - 19 - func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 - l := x.Logger 21 - fail := func(e xrpcerr.XrpcError) { 22 - l.Error("failed", "kind", e.Tag, "error", e.Message) 23 - writeError(w, e, http.StatusBadRequest) 24 - } 25 - 26 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 - if !ok { 28 - fail(xrpcerr.MissingActorDidError) 29 - return 30 - } 31 - 32 - var data tangled.RepoDeleteBranch_Input 33 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(xrpcerr.GenericError(err)) 35 - return 36 - } 37 - 38 - // unfortunately we have to resolve repo-at here 39 - repoAt, err := syntax.ParseATURI(data.Repo) 40 - if err != nil { 41 - fail(xrpcerr.InvalidRepoError(data.Repo)) 42 - return 43 - } 44 - 45 - // resolve this aturi to extract the repo record 46 - ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 - if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 - return 50 - } 51 - 52 - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 - resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 - if err != nil { 55 - fail(xrpcerr.GenericError(err)) 56 - return 57 - } 58 - 59 - repo := resp.Value.Val.(*tangled.Repo) 60 - didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 - if err != nil { 62 - fail(xrpcerr.GenericError(err)) 63 - return 64 - } 65 - 66 - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 - l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath) 68 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 - return 70 - } 71 - 72 - path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 - gr, err := git.PlainOpen(path) 74 - if err != nil { 75 - fail(xrpcerr.GenericError(err)) 76 - return 77 - } 78 - 79 - err = gr.DeleteBranch(data.Branch) 80 - if err != nil { 81 - l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 - return 84 - } 85 - 86 - w.WriteHeader(http.StatusOK) 87 - }
+1 -1
knotserver/xrpc/merge.go
··· 85 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 87 88 - err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 88 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 89 if err != nil { 90 90 var mergeErr *git.ErrMerge 91 91 if errors.As(err, &mergeErr) {
+1 -3
knotserver/xrpc/merge_check.go
··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck(data.Patch, data.Branch) 54 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 55 56 56 response := tangled.RepoMergeCheck_Output{ 57 57 Is_conflicted: false, ··· 80 80 response.Error = &errMsg 81 81 } 82 82 } 83 - 84 - l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 85 83 86 84 w.Header().Set("Content-Type", "application/json") 87 85 w.WriteHeader(http.StatusOK)
+2 -21
knotserver/xrpc/repo_blob.go
··· 42 42 return 43 43 } 44 44 45 - // first check if this path is a submodule 46 - submodule, err := gr.Submodule(treePath) 47 - if err != nil { 48 - // this is okay, continue and try to treat it as a regular file 49 - } else { 50 - response := tangled.RepoBlob_Output{ 51 - Ref: ref, 52 - Path: treePath, 53 - Submodule: &tangled.RepoBlob_Submodule{ 54 - Name: submodule.Name, 55 - Url: submodule.URL, 56 - Branch: &submodule.Branch, 57 - }, 58 - } 59 - writeJson(w, response) 60 - return 61 - } 62 - 63 45 contents, err := gr.RawContent(treePath) 64 46 if err != nil { 65 47 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) ··· 119 101 var encoding string 120 102 121 103 isBinary := !isTextual(mimeType) 122 - size := int64(len(contents)) 123 104 124 105 if isBinary { 125 106 content = base64.StdEncoding.EncodeToString(contents) ··· 132 113 response := tangled.RepoBlob_Output{ 133 114 Ref: ref, 134 115 Path: treePath, 135 - Content: &content, 116 + Content: content, 136 117 Encoding: &encoding, 137 - Size: &size, 118 + Size: &[]int64{int64(len(contents))}[0], 138 119 IsBinary: &isBinary, 139 120 } 140 121
+4 -20
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 - "github.com/bluekeyes/go-gitdiff/gitdiff" 8 7 "tangled.org/core/knotserver/git" 9 8 "tangled.org/core/types" 10 9 xrpcerr "tangled.org/core/xrpc/errors" ··· 72 71 return 73 72 } 74 73 75 - var combinedPatch []*gitdiff.File 76 - var combinedPatchRaw string 77 - // we need the combined patch 78 - if len(formatPatch) >= 2 { 79 - diffTree, err := gr.DiffTree(commit1, commit2) 80 - if err != nil { 81 - x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 - } else { 83 - combinedPatch = diffTree.Diff 84 - combinedPatchRaw = diffTree.Patch 85 - } 86 - } 87 - 88 74 response := types.RepoFormatPatchResponse{ 89 - Rev1: commit1.Hash.String(), 90 - Rev2: commit2.Hash.String(), 91 - FormatPatch: formatPatch, 92 - FormatPatchRaw: rawPatch, 93 - CombinedPatch: combinedPatch, 94 - CombinedPatchRaw: combinedPatchRaw, 75 + Rev1: commit1.Hash.String(), 76 + Rev2: commit2.Hash.String(), 77 + FormatPatch: formatPatch, 78 + Patch: rawPatch, 95 79 } 96 80 97 81 writeJson(w, response)
+5 -3
knotserver/xrpc/repo_tree.go
··· 67 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 68 for i, file := range files { 69 69 entry := &tangled.RepoTree_TreeEntry{ 70 - Name: file.Name, 71 - Mode: file.Mode, 72 - Size: file.Size, 70 + Name: file.Name, 71 + Mode: file.Mode, 72 + Size: file.Size, 73 + Is_file: file.IsFile, 74 + Is_subtree: file.IsSubtree, 73 75 } 74 76 75 77 if file.LastCommit != nil {
-1
knotserver/xrpc/xrpc.go
··· 38 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 39 40 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 - r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 42 41 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 43 42 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 44 43 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
-5
lexicons/actor/profile.json
··· 64 64 "type": "string", 65 65 "format": "at-uri" 66 66 } 67 - }, 68 - "pronouns": { 69 - "type": "string", 70 - "description": "Preferred gender pronouns.", 71 - "maxLength": 40 72 67 } 73 68 } 74 69 }
+5 -49
lexicons/repo/blob.json
··· 6 6 "type": "query", 7 7 "parameters": { 8 8 "type": "params", 9 - "required": [ 10 - "repo", 11 - "ref", 12 - "path" 13 - ], 9 + "required": ["repo", "ref", "path"], 14 10 "properties": { 15 11 "repo": { 16 12 "type": "string", ··· 35 31 "encoding": "application/json", 36 32 "schema": { 37 33 "type": "object", 38 - "required": [ 39 - "ref", 40 - "path" 41 - ], 34 + "required": ["ref", "path", "content"], 42 35 "properties": { 43 36 "ref": { 44 37 "type": "string", ··· 55 48 "encoding": { 56 49 "type": "string", 57 50 "description": "Content encoding", 58 - "enum": [ 59 - "utf-8", 60 - "base64" 61 - ] 51 + "enum": ["utf-8", "base64"] 62 52 }, 63 53 "size": { 64 54 "type": "integer", ··· 71 61 "mimeType": { 72 62 "type": "string", 73 63 "description": "MIME type of the file" 74 - }, 75 - "submodule": { 76 - "type": "ref", 77 - "ref": "#submodule", 78 - "description": "Submodule information if path is a submodule" 79 64 }, 80 65 "lastCommit": { 81 66 "type": "ref", ··· 105 90 }, 106 91 "lastCommit": { 107 92 "type": "object", 108 - "required": [ 109 - "hash", 110 - "message", 111 - "when" 112 - ], 93 + "required": ["hash", "message", "when"], 113 94 "properties": { 114 95 "hash": { 115 96 "type": "string", ··· 136 117 }, 137 118 "signature": { 138 119 "type": "object", 139 - "required": [ 140 - "name", 141 - "email", 142 - "when" 143 - ], 120 + "required": ["name", "email", "when"], 144 121 "properties": { 145 122 "name": { 146 123 "type": "string", ··· 154 131 "type": "string", 155 132 "format": "datetime", 156 133 "description": "Author timestamp" 157 - } 158 - } 159 - }, 160 - "submodule": { 161 - "type": "object", 162 - "required": [ 163 - "name", 164 - "url" 165 - ], 166 - "properties": { 167 - "name": { 168 - "type": "string", 169 - "description": "Submodule name" 170 - }, 171 - "url": { 172 - "type": "string", 173 - "description": "Submodule repository URL" 174 - }, 175 - "branch": { 176 - "type": "string", 177 - "description": "Branch to track in the submodule" 178 134 } 179 135 } 180 136 }
-30
lexicons/repo/deleteBranch.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.deleteBranch", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Delete a branch on this repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "branch" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "branch": { 22 - "type": "string" 23 - } 24 - } 25 - } 26 - } 27 - } 28 - } 29 - } 30 -
-15
lexicons/repo/repo.json
··· 32 32 "minGraphemes": 1, 33 33 "maxGraphemes": 140 34 34 }, 35 - "website": { 36 - "type": "string", 37 - "format": "uri", 38 - "description": "Any URI related to the repo" 39 - }, 40 - "topics": { 41 - "type": "array", 42 - "description": "Topics related to the repo", 43 - "items": { 44 - "type": "string", 45 - "minLength": 1, 46 - "maxLength": 50 47 - }, 48 - "maxLength": 50 49 - }, 50 35 "source": { 51 36 "type": "string", 52 37 "format": "uri",
+9 -1
lexicons/repo/tree.json
··· 91 91 }, 92 92 "treeEntry": { 93 93 "type": "object", 94 - "required": ["name", "mode", "size"], 94 + "required": ["name", "mode", "size", "is_file", "is_subtree"], 95 95 "properties": { 96 96 "name": { 97 97 "type": "string", ··· 104 104 "size": { 105 105 "type": "integer", 106 106 "description": "File size in bytes" 107 + }, 108 + "is_file": { 109 + "type": "boolean", 110 + "description": "Whether this entry is a file" 111 + }, 112 + "is_subtree": { 113 + "type": "boolean", 114 + "description": "Whether this entry is a directory/subtree" 107 115 }, 108 116 "last_commit": { 109 117 "type": "ref",
+9 -23
log/log.go
··· 4 4 "context" 5 5 "log/slog" 6 6 "os" 7 - 8 - "github.com/charmbracelet/log" 9 7 ) 10 8 9 + // NewHandler sets up a new slog.Handler with the service name 10 + // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - return log.NewWithOptions(os.Stderr, log.Options{ 13 - ReportTimestamp: true, 14 - Prefix: name, 15 - Level: log.DebugLevel, 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 16 14 }) 15 + 16 + var attrs []slog.Attr 17 + attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)}) 18 + handler.WithAttrs(attrs) 19 + return handler 17 20 } 18 21 19 22 func New(name string) *slog.Logger { ··· 46 49 47 50 return slog.Default() 48 51 } 49 - 50 - // sublogger derives a new logger from an existing one by appending a suffix to its prefix. 51 - func SubLogger(base *slog.Logger, suffix string) *slog.Logger { 52 - // try to get the underlying charmbracelet logger 53 - if cl, ok := base.Handler().(*log.Logger); ok { 54 - prefix := cl.GetPrefix() 55 - if prefix != "" { 56 - prefix = prefix + "/" + suffix 57 - } else { 58 - prefix = suffix 59 - } 60 - return slog.New(NewHandler(prefix)) 61 - } 62 - 63 - // Fallback: no known handler type 64 - return slog.New(NewHandler(suffix)) 65 - }
+17 -149
nix/gomod2nix.toml
··· 13 13 [mod."github.com/ProtonMail/go-crypto"] 14 14 version = "v1.3.0" 15 15 hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 - [mod."github.com/RoaringBitmap/roaring/v2"] 17 - version = "v2.4.5" 18 - hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA=" 19 16 [mod."github.com/alecthomas/assert/v2"] 20 17 version = "v2.11.0" 21 18 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 32 29 [mod."github.com/avast/retry-go/v4"] 33 30 version = "v4.6.1" 34 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 35 - [mod."github.com/aymanbagabas/go-osc52/v2"] 36 - version = "v2.0.1" 37 - hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 38 32 [mod."github.com/aymerick/douceur"] 39 33 version = "v0.2.0" 40 34 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" 41 35 [mod."github.com/beorn7/perks"] 42 36 version = "v1.0.1" 43 37 hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 44 - [mod."github.com/bits-and-blooms/bitset"] 45 - version = "v1.22.0" 46 - hash = "sha256-lY1K29h4vlAmJVvwKgbTG8BTACYGjFaginCszN+ST6w=" 47 - [mod."github.com/blevesearch/bleve/v2"] 48 - version = "v2.5.3" 49 - hash = "sha256-DkpX43WMpB8+9KCibdNjyf6N/1a51xJTfGF97xdoCAQ=" 50 - [mod."github.com/blevesearch/bleve_index_api"] 51 - version = "v1.2.8" 52 - hash = "sha256-LyGDBRvK2GThgUFLZoAbDOOKP1M9Z8oy0E2M6bHZdrk=" 53 - [mod."github.com/blevesearch/geo"] 54 - version = "v0.2.4" 55 - hash = "sha256-W1OV/pvqzJC28VJomGnIU/HeBZ689+p54vWdZ1z/bxc=" 56 - [mod."github.com/blevesearch/go-faiss"] 57 - version = "v1.0.25" 58 - hash = "sha256-bcm976UX22aNIuSjBxFaYMKTltO9lbqyeG4Z3KVG3/Y=" 59 - [mod."github.com/blevesearch/go-porterstemmer"] 60 - version = "v1.0.3" 61 - hash = "sha256-hUjo6g1ehUD1awBmta0ji/xoooD2qG7O22HIeSQiRFo=" 62 - [mod."github.com/blevesearch/gtreap"] 63 - version = "v0.1.1" 64 - hash = "sha256-B4p/5RnECRfV4yOiSQDLMHb23uI7lsQDePhNK+zjbF4=" 65 - [mod."github.com/blevesearch/mmap-go"] 66 - version = "v1.0.4" 67 - hash = "sha256-8y0nMAE9goKjYhR/FFEvtbP7cvM46xneE461L1Jn2Pg=" 68 - [mod."github.com/blevesearch/scorch_segment_api/v2"] 69 - version = "v2.3.10" 70 - hash = "sha256-BcBRjVOrsYySdsdgEjS3qHFm/c58KUNJepRPUO0lFmY=" 71 - [mod."github.com/blevesearch/segment"] 72 - version = "v0.9.1" 73 - hash = "sha256-0EAT737kNxl8IJFGl2SD9mOzxolONGgpfaYEGr7JXkQ=" 74 - [mod."github.com/blevesearch/snowballstem"] 75 - version = "v0.9.0" 76 - hash = "sha256-NQsXrhXcYXn4jQcvwjwLc96SGMRcqVlrR6hYKWGk7/s=" 77 - [mod."github.com/blevesearch/upsidedown_store_api"] 78 - version = "v1.0.2" 79 - hash = "sha256-P69Mnh6YR5RI73bD6L7BYDxkVmaqPMNUrjbfSJoKWuo=" 80 - [mod."github.com/blevesearch/vellum"] 81 - version = "v1.1.0" 82 - hash = "sha256-GJ1wslEJEZhPbMiANw0W4Dgb1ZouiILbWEaIUfxZTkw=" 83 - [mod."github.com/blevesearch/zapx/v11"] 84 - version = "v11.4.2" 85 - hash = "sha256-YzRcc2GwV4VL2Bc+tXOOUL6xNi8LWS76DXEcTkFPTaQ=" 86 - [mod."github.com/blevesearch/zapx/v12"] 87 - version = "v12.4.2" 88 - hash = "sha256-yqyzkMWpyXZSF9KLjtiuOmnRUfhaZImk27mU8lsMyJY=" 89 - [mod."github.com/blevesearch/zapx/v13"] 90 - version = "v13.4.2" 91 - hash = "sha256-VSS2fI7YUkeGMBH89TB9yW5qG8MWjM6zKbl8DboHsB4=" 92 - [mod."github.com/blevesearch/zapx/v14"] 93 - version = "v14.4.2" 94 - hash = "sha256-mAWr+vK0uZWMUaJfGfchzQo4dzMdBbD3Z7F84Jn/ktg=" 95 - [mod."github.com/blevesearch/zapx/v15"] 96 - version = "v15.4.2" 97 - hash = "sha256-R8Eh3N4e8CDXiW47J8ZBnfMY1TTnX1SJPwQc4gYChi8=" 98 - [mod."github.com/blevesearch/zapx/v16"] 99 - version = "v16.2.4" 100 - hash = "sha256-Jo5k7DflV/ghszOWJTCOGVyyLMvlvSYyxRrmSIFjyEE=" 101 38 [mod."github.com/bluekeyes/go-gitdiff"] 102 39 version = "v0.8.2" 103 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 104 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 105 42 [mod."github.com/bluesky-social/indigo"] 106 - version = "v0.0.0-20251003000214-3259b215110e" 107 - hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 108 45 [mod."github.com/bluesky-social/jetstream"] 109 46 version = "v0.0.0-20241210005130-ea96859b93d1" 110 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 111 48 [mod."github.com/bmatcuk/doublestar/v4"] 112 - version = "v4.9.1" 113 - hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 49 + version = "v4.7.1" 50 + hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 114 51 [mod."github.com/carlmjohnson/versioninfo"] 115 52 version = "v0.22.5" 116 53 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 126 63 [mod."github.com/cespare/xxhash/v2"] 127 64 version = "v2.3.0" 128 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 129 - [mod."github.com/charmbracelet/colorprofile"] 130 - version = "v0.2.3-0.20250311203215-f60798e515dc" 131 - hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" 132 - [mod."github.com/charmbracelet/lipgloss"] 133 - version = "v1.1.0" 134 - hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" 135 - [mod."github.com/charmbracelet/log"] 136 - version = "v0.4.2" 137 - hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=" 138 - [mod."github.com/charmbracelet/x/ansi"] 139 - version = "v0.8.0" 140 - hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas=" 141 - [mod."github.com/charmbracelet/x/cellbuf"] 142 - version = "v0.0.13-0.20250311204145-2c3ea96c31dd" 143 - hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" 144 - [mod."github.com/charmbracelet/x/term"] 145 - version = "v0.2.1" 146 - hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" 147 66 [mod."github.com/cloudflare/circl"] 148 67 version = "v1.6.2-0.20250618153321-aa837fd1539d" 149 68 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 226 145 [mod."github.com/go-jose/go-jose/v3"] 227 146 version = "v3.0.4" 228 147 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 229 - [mod."github.com/go-logfmt/logfmt"] 230 - version = "v0.6.0" 231 - hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 232 148 [mod."github.com/go-logr/logr"] 233 149 version = "v1.4.3" 234 150 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 247 163 [mod."github.com/gogo/protobuf"] 248 164 version = "v1.3.2" 249 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 250 - [mod."github.com/goki/freetype"] 251 - version = "v1.0.5" 252 - hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 253 166 [mod."github.com/golang-jwt/jwt/v5"] 254 167 version = "v5.2.3" 255 168 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 259 172 [mod."github.com/golang/mock"] 260 173 version = "v1.6.0" 261 174 hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 262 - [mod."github.com/golang/protobuf"] 263 - version = "v1.5.4" 264 - hash = "sha256-N3+Lv9lEZjrdOWdQhFj6Y3Iap4rVLEQeI8/eFFyAMZ0=" 265 - [mod."github.com/golang/snappy"] 266 - version = "v0.0.4" 267 - hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA=" 268 175 [mod."github.com/google/go-querystring"] 269 176 version = "v1.1.0" 270 177 hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" ··· 361 268 [mod."github.com/ipfs/go-metrics-interface"] 362 269 version = "v0.3.0" 363 270 hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 364 - [mod."github.com/json-iterator/go"] 365 - version = "v1.1.12" 366 - hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" 367 271 [mod."github.com/kevinburke/ssh_config"] 368 272 version = "v1.2.0" 369 273 hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" ··· 391 295 [mod."github.com/lestrrat-go/option"] 392 296 version = "v1.0.1" 393 297 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 394 - [mod."github.com/lucasb-eyer/go-colorful"] 395 - version = "v1.2.0" 396 - hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 397 298 [mod."github.com/mattn/go-isatty"] 398 299 version = "v0.0.20" 399 300 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 400 - [mod."github.com/mattn/go-runewidth"] 401 - version = "v0.0.16" 402 - hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 403 301 [mod."github.com/mattn/go-sqlite3"] 404 302 version = "v1.14.24" 405 303 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 421 319 [mod."github.com/moby/term"] 422 320 version = "v0.5.2" 423 321 hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 424 - [mod."github.com/modern-go/concurrent"] 425 - version = "v0.0.0-20180306012644-bacd9c7ef1dd" 426 - hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo=" 427 - [mod."github.com/modern-go/reflect2"] 428 - version = "v1.0.2" 429 - hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU=" 430 322 [mod."github.com/morikuni/aec"] 431 323 version = "v1.0.0" 432 324 hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 433 325 [mod."github.com/mr-tron/base58"] 434 326 version = "v1.2.0" 435 327 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 436 - [mod."github.com/mschoch/smat"] 437 - version = "v0.2.0" 438 - hash = "sha256-DZvUJXjIcta3U+zxzgU3wpoGn/V4lpBY7Xme8aQUi+E=" 439 - [mod."github.com/muesli/termenv"] 440 - version = "v0.16.0" 441 - hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 442 328 [mod."github.com/multiformats/go-base32"] 443 329 version = "v0.1.0" 444 330 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 505 391 [mod."github.com/resend/resend-go/v2"] 506 392 version = "v2.15.0" 507 393 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 508 - [mod."github.com/rivo/uniseg"] 509 - version = "v0.4.7" 510 - hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 511 394 [mod."github.com/ryanuber/go-glob"] 512 395 version = "v1.0.0" 513 396 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 524 407 [mod."github.com/spaolacci/murmur3"] 525 408 version = "v1.1.0" 526 409 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 527 - [mod."github.com/srwiley/oksvg"] 528 - version = "v0.0.0-20221011165216-be6e8873101c" 529 - hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 530 - [mod."github.com/srwiley/rasterx"] 531 - version = "v0.0.0-20220730225603-2ab79fcdd4ef" 532 - hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 533 410 [mod."github.com/stretchr/testify"] 534 411 version = "v1.10.0" 535 412 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 549 426 version = "v0.3.1" 550 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 551 428 [mod."github.com/wyatt915/goldmark-treeblood"] 552 - version = "v0.0.1" 553 - hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g=" 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 554 431 [mod."github.com/wyatt915/treeblood"] 555 - version = "v0.1.16" 556 - hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw=" 557 - [mod."github.com/xo/terminfo"] 558 - version = "v0.0.0-20220910002029-abceb7e1c41e" 559 - hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 560 434 [mod."github.com/yuin/goldmark"] 561 - version = "v1.7.13" 562 - hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 563 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 564 438 version = "v2.0.0-20230729083705-37449abec8cc" 565 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 566 - [mod."gitlab.com/staticnoise/goldmark-callout"] 567 - version = "v0.0.0-20240609120641-6366b799e4ab" 568 - hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 569 440 [mod."gitlab.com/yawning/secp256k1-voi"] 570 441 version = "v0.0.0-20230925100816-f2616030848b" 571 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 572 443 [mod."gitlab.com/yawning/tuplehash"] 573 444 version = "v0.0.0-20230713102510-df83abbf9a02" 574 445 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 575 - [mod."go.etcd.io/bbolt"] 576 - version = "v1.4.0" 577 - hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps=" 578 446 [mod."go.opentelemetry.io/auto/sdk"] 579 447 version = "v1.1.0" 580 448 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" ··· 611 479 [mod."golang.org/x/exp"] 612 480 version = "v0.0.0-20250620022241-b7579e27df2b" 613 481 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 614 - [mod."golang.org/x/image"] 615 - version = "v0.31.0" 616 - hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 617 482 [mod."golang.org/x/net"] 618 483 version = "v0.42.0" 619 484 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 620 485 [mod."golang.org/x/sync"] 621 - version = "v0.17.0" 622 - hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 486 + version = "v0.16.0" 487 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 623 488 [mod."golang.org/x/sys"] 624 489 version = "v0.34.0" 625 490 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 626 491 [mod."golang.org/x/text"] 627 - version = "v0.29.0" 628 - hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 492 + version = "v0.27.0" 493 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 629 494 [mod."golang.org/x/time"] 630 495 version = "v0.12.0" 631 496 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 662 527 [mod."lukechampine.com/blake3"] 663 528 version = "v1.4.1" 664 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 + [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 + version = "v0.0.0-20250724194903-28e660378cb1" 532 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+18 -285
nix/modules/appview.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled.appview; 6 + cfg = config.services.tangled-appview; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled.appview = { 10 + services.tangled-appview = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; 14 14 description = "Enable tangled appview"; 15 15 }; 16 - 17 16 package = mkOption { 18 17 type = types.package; 19 18 description = "Package to use for the appview"; 20 19 }; 21 - 22 - # core configuration 23 20 port = mkOption { 24 - type = types.port; 21 + type = types.int; 25 22 default = 3000; 26 23 description = "Port to run the appview on"; 27 24 }; 28 - 29 - listenAddr = mkOption { 30 - type = types.str; 31 - default = "0.0.0.0:${toString cfg.port}"; 32 - description = "Listen address for the appview service"; 33 - }; 34 - 35 - dbPath = mkOption { 36 - type = types.str; 37 - default = "/var/lib/appview/appview.db"; 38 - description = "Path to the SQLite database file"; 39 - }; 40 - 41 - appviewHost = mkOption { 25 + cookie_secret = mkOption { 42 26 type = types.str; 43 - default = "https://tangled.org"; 44 - example = "https://example.com"; 45 - description = "Public host URL for the appview instance"; 46 - }; 47 - 48 - appviewName = mkOption { 49 - type = types.str; 50 - default = "Tangled"; 51 - description = "Display name for the appview instance"; 52 - }; 53 - 54 - dev = mkOption { 55 - type = types.bool; 56 - default = false; 57 - description = "Enable development mode"; 58 - }; 59 - 60 - disallowedNicknamesFile = mkOption { 61 - type = types.nullOr types.path; 62 - default = null; 63 - description = "Path to file containing disallowed nicknames"; 64 - }; 65 - 66 - # redis configuration 67 - redis = { 68 - addr = mkOption { 69 - type = types.str; 70 - default = "localhost:6379"; 71 - description = "Redis server address"; 72 - }; 73 - 74 - db = mkOption { 75 - type = types.int; 76 - default = 0; 77 - description = "Redis database number"; 78 - }; 79 - }; 80 - 81 - # jetstream configuration 82 - jetstream = { 83 - endpoint = mkOption { 84 - type = types.str; 85 - default = "wss://jetstream1.us-east.bsky.network/subscribe"; 86 - description = "Jetstream WebSocket endpoint"; 87 - }; 88 - }; 89 - 90 - # knotstream consumer configuration 91 - knotstream = { 92 - retryInterval = mkOption { 93 - type = types.str; 94 - default = "60s"; 95 - description = "Initial retry interval for knotstream consumer"; 96 - }; 97 - 98 - maxRetryInterval = mkOption { 99 - type = types.str; 100 - default = "120m"; 101 - description = "Maximum retry interval for knotstream consumer"; 102 - }; 103 - 104 - connectionTimeout = mkOption { 105 - type = types.str; 106 - default = "5s"; 107 - description = "Connection timeout for knotstream consumer"; 108 - }; 109 - 110 - workerCount = mkOption { 111 - type = types.int; 112 - default = 64; 113 - description = "Number of workers for knotstream consumer"; 114 - }; 115 - 116 - queueSize = mkOption { 117 - type = types.int; 118 - default = 100; 119 - description = "Queue size for knotstream consumer"; 120 - }; 121 - }; 122 - 123 - # spindlestream consumer configuration 124 - spindlestream = { 125 - retryInterval = mkOption { 126 - type = types.str; 127 - default = "60s"; 128 - description = "Initial retry interval for spindlestream consumer"; 129 - }; 130 - 131 - maxRetryInterval = mkOption { 132 - type = types.str; 133 - default = "120m"; 134 - description = "Maximum retry interval for spindlestream consumer"; 135 - }; 136 - 137 - connectionTimeout = mkOption { 138 - type = types.str; 139 - default = "5s"; 140 - description = "Connection timeout for spindlestream consumer"; 141 - }; 142 - 143 - workerCount = mkOption { 144 - type = types.int; 145 - default = 64; 146 - description = "Number of workers for spindlestream consumer"; 147 - }; 148 - 149 - queueSize = mkOption { 150 - type = types.int; 151 - default = 100; 152 - description = "Queue size for spindlestream consumer"; 153 - }; 27 + default = "00000000000000000000000000000000"; 28 + description = "Cookie secret"; 154 29 }; 155 - 156 - # resend configuration 157 - resend = { 158 - sentFrom = mkOption { 159 - type = types.str; 160 - default = "noreply@notifs.tangled.sh"; 161 - description = "Email address to send notifications from"; 162 - }; 163 - }; 164 - 165 - # posthog configuration 166 - posthog = { 167 - endpoint = mkOption { 168 - type = types.str; 169 - default = "https://eu.i.posthog.com"; 170 - description = "PostHog API endpoint"; 171 - }; 172 - }; 173 - 174 - # camo configuration 175 - camo = { 176 - host = mkOption { 177 - type = types.str; 178 - default = "https://camo.tangled.sh"; 179 - description = "Camo proxy host URL"; 180 - }; 181 - }; 182 - 183 - # avatar configuration 184 - avatar = { 185 - host = mkOption { 186 - type = types.str; 187 - default = "https://avatar.tangled.sh"; 188 - description = "Avatar service host URL"; 189 - }; 190 - }; 191 - 192 - plc = { 193 - url = mkOption { 194 - type = types.str; 195 - default = "https://plc.directory"; 196 - description = "PLC directory URL"; 197 - }; 198 - }; 199 - 200 - pds = { 201 - host = mkOption { 202 - type = types.str; 203 - default = "https://tngl.sh"; 204 - description = "PDS host URL"; 205 - }; 206 - }; 207 - 208 - label = { 209 - defaults = mkOption { 210 - type = types.listOf types.str; 211 - default = [ 212 - "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix" 213 - "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 214 - "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate" 215 - "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation" 216 - "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" 217 - ]; 218 - description = "Default label definitions"; 219 - }; 220 - 221 - goodFirstIssue = mkOption { 222 - type = types.str; 223 - default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"; 224 - description = "Good first issue label definition"; 225 - }; 226 - }; 227 - 228 30 environmentFile = mkOption { 229 31 type = with types; nullOr path; 230 32 default = null; 231 - example = "/etc/appview.env"; 33 + example = "/etc/tangled-appview.env"; 232 34 description = '' 233 35 Additional environment file as defined in {manpage}`systemd.exec(5)`. 234 36 235 - Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`, 236 - {env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`, 237 - {env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`, 238 - {env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`, 239 - {env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`, 240 - {env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`, 241 - {env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`, 242 - {env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`, 243 - and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service 244 - without making them world readable in the nix store. 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 245 41 ''; 246 42 }; 247 43 }; 248 44 }; 249 45 250 46 config = mkIf cfg.enable { 251 - services.redis.servers.appview = { 252 - enable = true; 253 - port = 6379; 254 - }; 255 - 256 - systemd.services.appview = { 47 + systemd.services.tangled-appview = { 257 48 description = "tangled appview service"; 258 49 wantedBy = ["multi-user.target"]; 259 - after = ["redis-appview.service" "network-online.target"]; 260 - requires = ["redis-appview.service"]; 261 - wants = ["network-online.target"]; 262 50 263 51 serviceConfig = { 264 - Type = "simple"; 52 + ListenStream = "0.0.0.0:${toString cfg.port}"; 265 53 ExecStart = "${cfg.package}/bin/appview"; 266 54 Restart = "always"; 267 - RestartSec = "10s"; 268 - EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 269 - 270 - # state directory 271 - StateDirectory = "appview"; 272 - WorkingDirectory = "/var/lib/appview"; 273 - 274 - # security hardening 275 - NoNewPrivileges = true; 276 - PrivateTmp = true; 277 - ProtectSystem = "strict"; 278 - ProtectHome = true; 279 - ReadWritePaths = ["/var/lib/appview"]; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 280 56 }; 281 57 282 - environment = 283 - { 284 - TANGLED_DB_PATH = cfg.dbPath; 285 - TANGLED_LISTEN_ADDR = cfg.listenAddr; 286 - TANGLED_APPVIEW_HOST = cfg.appviewHost; 287 - TANGLED_APPVIEW_NAME = cfg.appviewName; 288 - TANGLED_DEV = 289 - if cfg.dev 290 - then "true" 291 - else "false"; 292 - } 293 - // optionalAttrs (cfg.disallowedNicknamesFile != null) { 294 - TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile; 295 - } 296 - // { 297 - TANGLED_REDIS_ADDR = cfg.redis.addr; 298 - TANGLED_REDIS_DB = toString cfg.redis.db; 299 - 300 - TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint; 301 - 302 - TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval; 303 - TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval; 304 - TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout; 305 - TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount; 306 - TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize; 307 - 308 - TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval; 309 - TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval; 310 - TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout; 311 - TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount; 312 - TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize; 313 - 314 - TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom; 315 - 316 - TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint; 317 - 318 - TANGLED_CAMO_HOST = cfg.camo.host; 319 - 320 - TANGLED_AVATAR_HOST = cfg.avatar.host; 321 - 322 - TANGLED_PLC_URL = cfg.plc.url; 323 - 324 - TANGLED_PDS_HOST = cfg.pds.host; 325 - 326 - TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults; 327 - TANGLED_LABEL_GFI = cfg.label.goodFirstIssue; 328 - }; 58 + environment = { 59 + TANGLED_DB_PATH = "appview.db"; 60 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 + }; 329 62 }; 330 63 }; 331 64 }
+6 -76
nix/modules/knot.nix
··· 4 4 lib, 5 5 ... 6 6 }: let 7 - cfg = config.services.tangled.knot; 7 + cfg = config.services.tangled-knot; 8 8 in 9 9 with lib; { 10 10 options = { 11 - services.tangled.knot = { 11 + services.tangled-knot = { 12 12 enable = mkOption { 13 13 type = types.bool; 14 14 default = false; ··· 22 22 23 23 appviewEndpoint = mkOption { 24 24 type = types.str; 25 - default = "https://tangled.org"; 25 + default = "https://tangled.sh"; 26 26 description = "Appview endpoint"; 27 27 }; 28 28 ··· 51 51 description = "Path where repositories are scanned from"; 52 52 }; 53 53 54 - readme = mkOption { 55 - type = types.listOf types.str; 56 - default = [ 57 - "README.md" 58 - "readme.md" 59 - "README" 60 - "readme" 61 - "README.markdown" 62 - "readme.markdown" 63 - "README.txt" 64 - "readme.txt" 65 - "README.rst" 66 - "readme.rst" 67 - "README.org" 68 - "readme.org" 69 - "README.asciidoc" 70 - "readme.asciidoc" 71 - ]; 72 - description = "List of README filenames to look for (in priority order)"; 73 - }; 74 - 75 54 mainBranch = mkOption { 76 55 type = types.str; 77 56 default = "main"; 78 57 description = "Default branch name for repositories"; 79 - }; 80 - }; 81 - 82 - git = { 83 - userName = mkOption { 84 - type = types.str; 85 - default = "Tangled"; 86 - description = "Git user name used as committer"; 87 - }; 88 - 89 - userEmail = mkOption { 90 - type = types.str; 91 - default = "noreply@tangled.org"; 92 - description = "Git user email used as committer"; 93 58 }; 94 59 }; 95 60 ··· 142 107 143 108 hostname = mkOption { 144 109 type = types.str; 145 - example = "my.knot.com"; 110 + example = "knot.tangled.sh"; 146 111 description = "Hostname for the server (required)"; 147 - }; 148 - 149 - plcUrl = mkOption { 150 - type = types.str; 151 - default = "https://plc.directory"; 152 - description = "atproto PLC directory"; 153 - }; 154 - 155 - jetstreamEndpoint = mkOption { 156 - type = types.str; 157 - default = "wss://jetstream1.us-west.bsky.network/subscribe"; 158 - description = "Jetstream endpoint to subscribe to"; 159 - }; 160 - 161 - logDids = mkOption { 162 - type = types.bool; 163 - default = true; 164 - description = "Enable logging of DIDs"; 165 112 }; 166 113 167 114 dev = mkOption { ··· 231 178 mkdir -p "${cfg.stateDir}/.config/git" 232 179 cat > "${cfg.stateDir}/.config/git/config" << EOF 233 180 [user] 234 - name = ${cfg.git.userName} 235 - email = ${cfg.git.userEmail} 181 + name = Git User 182 + email = git@example.com 236 183 [receive] 237 184 advertisePushOptions = true 238 - [uploadpack] 239 - allowFilter = true 240 185 EOF 241 186 ${setMotd} 242 187 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 248 193 WorkingDirectory = cfg.stateDir; 249 194 Environment = [ 250 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 251 - "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 252 196 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 253 - "KNOT_GIT_USER_NAME=${cfg.git.userName}" 254 - "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 255 197 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 256 198 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 257 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 258 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 259 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 260 - "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 261 - "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 262 202 "KNOT_SERVER_OWNER=${cfg.server.owner}" 263 - "KNOT_SERVER_LOG_DIDS=${ 264 - if cfg.server.logDids 265 - then "true" 266 - else "false" 267 - }" 268 - "KNOT_SERVER_DEV=${ 269 - if cfg.server.dev 270 - then "true" 271 - else "false" 272 - }" 273 203 ]; 274 204 ExecStart = "${cfg.package}/bin/knot server"; 275 205 Restart = "always";
+5 -12
nix/modules/spindle.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled.spindle; 6 + cfg = config.services.tangled-spindle; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled.spindle = { 10 + services.tangled-spindle = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; ··· 33 33 34 34 hostname = mkOption { 35 35 type = types.str; 36 - example = "my.spindle.com"; 36 + example = "spindle.tangled.sh"; 37 37 description = "Hostname for the server (required)"; 38 38 }; 39 39 40 - plcUrl = mkOption { 41 - type = types.str; 42 - default = "https://plc.directory"; 43 - description = "atproto PLC directory"; 44 - }; 45 - 46 40 jetstreamEndpoint = mkOption { 47 41 type = types.str; 48 42 default = "wss://jetstream1.us-west.bsky.network/subscribe"; ··· 98 92 pipelines = { 99 93 nixery = mkOption { 100 94 type = types.str; 101 - default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 95 + default = "nixery.tangled.sh"; 102 96 description = "Nixery instance to use"; 103 97 }; 104 98 ··· 125 119 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 126 120 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 127 121 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 128 - "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 - "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 122 + "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 130 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 131 124 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 132 125 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
-3
nix/pkgs/appview-static-files.nix
··· 5 5 lucide-src, 6 6 inter-fonts-src, 7 7 ibm-plex-mono-src, 8 - actor-typeahead-src, 9 8 sqlite-lib, 10 9 tailwindcss, 11 10 src, ··· 23 22 cp -rf ${lucide-src}/*.svg icons/ 24 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 25 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 26 - cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 27 25 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 - cp -f ${actor-typeahead-src}/actor-typeahead.js . 29 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 30 27 # for whatever reason (produces broken css), so we are doing this instead 31 28 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+18
nix/pkgs/genjwks.nix
··· 1 + { 2 + buildGoApplication, 3 + modules, 4 + }: 5 + buildGoApplication { 6 + pname = "genjwks"; 7 + version = "0.1.0"; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 15 + inherit modules; 16 + doCheck = false; 17 + CGO_ENABLED = 0; 18 + }
-12
nix/pkgs/goat.nix
··· 1 - { 2 - buildGoModule, 3 - indigo, 4 - }: 5 - buildGoModule { 6 - pname = "goat"; 7 - version = "0.1.0"; 8 - src = indigo; 9 - subPackages = ["cmd/goat"]; 10 - vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 - doCheck = false; 12 - }
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.1-alpha"; 7 + version = "1.9.0-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+8 -21
nix/vm.nix
··· 10 10 if var == "" 11 11 then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 12 else var; 13 - envVarOr = name: default: let 14 - var = builtins.getEnv name; 15 - in 16 - if var != "" 17 - then var 18 - else default; 19 - 20 - plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 - jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 13 in 23 14 nixpkgs.lib.nixosSystem { 24 15 inherit system; ··· 82 73 time.timeZone = "Europe/London"; 83 74 services.getty.autologinUser = "root"; 84 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 85 - services.tangled.knot = { 76 + services.tangled-knot = { 86 77 enable = true; 87 78 motd = "Welcome to the development knot!\n"; 88 79 server = { 89 80 owner = envVar "TANGLED_VM_KNOT_OWNER"; 90 - hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000"; 91 - plcUrl = plcUrl; 92 - jetstreamEndpoint = jetstream; 81 + hostname = "localhost:6000"; 93 82 listenAddr = "0.0.0.0:6000"; 94 83 }; 95 84 }; 96 - services.tangled.spindle = { 85 + services.tangled-spindle = { 97 86 enable = true; 98 87 server = { 99 88 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 100 - hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 - plcUrl = plcUrl; 102 - jetstreamEndpoint = jetstream; 89 + hostname = "localhost:6555"; 103 90 listenAddr = "0.0.0.0:6555"; 104 91 dev = true; 105 92 queueSize = 100; ··· 112 99 users = { 113 100 # So we don't have to deal with permission clashing between 114 101 # blank disk VMs and existing state 115 - users.${config.services.tangled.knot.gitUser}.uid = 666; 116 - groups.${config.services.tangled.knot.gitUser}.gid = 666; 102 + users.${config.services.tangled-knot.gitUser}.uid = 666; 103 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 117 104 118 105 # TODO: separate spindle user 119 106 }; ··· 133 120 serviceConfig.PermissionsStartOnly = true; 134 121 }; 135 122 in { 136 - knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 - spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 123 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 138 125 }; 139 126 }) 140 127 ];
+7 -18
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 - "errors" 5 4 "fmt" 6 5 "log" 7 6 "os" ··· 43 42 // IsPatchValid checks if the given patch string is valid. 44 43 // It performs very basic sniffing for either git-diff or git-format-patch 45 44 // header lines. For format patches, it attempts to extract and validate each one. 46 - var ( 47 - EmptyPatchError error = errors.New("patch is empty") 48 - GenericPatchError error = errors.New("patch is invalid") 49 - FormatPatchError error = errors.New("patch is not a valid format-patch") 50 - ) 51 - 52 - func IsPatchValid(patch string) error { 45 + func IsPatchValid(patch string) bool { 53 46 if len(patch) == 0 { 54 - return EmptyPatchError 47 + return false 55 48 } 56 49 57 50 lines := strings.Split(patch, "\n") 58 51 if len(lines) < 2 { 59 - return EmptyPatchError 52 + return false 60 53 } 61 54 62 55 firstLine := strings.TrimSpace(lines[0]) ··· 67 60 strings.HasPrefix(firstLine, "Index: ") || 68 61 strings.HasPrefix(firstLine, "+++ ") || 69 62 strings.HasPrefix(firstLine, "@@ ") { 70 - return nil 63 + return true 71 64 } 72 65 73 66 // check if it's format-patch ··· 77 70 // it's safe to say it's broken. 78 71 patches, err := ExtractPatches(patch) 79 72 if err != nil { 80 - return fmt.Errorf("%w: %w", FormatPatchError, err) 81 - } 82 - if len(patches) == 0 { 83 - return EmptyPatchError 73 + return false 84 74 } 85 - 86 - return nil 75 + return len(patches) > 0 87 76 } 88 77 89 - return GenericPatchError 78 + return false 90 79 } 91 80 92 81 func IsFormatPatch(patch string) bool {
+12 -13
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 - "errors" 5 4 "reflect" 6 5 "testing" 7 6 ) ··· 10 9 tests := []struct { 11 10 name string 12 11 patch string 13 - expected error 12 + expected bool 14 13 }{ 15 14 { 16 15 name: `empty patch`, 17 16 patch: ``, 18 - expected: EmptyPatchError, 17 + expected: false, 19 18 }, 20 19 { 21 20 name: `single line patch`, 22 21 patch: `single line`, 23 - expected: EmptyPatchError, 22 + expected: false, 24 23 }, 25 24 { 26 25 name: `valid diff patch`, ··· 32 31 -old line 33 32 +new line 34 33 context`, 35 - expected: nil, 34 + expected: true, 36 35 }, 37 36 { 38 37 name: `valid patch starting with ---`, ··· 42 41 -old line 43 42 +new line 44 43 context`, 45 - expected: nil, 44 + expected: true, 46 45 }, 47 46 { 48 47 name: `valid patch starting with Index`, ··· 54 53 -old line 55 54 +new line 56 55 context`, 57 - expected: nil, 56 + expected: true, 58 57 }, 59 58 { 60 59 name: `valid patch starting with +++`, ··· 64 63 -old line 65 64 +new line 66 65 context`, 67 - expected: nil, 66 + expected: true, 68 67 }, 69 68 { 70 69 name: `valid patch starting with @@`, ··· 73 72 +new line 74 73 context 75 74 `, 76 - expected: nil, 75 + expected: true, 77 76 }, 78 77 { 79 78 name: `valid format patch`, ··· 91 90 +new content 92 91 -- 93 92 2.48.1`, 94 - expected: nil, 93 + expected: true, 95 94 }, 96 95 { 97 96 name: `invalid format patch`, 98 97 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 99 98 From: Author <author@example.com> 100 99 This is not a valid patch format`, 101 - expected: FormatPatchError, 100 + expected: false, 102 101 }, 103 102 { 104 103 name: `not a patch at all`, ··· 106 105 just some 107 106 random text 108 107 that isn't a patch`, 109 - expected: GenericPatchError, 108 + expected: false, 110 109 }, 111 110 } 112 111 113 112 for _, tt := range tests { 114 113 t.Run(tt.name, func(t *testing.T) { 115 114 result := IsPatchValid(tt.patch) 116 - if !errors.Is(result, tt.expected) { 115 + if result != tt.expected { 117 116 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 118 117 } 119 118 })
+26
scripts/appview.sh
··· 1 + #!/bin/bash 2 + 3 + # Variables 4 + BINARY_NAME="appview" 5 + BINARY_PATH=".bin/app" 6 + SERVER="95.111.206.63" 7 + USER="appview" 8 + 9 + # SCP the binary to root's home directory 10 + scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME" 11 + 12 + # SSH into the server and perform the necessary operations 13 + ssh root@$SERVER <<EOF 14 + set -e # Exit on error 15 + 16 + # Move binary to /usr/local/bin and set executable permissions 17 + mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME 18 + chmod +x /usr/local/bin/$BINARY_NAME 19 + 20 + su appview 21 + cd ~ 22 + ./reset.sh 23 + EOF 24 + 25 + echo "Deployment complete." 26 +
+5
scripts/generate-jwks.sh
··· 1 + #! /usr/bin/env bash 2 + 3 + set -e 4 + 5 + go run ./cmd/genjwks/
-1
spindle/config/config.go
··· 13 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 14 Hostname string `env:"HOSTNAME, required"` 15 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 16 Dev bool `env:"DEV, default=false"` 18 17 Owner string `env:"OWNER, required"` 19 18 Secrets Secrets `env:",prefix=SECRETS_"`
+3 -13
spindle/engine/engine.go
··· 79 79 defer cancel() 80 80 81 81 for stepIdx, step := range w.Steps { 82 - // log start of step 83 82 if wfLogger != nil { 84 - wfLogger. 85 - ControlWriter(stepIdx, step, models.StepStatusStart). 86 - Write([]byte{0}) 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 87 85 } 88 86 89 87 err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 90 - 91 - // log end of step 92 - if wfLogger != nil { 93 - wfLogger. 94 - ControlWriter(stepIdx, step, models.StepStatusEnd). 95 - Write([]byte{0}) 96 - } 97 - 98 88 if err != nil { 99 89 if errors.Is(err, ErrTimedOut) { 100 90 dbErr := db.StatusTimeout(wid, n) ··· 125 115 if err := eg.Wait(); err != nil { 126 116 l.Error("failed to run one or more workflows", "err", err) 127 117 } else { 128 - l.Info("successfully ran full pipeline") 118 + l.Error("successfully ran full pipeline") 129 119 } 130 120 }
+3 -3
spindle/engines/nixery/engine.go
··· 222 222 }, 223 223 ReadonlyRootfs: false, 224 224 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 226 SecurityOpt: []string{"no-new-privileges"}, 227 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 228 }, nil, nil, "") ··· 381 381 defer logs.Close() 382 382 383 383 _, err = stdcopy.StdCopy( 384 - wfLogger.DataWriter(stepIdx, "stdout"), 385 - wfLogger.DataWriter(stepIdx, "stderr"), 384 + wfLogger.DataWriter("stdout"), 385 + wfLogger.DataWriter("stderr"), 386 386 logs.Reader, 387 387 ) 388 388 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+7 -3
spindle/ingester.go
··· 9 9 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/eventconsumer" 12 + "tangled.org/core/idresolver" 12 13 "tangled.org/core/rbac" 13 14 "tangled.org/core/spindle/db" 14 15 ··· 141 142 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 143 var err error 143 144 did := e.Did 145 + resolver := idresolver.DefaultResolver() 144 146 145 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 148 ··· 188 190 } 189 191 190 192 // add collaborators to rbac 191 - owner, err := s.res.ResolveIdent(ctx, did) 193 + owner, err := resolver.ResolveIdent(ctx, did) 192 194 if err != nil || owner.Handle.IsInvalidHandle() { 193 195 return err 194 196 } ··· 223 225 return err 224 226 } 225 227 226 - subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 228 + resolver := idresolver.DefaultResolver() 229 + 230 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 227 231 if err != nil || subjectId.Handle.IsInvalidHandle() { 228 232 return err 229 233 } ··· 236 240 237 241 // TODO: get rid of this entirely 238 242 // resolve this aturi to extract the repo record 239 - owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 243 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 240 244 if err != nil || owner.Handle.IsInvalidHandle() { 241 245 return fmt.Errorf("failed to resolve handle: %w", err) 242 246 }
-35
spindle/middleware.go
··· 1 - package spindle 2 - 3 - import ( 4 - "log/slog" 5 - "net/http" 6 - "time" 7 - ) 8 - 9 - func (s *Spindle) RequestLogger(next http.Handler) http.Handler { 10 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 - start := time.Now() 12 - 13 - next.ServeHTTP(w, r) 14 - 15 - // Build query params as slog.Attrs for the group 16 - queryParams := r.URL.Query() 17 - queryAttrs := make([]any, 0, len(queryParams)) 18 - for key, values := range queryParams { 19 - if len(values) == 1 { 20 - queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 - } else { 22 - queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 - } 24 - } 25 - 26 - s.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 - slog.Group("request", 28 - slog.String("method", r.Method), 29 - slog.String("path", r.URL.Path), 30 - slog.Group("query", queryAttrs...), 31 - slog.Duration("duration", time.Since(start)), 32 - ), 33 - ) 34 - }) 35 - }
+11 -14
spindle/models/logger.go
··· 37 37 return l.file.Close() 38 38 } 39 39 40 - func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 41 42 return &dataWriter{ 42 43 logger: l, 43 - idx: idx, 44 44 stream: stream, 45 45 } 46 46 } 47 47 48 - func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 49 return &controlWriter{ 50 - logger: l, 51 - idx: idx, 52 - step: step, 53 - stepStatus: stepStatus, 50 + logger: l, 51 + idx: idx, 52 + step: step, 54 53 } 55 54 } 56 55 57 56 type dataWriter struct { 58 57 logger *WorkflowLogger 59 - idx int 60 58 stream string 61 59 } 62 60 63 61 func (w *dataWriter) Write(p []byte) (int, error) { 64 62 line := strings.TrimRight(string(p), "\r\n") 65 - entry := NewDataLogLine(w.idx, line, w.stream) 63 + entry := NewDataLogLine(line, w.stream) 66 64 if err := w.logger.encoder.Encode(entry); err != nil { 67 65 return 0, err 68 66 } ··· 70 68 } 71 69 72 70 type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step Step 76 - stepStatus StepStatus 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 77 74 } 78 75 79 76 func (w *controlWriter) Write(_ []byte) (int, error) { 80 - entry := NewControlLogLine(w.idx, w.step, w.stepStatus) 77 + entry := NewControlLogLine(w.idx, w.step) 81 78 if err := w.logger.encoder.Encode(entry); err != nil { 82 79 return 0, err 83 80 }
+8 -23
spindle/models/models.go
··· 4 4 "fmt" 5 5 "regexp" 6 6 "slices" 7 - "time" 8 7 9 8 "tangled.org/core/api/tangled" 10 9 ··· 77 76 var ( 78 77 // step log data 79 78 LogKindData LogKind = "data" 80 - // indicates status of a step 79 + // indicates start/end of a step 81 80 LogKindControl LogKind = "control" 82 81 ) 83 82 84 - // step status indicator in control log lines 85 - type StepStatus string 86 - 87 - var ( 88 - StepStatusStart StepStatus = "start" 89 - StepStatusEnd StepStatus = "end" 90 - ) 91 - 92 83 type LogLine struct { 93 - Kind LogKind `json:"kind"` 94 - Content string `json:"content"` 95 - Time time.Time `json:"time"` 96 - StepId int `json:"step_id"` 84 + Kind LogKind `json:"kind"` 85 + Content string `json:"content"` 97 86 98 87 // fields if kind is "data" 99 88 Stream string `json:"stream,omitempty"` 100 89 101 90 // fields if kind is "control" 102 - StepStatus StepStatus `json:"step_status,omitempty"` 103 - StepKind StepKind `json:"step_kind,omitempty"` 104 - StepCommand string `json:"step_command,omitempty"` 91 + StepId int `json:"step_id,omitempty"` 92 + StepKind StepKind `json:"step_kind,omitempty"` 93 + StepCommand string `json:"step_command,omitempty"` 105 94 } 106 95 107 - func NewDataLogLine(idx int, content, stream string) LogLine { 96 + func NewDataLogLine(content, stream string) LogLine { 108 97 return LogLine{ 109 98 Kind: LogKindData, 110 - Time: time.Now(), 111 99 Content: content, 112 - StepId: idx, 113 100 Stream: stream, 114 101 } 115 102 } 116 103 117 - func NewControlLogLine(idx int, step Step, status StepStatus) LogLine { 104 + func NewControlLogLine(idx int, step Step) LogLine { 118 105 return LogLine{ 119 106 Kind: LogKindControl, 120 - Time: time.Now(), 121 107 Content: step.Name(), 122 108 StepId: idx, 123 - StepStatus: status, 124 109 StepKind: step.Kind(), 125 110 StepCommand: step.Command(), 126 111 }
+47 -92
spindle/server.go
··· 49 49 vault secrets.Manager 50 50 } 51 51 52 - // New creates a new Spindle server with the provided configuration and engines. 53 - func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 52 + func Run(ctx context.Context) error { 54 53 logger := log.FromContext(ctx) 55 54 55 + cfg, err := config.Load(ctx) 56 + if err != nil { 57 + return fmt.Errorf("failed to load config: %w", err) 58 + } 59 + 56 60 d, err := db.Make(cfg.Server.DBPath) 57 61 if err != nil { 58 - return nil, fmt.Errorf("failed to setup db: %w", err) 62 + return fmt.Errorf("failed to setup db: %w", err) 59 63 } 60 64 61 65 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 62 66 if err != nil { 63 - return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 67 + return fmt.Errorf("failed to setup rbac enforcer: %w", err) 64 68 } 65 69 e.E.EnableAutoSave(true) 66 70 ··· 70 74 switch cfg.Server.Secrets.Provider { 71 75 case "openbao": 72 76 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 73 - return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 77 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 74 78 } 75 79 vault, err = secrets.NewOpenBaoManager( 76 80 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 78 82 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 79 83 ) 80 84 if err != nil { 81 - return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 85 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 82 86 } 83 87 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 84 88 case "sqlite", "": 85 89 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 86 90 if err != nil { 87 - return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 91 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 88 92 } 89 93 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 90 94 default: 91 - return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 95 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 + } 97 + 98 + nixeryEng, err := nixery.New(ctx, cfg) 99 + if err != nil { 100 + return err 92 101 } 93 102 94 103 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 99 108 tangled.RepoNSID, 100 109 tangled.RepoCollaboratorNSID, 101 110 } 102 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 103 112 if err != nil { 104 - return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 113 + return fmt.Errorf("failed to setup jetstream client: %w", err) 105 114 } 106 115 jc.AddDid(cfg.Server.Owner) 107 116 108 117 // Check if the spindle knows about any Dids; 109 118 dids, err := d.GetAllDids() 110 119 if err != nil { 111 - return nil, fmt.Errorf("failed to get all dids: %w", err) 120 + return fmt.Errorf("failed to get all dids: %w", err) 112 121 } 113 122 for _, d := range dids { 114 123 jc.AddDid(d) 115 124 } 116 125 117 - resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 126 + resolver := idresolver.DefaultResolver() 118 127 119 - spindle := &Spindle{ 128 + spindle := Spindle{ 120 129 jc: jc, 121 130 e: e, 122 131 db: d, 123 132 l: logger, 124 133 n: &n, 125 - engs: engines, 134 + engs: map[string]models.Engine{"nixery": nixeryEng}, 126 135 jq: jq, 127 136 cfg: cfg, 128 137 res: resolver, ··· 131 140 132 141 err = e.AddSpindle(rbacDomain) 133 142 if err != nil { 134 - return nil, fmt.Errorf("failed to set rbac domain: %w", err) 143 + return fmt.Errorf("failed to set rbac domain: %w", err) 135 144 } 136 145 err = spindle.configureOwner() 137 146 if err != nil { 138 - return nil, err 147 + return err 139 148 } 140 149 logger.Info("owner set", "did", cfg.Server.Owner) 141 150 151 + // starts a job queue runner in the background 152 + jq.Start() 153 + defer jq.Stop() 154 + 155 + // Stop vault token renewal if it implements Stopper 156 + if stopper, ok := vault.(secrets.Stopper); ok { 157 + defer stopper.Stop() 158 + } 159 + 142 160 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 143 161 if err != nil { 144 - return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 162 + return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 145 163 } 146 164 147 165 err = jc.StartJetstream(ctx, spindle.ingest()) 148 166 if err != nil { 149 - return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 167 + return fmt.Errorf("failed to start jetstream consumer: %w", err) 150 168 } 151 169 152 170 // for each incoming sh.tangled.pipeline, we execute 153 171 // spindle.processPipeline, which in turn enqueues the pipeline 154 172 // job in the above registered queue. 155 173 ccfg := eventconsumer.NewConsumerConfig() 156 - ccfg.Logger = log.SubLogger(logger, "eventconsumer") 174 + ccfg.Logger = logger 157 175 ccfg.Dev = cfg.Server.Dev 158 176 ccfg.ProcessFunc = spindle.processPipeline 159 177 ccfg.CursorStore = cursorStore 160 178 knownKnots, err := d.Knots() 161 179 if err != nil { 162 - return nil, err 180 + return err 163 181 } 164 182 for _, knot := range knownKnots { 165 183 logger.Info("adding source start", "knot", knot) ··· 167 185 } 168 186 spindle.ks = eventconsumer.NewConsumer(*ccfg) 169 187 170 - return spindle, nil 171 - } 172 - 173 - // DB returns the database instance. 174 - func (s *Spindle) DB() *db.DB { 175 - return s.db 176 - } 177 - 178 - // Queue returns the job queue instance. 179 - func (s *Spindle) Queue() *queue.Queue { 180 - return s.jq 181 - } 182 - 183 - // Engines returns the map of available engines. 184 - func (s *Spindle) Engines() map[string]models.Engine { 185 - return s.engs 186 - } 187 - 188 - // Vault returns the secrets manager instance. 189 - func (s *Spindle) Vault() secrets.Manager { 190 - return s.vault 191 - } 192 - 193 - // Notifier returns the notifier instance. 194 - func (s *Spindle) Notifier() *notifier.Notifier { 195 - return s.n 196 - } 197 - 198 - // Enforcer returns the RBAC enforcer instance. 199 - func (s *Spindle) Enforcer() *rbac.Enforcer { 200 - return s.e 201 - } 202 - 203 - // Start starts the Spindle server (blocking). 204 - func (s *Spindle) Start(ctx context.Context) error { 205 - // starts a job queue runner in the background 206 - s.jq.Start() 207 - defer s.jq.Stop() 208 - 209 - // Stop vault token renewal if it implements Stopper 210 - if stopper, ok := s.vault.(secrets.Stopper); ok { 211 - defer stopper.Stop() 212 - } 213 - 214 188 go func() { 215 - s.l.Info("starting knot event consumer") 216 - s.ks.Start(ctx) 189 + logger.Info("starting knot event consumer") 190 + spindle.ks.Start(ctx) 217 191 }() 218 192 219 - s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 220 - return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 221 - } 193 + logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 + logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 222 195 223 - func Run(ctx context.Context) error { 224 - cfg, err := config.Load(ctx) 225 - if err != nil { 226 - return fmt.Errorf("failed to load config: %w", err) 227 - } 228 - 229 - nixeryEng, err := nixery.New(ctx, cfg) 230 - if err != nil { 231 - return err 232 - } 233 - 234 - s, err := New(ctx, cfg, map[string]models.Engine{ 235 - "nixery": nixeryEng, 236 - }) 237 - if err != nil { 238 - return err 239 - } 240 - 241 - return s.Start(ctx) 196 + return nil 242 197 } 243 198 244 199 func (s *Spindle) Router() http.Handler { ··· 255 210 } 256 211 257 212 func (s *Spindle) XrpcRouter() http.Handler { 258 - serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 213 + logger := s.l.With("route", "xrpc") 259 214 260 - l := log.SubLogger(s.l, "xrpc") 215 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 261 216 262 217 x := xrpc.Xrpc{ 263 - Logger: l, 218 + Logger: logger, 264 219 Db: s.db, 265 220 Enforcer: s.e, 266 221 Engines: s.engs, ··· 350 305 351 306 ok := s.jq.Enqueue(queue.Job{ 352 307 Run: func() error { 353 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 308 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 354 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 355 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 356 311 Workflows: workflows,
+3 -8
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 - "tangled.org/core/log" 14 13 "tangled.org/core/spindle/models" 15 14 16 15 "github.com/go-chi/chi/v5" ··· 24 23 } 25 24 26 25 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 27 - l := log.SubLogger(s.l, "eventstream") 28 - 26 + l := s.l.With("handler", "Events") 29 27 l.Debug("received new connection") 30 28 31 29 conn, err := upgrader.Upgrade(w, r, nil) ··· 84 82 } 85 83 case <-time.After(30 * time.Second): 86 84 // send a keep-alive 85 + l.Debug("sent keepalive") 87 86 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 88 87 l.Error("failed to write control", "err", err) 89 88 } ··· 213 212 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 214 213 return fmt.Errorf("failed to write to websocket: %w", err) 215 214 } 216 - case <-time.After(30 * time.Second): 217 - // send a keep-alive 218 - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 219 - return fmt.Errorf("failed to write control: %w", err) 220 - } 221 215 } 222 216 } 223 217 } ··· 228 222 s.l.Debug("err", "err", err) 229 223 return err 230 224 } 225 + s.l.Debug("ops", "ops", events) 231 226 232 227 for _, event := range events { 233 228 // first extract the inner json into a map
+5 -7
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 - "github.com/bluekeyes/go-gitdiff/gitdiff" 5 4 "github.com/go-git/go-git/v5/plumbing/object" 6 5 ) 7 6 ··· 34 33 } 35 34 36 35 type RepoFormatPatchResponse struct { 37 - Rev1 string `json:"rev1,omitempty"` 38 - Rev2 string `json:"rev2,omitempty"` 39 - FormatPatch []FormatPatch `json:"format_patch,omitempty"` 40 - FormatPatchRaw string `json:"patch,omitempty"` 41 - CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 42 - CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 36 + Rev1 string `json:"rev1,omitempty"` 37 + Rev2 string `json:"rev2,omitempty"` 38 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 + MergeBase string `json:"merge_base,omitempty"` // deprecated 40 + Patch string `json:"patch,omitempty"` 43 41 } 44 42 45 43 type RepoTreeResponse struct {
+5 -28
types/tree.go
··· 4 4 "time" 5 5 6 6 "github.com/go-git/go-git/v5/plumbing" 7 - "github.com/go-git/go-git/v5/plumbing/filemode" 8 7 ) 9 8 10 9 // A nicer git tree representation. 11 10 type NiceTree struct { 12 11 // Relative path 13 - Name string `json:"name"` 14 - Mode string `json:"mode"` 15 - Size int64 `json:"size"` 12 + Name string `json:"name"` 13 + Mode string `json:"mode"` 14 + Size int64 `json:"size"` 15 + IsFile bool `json:"is_file"` 16 + IsSubtree bool `json:"is_subtree"` 16 17 17 18 LastCommit *LastCommitInfo `json:"last_commit,omitempty"` 18 - } 19 - 20 - func (t *NiceTree) FileMode() (filemode.FileMode, error) { 21 - return filemode.New(t.Mode) 22 - } 23 - 24 - func (t *NiceTree) IsFile() bool { 25 - m, err := t.FileMode() 26 - 27 - if err != nil { 28 - return false 29 - } 30 - 31 - return m.IsFile() 32 - } 33 - 34 - func (t *NiceTree) IsSubmodule() bool { 35 - m, err := t.FileMode() 36 - 37 - if err != nil { 38 - return false 39 - } 40 - 41 - return m == filemode.Submodule 42 19 } 43 20 44 21 type LastCommitInfo struct {
+1 -9
workflow/compile.go
··· 113 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 114 cw := &tangled.Pipeline_Workflow{} 115 115 116 - matched, err := w.Match(compiler.Trigger) 117 - if err != nil { 118 - compiler.Diagnostics.AddError( 119 - w.Name, 120 - fmt.Errorf("failed to execute workflow: %w", err), 121 - ) 122 - return nil 123 - } 124 - if !matched { 116 + if !w.Match(compiler.Trigger) { 125 117 compiler.Diagnostics.AddWarning( 126 118 w.Name, 127 119 WorkflowSkipped,
-125
workflow/compile_test.go
··· 95 95 assert.Len(t, c.Diagnostics.Errors, 1) 96 96 assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 97 } 98 - 99 - func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) { 100 - wf := Workflow{ 101 - Name: ".tangled/workflows/branch_and_tag.yml", 102 - When: []Constraint{ 103 - { 104 - Event: []string{"push"}, 105 - Branch: []string{"main", "develop"}, 106 - Tag: []string{"v*"}, 107 - }, 108 - }, 109 - Engine: "nixery", 110 - } 111 - 112 - tests := []struct { 113 - name string 114 - trigger tangled.Pipeline_TriggerMetadata 115 - shouldMatch bool 116 - expectedCount int 117 - }{ 118 - { 119 - name: "matches main branch", 120 - trigger: tangled.Pipeline_TriggerMetadata{ 121 - Kind: string(TriggerKindPush), 122 - Push: &tangled.Pipeline_PushTriggerData{ 123 - Ref: "refs/heads/main", 124 - OldSha: strings.Repeat("0", 40), 125 - NewSha: strings.Repeat("f", 40), 126 - }, 127 - }, 128 - shouldMatch: true, 129 - expectedCount: 1, 130 - }, 131 - { 132 - name: "matches develop branch", 133 - trigger: tangled.Pipeline_TriggerMetadata{ 134 - Kind: string(TriggerKindPush), 135 - Push: &tangled.Pipeline_PushTriggerData{ 136 - Ref: "refs/heads/develop", 137 - OldSha: strings.Repeat("0", 40), 138 - NewSha: strings.Repeat("f", 40), 139 - }, 140 - }, 141 - shouldMatch: true, 142 - expectedCount: 1, 143 - }, 144 - { 145 - name: "matches v* tag pattern", 146 - trigger: tangled.Pipeline_TriggerMetadata{ 147 - Kind: string(TriggerKindPush), 148 - Push: &tangled.Pipeline_PushTriggerData{ 149 - Ref: "refs/tags/v1.0.0", 150 - OldSha: strings.Repeat("0", 40), 151 - NewSha: strings.Repeat("f", 40), 152 - }, 153 - }, 154 - shouldMatch: true, 155 - expectedCount: 1, 156 - }, 157 - { 158 - name: "matches v* tag pattern with different version", 159 - trigger: tangled.Pipeline_TriggerMetadata{ 160 - Kind: string(TriggerKindPush), 161 - Push: &tangled.Pipeline_PushTriggerData{ 162 - Ref: "refs/tags/v2.5.3", 163 - OldSha: strings.Repeat("0", 40), 164 - NewSha: strings.Repeat("f", 40), 165 - }, 166 - }, 167 - shouldMatch: true, 168 - expectedCount: 1, 169 - }, 170 - { 171 - name: "does not match master branch", 172 - trigger: tangled.Pipeline_TriggerMetadata{ 173 - Kind: string(TriggerKindPush), 174 - Push: &tangled.Pipeline_PushTriggerData{ 175 - Ref: "refs/heads/master", 176 - OldSha: strings.Repeat("0", 40), 177 - NewSha: strings.Repeat("f", 40), 178 - }, 179 - }, 180 - shouldMatch: false, 181 - expectedCount: 0, 182 - }, 183 - { 184 - name: "does not match non-v tag", 185 - trigger: tangled.Pipeline_TriggerMetadata{ 186 - Kind: string(TriggerKindPush), 187 - Push: &tangled.Pipeline_PushTriggerData{ 188 - Ref: "refs/tags/release-1.0", 189 - OldSha: strings.Repeat("0", 40), 190 - NewSha: strings.Repeat("f", 40), 191 - }, 192 - }, 193 - shouldMatch: false, 194 - expectedCount: 0, 195 - }, 196 - { 197 - name: "does not match feature branch", 198 - trigger: tangled.Pipeline_TriggerMetadata{ 199 - Kind: string(TriggerKindPush), 200 - Push: &tangled.Pipeline_PushTriggerData{ 201 - Ref: "refs/heads/feature/new-feature", 202 - OldSha: strings.Repeat("0", 40), 203 - NewSha: strings.Repeat("f", 40), 204 - }, 205 - }, 206 - shouldMatch: false, 207 - expectedCount: 0, 208 - }, 209 - } 210 - 211 - for _, tt := range tests { 212 - t.Run(tt.name, func(t *testing.T) { 213 - c := Compiler{Trigger: tt.trigger} 214 - cp := c.Compile([]Workflow{wf}) 215 - 216 - assert.Len(t, cp.Workflows, tt.expectedCount) 217 - if tt.shouldMatch { 218 - assert.Equal(t, wf.Name, cp.Workflows[0].Name) 219 - } 220 - }) 221 - } 222 - }
+19 -61
workflow/def.go
··· 8 8 9 9 "tangled.org/core/api/tangled" 10 10 11 - "github.com/bmatcuk/doublestar/v4" 12 11 "github.com/go-git/go-git/v5/plumbing" 13 12 "gopkg.in/yaml.v3" 14 13 ) ··· 34 33 35 34 Constraint struct { 36 35 Event StringList `yaml:"event"` 37 - Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 - Tag StringList `yaml:"tag"` // optional; only applies to push events 36 + Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 39 37 } 40 38 41 39 CloneOpts struct { ··· 61 59 return strings.ReplaceAll(string(t), "_", " ") 62 60 } 63 61 64 - // matchesPattern checks if a name matches any of the given patterns. 65 - // Patterns can be exact matches or glob patterns using * and **. 66 - // * matches any sequence of non-separator characters 67 - // ** matches any sequence of characters including separators 68 - func matchesPattern(name string, patterns []string) (bool, error) { 69 - for _, pattern := range patterns { 70 - matched, err := doublestar.Match(pattern, name) 71 - if err != nil { 72 - return false, err 73 - } 74 - if matched { 75 - return true, nil 76 - } 77 - } 78 - return false, nil 79 - } 80 - 81 62 func FromFile(name string, contents []byte) (Workflow, error) { 82 63 var wf Workflow 83 64 ··· 93 74 } 94 75 95 76 // if any of the constraints on a workflow is true, return true 96 - func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 77 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 97 78 // manual triggers always run the workflow 98 79 if trigger.Manual != nil { 99 - return true, nil 80 + return true 100 81 } 101 82 102 83 // if not manual, run through the constraint list and see if any one matches 103 84 for _, c := range w.When { 104 - matched, err := c.Match(trigger) 105 - if err != nil { 106 - return false, err 107 - } 108 - if matched { 109 - return true, nil 85 + if c.Match(trigger) { 86 + return true 110 87 } 111 88 } 112 89 113 90 // no constraints, always run this workflow 114 91 if len(w.When) == 0 { 115 - return true, nil 92 + return true 116 93 } 117 94 118 - return false, nil 95 + return false 119 96 } 120 97 121 - func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 98 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 122 99 match := true 123 100 124 101 // manual triggers always pass this constraint 125 102 if trigger.Manual != nil { 126 - return true, nil 103 + return true 127 104 } 128 105 129 106 // apply event constraints ··· 131 108 132 109 // apply branch constraints for PRs 133 110 if trigger.PullRequest != nil { 134 - matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) 135 - if err != nil { 136 - return false, err 137 - } 138 - match = match && matched 111 + match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 139 112 } 140 113 141 114 // apply ref constraints for pushes 142 115 if trigger.Push != nil { 143 - matched, err := c.MatchRef(trigger.Push.Ref) 144 - if err != nil { 145 - return false, err 146 - } 147 - match = match && matched 116 + match = match && c.MatchRef(trigger.Push.Ref) 148 117 } 149 118 150 - return match, nil 119 + return match 151 120 } 152 121 153 - func (c *Constraint) MatchRef(ref string) (bool, error) { 154 - refName := plumbing.ReferenceName(ref) 155 - shortName := refName.Short() 122 + func (c *Constraint) MatchBranch(branch string) bool { 123 + return slices.Contains(c.Branch, branch) 124 + } 156 125 126 + func (c *Constraint) MatchRef(ref string) bool { 127 + refName := plumbing.ReferenceName(ref) 157 128 if refName.IsBranch() { 158 - return c.MatchBranch(shortName) 159 - } 160 - 161 - if refName.IsTag() { 162 - return c.MatchTag(shortName) 129 + return slices.Contains(c.Branch, refName.Short()) 163 130 } 164 - 165 - return false, nil 166 - } 167 - 168 - func (c *Constraint) MatchBranch(branch string) (bool, error) { 169 - return matchesPattern(branch, c.Branch) 170 - } 171 - 172 - func (c *Constraint) MatchTag(tag string) (bool, error) { 173 - return matchesPattern(tag, c.Tag) 131 + return false 174 132 } 175 133 176 134 func (c *Constraint) MatchEvent(event string) bool {
+1 -284
workflow/def_test.go
··· 6 6 "github.com/stretchr/testify/assert" 7 7 ) 8 8 9 - func TestUnmarshalWorkflowWithBranch(t *testing.T) { 9 + func TestUnmarshalWorkflow(t *testing.T) { 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] ··· 38 38 39 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 40 } 41 - 42 - func TestUnmarshalWorkflowWithTags(t *testing.T) { 43 - yamlData := ` 44 - when: 45 - - event: ["push"] 46 - tag: ["v*", "release-*"]` 47 - 48 - wf, err := FromFile("test.yml", []byte(yamlData)) 49 - assert.NoError(t, err, "YAML should unmarshal without error") 50 - 51 - assert.Len(t, wf.When, 1, "Should have one constraint") 52 - assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag) 53 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 54 - } 55 - 56 - func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) { 57 - yamlData := ` 58 - when: 59 - - event: ["push"] 60 - branch: ["main", "develop"] 61 - tag: ["v*"]` 62 - 63 - wf, err := FromFile("test.yml", []byte(yamlData)) 64 - assert.NoError(t, err, "YAML should unmarshal without error") 65 - 66 - assert.Len(t, wf.When, 1, "Should have one constraint") 67 - assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 68 - assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 - } 70 - 71 - func TestMatchesPattern(t *testing.T) { 72 - tests := []struct { 73 - name string 74 - input string 75 - patterns []string 76 - expected bool 77 - }{ 78 - {"exact match", "main", []string{"main"}, true}, 79 - {"exact match in list", "develop", []string{"main", "develop"}, true}, 80 - {"no match", "feature", []string{"main", "develop"}, false}, 81 - {"wildcard prefix", "v1.0.0", []string{"v*"}, true}, 82 - {"wildcard suffix", "release-1.0", []string{"*-1.0"}, true}, 83 - {"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true}, 84 - {"double star prefix", "release-1.0.0", []string{"release-**"}, true}, 85 - {"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true}, 86 - {"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true}, 87 - {"double star no match", "feature/test", []string{"release/**"}, false}, 88 - {"no patterns matches nothing", "anything", []string{}, false}, 89 - {"pattern doesn't match", "v1.0.0", []string{"release-*"}, false}, 90 - {"complex pattern", "release/v1.2.3", []string{"release/*"}, true}, 91 - {"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false}, 92 - } 93 - 94 - for _, tt := range tests { 95 - t.Run(tt.name, func(t *testing.T) { 96 - result, _ := matchesPattern(tt.input, tt.patterns) 97 - assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected) 98 - }) 99 - } 100 - } 101 - 102 - func TestConstraintMatchRef_Branches(t *testing.T) { 103 - tests := []struct { 104 - name string 105 - constraint Constraint 106 - ref string 107 - expected bool 108 - }{ 109 - { 110 - name: "exact branch match", 111 - constraint: Constraint{Branch: []string{"main"}}, 112 - ref: "refs/heads/main", 113 - expected: true, 114 - }, 115 - { 116 - name: "branch glob match", 117 - constraint: Constraint{Branch: []string{"feature-*"}}, 118 - ref: "refs/heads/feature-123", 119 - expected: true, 120 - }, 121 - { 122 - name: "branch no match", 123 - constraint: Constraint{Branch: []string{"main"}}, 124 - ref: "refs/heads/develop", 125 - expected: false, 126 - }, 127 - { 128 - name: "no constraints matches nothing", 129 - constraint: Constraint{}, 130 - ref: "refs/heads/anything", 131 - expected: false, 132 - }, 133 - } 134 - 135 - for _, tt := range tests { 136 - t.Run(tt.name, func(t *testing.T) { 137 - result, _ := tt.constraint.MatchRef(tt.ref) 138 - assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 139 - }) 140 - } 141 - } 142 - 143 - func TestConstraintMatchRef_Tags(t *testing.T) { 144 - tests := []struct { 145 - name string 146 - constraint Constraint 147 - ref string 148 - expected bool 149 - }{ 150 - { 151 - name: "exact tag match", 152 - constraint: Constraint{Tag: []string{"v1.0.0"}}, 153 - ref: "refs/tags/v1.0.0", 154 - expected: true, 155 - }, 156 - { 157 - name: "tag glob match", 158 - constraint: Constraint{Tag: []string{"v*"}}, 159 - ref: "refs/tags/v1.2.3", 160 - expected: true, 161 - }, 162 - { 163 - name: "tag glob with pattern", 164 - constraint: Constraint{Tag: []string{"release-*"}}, 165 - ref: "refs/tags/release-2024", 166 - expected: true, 167 - }, 168 - { 169 - name: "tag no match", 170 - constraint: Constraint{Tag: []string{"v*"}}, 171 - ref: "refs/tags/release-1.0", 172 - expected: false, 173 - }, 174 - { 175 - name: "tag not matched when only branch constraint", 176 - constraint: Constraint{Branch: []string{"main"}}, 177 - ref: "refs/tags/v1.0.0", 178 - expected: false, 179 - }, 180 - } 181 - 182 - for _, tt := range tests { 183 - t.Run(tt.name, func(t *testing.T) { 184 - result, _ := tt.constraint.MatchRef(tt.ref) 185 - assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 186 - }) 187 - } 188 - } 189 - 190 - func TestConstraintMatchRef_Combined(t *testing.T) { 191 - tests := []struct { 192 - name string 193 - constraint Constraint 194 - ref string 195 - expected bool 196 - }{ 197 - { 198 - name: "matches branch in combined constraint", 199 - constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 200 - ref: "refs/heads/main", 201 - expected: true, 202 - }, 203 - { 204 - name: "matches tag in combined constraint", 205 - constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 206 - ref: "refs/tags/v1.0.0", 207 - expected: true, 208 - }, 209 - { 210 - name: "no match in combined constraint", 211 - constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 212 - ref: "refs/heads/develop", 213 - expected: false, 214 - }, 215 - { 216 - name: "glob patterns in combined constraint - branch", 217 - constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 218 - ref: "refs/heads/release-2024", 219 - expected: true, 220 - }, 221 - { 222 - name: "glob patterns in combined constraint - tag", 223 - constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 224 - ref: "refs/tags/v2.0.0", 225 - expected: true, 226 - }, 227 - } 228 - 229 - for _, tt := range tests { 230 - t.Run(tt.name, func(t *testing.T) { 231 - result, _ := tt.constraint.MatchRef(tt.ref) 232 - assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 233 - }) 234 - } 235 - } 236 - 237 - func TestConstraintMatchBranch_GlobPatterns(t *testing.T) { 238 - tests := []struct { 239 - name string 240 - constraint Constraint 241 - branch string 242 - expected bool 243 - }{ 244 - { 245 - name: "exact match", 246 - constraint: Constraint{Branch: []string{"main"}}, 247 - branch: "main", 248 - expected: true, 249 - }, 250 - { 251 - name: "glob match", 252 - constraint: Constraint{Branch: []string{"feature-*"}}, 253 - branch: "feature-123", 254 - expected: true, 255 - }, 256 - { 257 - name: "no match", 258 - constraint: Constraint{Branch: []string{"main"}}, 259 - branch: "develop", 260 - expected: false, 261 - }, 262 - { 263 - name: "multiple patterns with match", 264 - constraint: Constraint{Branch: []string{"main", "release-*"}}, 265 - branch: "release-1.0", 266 - expected: true, 267 - }, 268 - } 269 - 270 - for _, tt := range tests { 271 - t.Run(tt.name, func(t *testing.T) { 272 - result, _ := tt.constraint.MatchBranch(tt.branch) 273 - assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch) 274 - }) 275 - } 276 - } 277 - 278 - func TestConstraintMatchTag_GlobPatterns(t *testing.T) { 279 - tests := []struct { 280 - name string 281 - constraint Constraint 282 - tag string 283 - expected bool 284 - }{ 285 - { 286 - name: "exact match", 287 - constraint: Constraint{Tag: []string{"v1.0.0"}}, 288 - tag: "v1.0.0", 289 - expected: true, 290 - }, 291 - { 292 - name: "glob match", 293 - constraint: Constraint{Tag: []string{"v*"}}, 294 - tag: "v2.3.4", 295 - expected: true, 296 - }, 297 - { 298 - name: "no match", 299 - constraint: Constraint{Tag: []string{"v*"}}, 300 - tag: "release-1.0", 301 - expected: false, 302 - }, 303 - { 304 - name: "multiple patterns with match", 305 - constraint: Constraint{Tag: []string{"v*", "release-*"}}, 306 - tag: "release-2024", 307 - expected: true, 308 - }, 309 - { 310 - name: "empty tag list matches nothing", 311 - constraint: Constraint{Tag: []string{}}, 312 - tag: "v1.0.0", 313 - expected: false, 314 - }, 315 - } 316 - 317 - for _, tt := range tests { 318 - t.Run(tt.name, func(t *testing.T) { 319 - result, _ := tt.constraint.MatchTag(tt.tag) 320 - assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag) 321 - }) 322 - } 323 - }
+4 -5
xrpc/serviceauth/service_auth.go
··· 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 11 "tangled.org/core/idresolver" 12 - "tangled.org/core/log" 13 12 xrpcerr "tangled.org/core/xrpc/errors" 14 13 ) 15 14 ··· 23 22 24 23 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 25 24 return &ServiceAuth{ 26 - logger: log.SubLogger(logger, "serviceauth"), 25 + logger: logger, 27 26 resolver: resolver, 28 27 audienceDid: audienceDid, 29 28 } ··· 31 30 32 31 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 33 32 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + l := sa.logger.With("url", r.URL) 34 + 34 35 token := r.Header.Get("Authorization") 35 36 token = strings.TrimPrefix(token, "Bearer ") 36 37 ··· 41 42 42 43 did, err := s.Validate(r.Context(), token, nil) 43 44 if err != nil { 44 - sa.logger.Error("signature verification failed", "err", err) 45 + l.Error("signature verification failed", "err", err) 45 46 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 46 47 return 47 48 } 48 - 49 - sa.logger.Debug("valid signature", ActorDid, did) 50 49 51 50 r = r.WithContext( 52 51 context.WithValue(r.Context(), ActorDid, did),