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

Compare changes

Choose any two refs to compare.

Changed files
+14112 -5343
.tangled
api
appview
config
db
dns
indexer
issues
knots
labels
middleware
models
notifications
notify
oauth
ogcard
pages
legal
markup
repoinfo
templates
pagination
pipelines
posthog
pulls
repo
reporesolver
settings
signup
spindles
state
strings
validator
xrpcclient
cmd
appview
cborgen
genjwks
knot
punchcardPopulate
spindle
docs
guard
idresolver
jetstream
knotserver
legal
lexicons
log
nix
patchutil
scripts
spindle
types
workflow
xrpc
serviceauth
+1
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 + **/*.bleve 18 19 # Created if following hacking.md 19 20 genjwks.out 20 21 /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
+3 -1
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 - Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 30 + // pronouns: Preferred gender pronouns. 31 + Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 32 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 33 }
+196 -2
api/tangled/cbor_gen.go
··· 26 26 } 27 27 28 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 7 29 + fieldCount := 8 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 { 44 48 fieldCount-- 45 49 } 46 50 ··· 186 190 return err 187 191 } 188 192 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 { 189 225 return err 190 226 } 191 227 } ··· 430 466 } 431 467 432 468 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) 433 490 } 434 491 } 435 492 // t.Description (string) (string) ··· 5806 5863 } 5807 5864 5808 5865 cw := cbg.NewCborWriter(w) 5809 - fieldCount := 8 5866 + fieldCount := 10 5810 5867 5811 5868 if t.Description == nil { 5812 5869 fieldCount-- ··· 5821 5878 } 5822 5879 5823 5880 if t.Spindle == nil { 5881 + fieldCount-- 5882 + } 5883 + 5884 + if t.Topics == nil { 5885 + fieldCount-- 5886 + } 5887 + 5888 + if t.Website == nil { 5824 5889 fieldCount-- 5825 5890 } 5826 5891 ··· 5961 6026 } 5962 6027 } 5963 6028 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 + 5964 6065 // t.Spindle (string) (string) 5965 6066 if t.Spindle != nil { 5966 6067 ··· 5993 6094 } 5994 6095 } 5995 6096 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 + 5996 6129 // t.CreatedAt (string) (string) 5997 6130 if len("createdAt") > 1000000 { 5998 6131 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6185 6318 t.Source = (*string)(&sval) 6186 6319 } 6187 6320 } 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 + } 6188 6361 // t.Spindle (string) (string) 6189 6362 case "spindle": 6190 6363 ··· 6204 6377 } 6205 6378 6206 6379 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) 6207 6401 } 6208 6402 } 6209 6403 // t.CreatedAt (string) (string)
+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 + }
+10
api/tangled/repotree.go
··· 31 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 32 // parent: The parent path in the tree 33 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 34 36 // ref: The git reference used 35 37 Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 36 46 } 37 47 38 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+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"` 33 37 }
+19 -4
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.sh"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"` 17 + AppviewName string `env:"APPVIEW_Name, default=Tangled"` 17 18 Dev bool `env:"DEV, default=false"` 18 19 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 20 ··· 25 26 } 26 27 27 28 type OAuthConfig struct { 28 - Jwks string `env:"JWKS"` 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"` 29 35 } 30 36 31 37 type JetstreamConfig struct { ··· 72 78 } 73 79 74 80 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 81 + ApiToken string `env:"API_TOKEN"` 82 + ZoneId string `env:"ZONE_ID"` 83 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 84 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 85 + } 86 + 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"` 77 90 } 78 91 79 92 func (cfg RedisConfig) ToURL() string { ··· 101 114 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 102 115 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 103 116 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 104 118 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 105 119 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 106 121 } 107 122 108 123 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 - 71 70 if err != nil { 72 71 return nil, err 73 72 }
+53
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + "time" 6 7 7 8 "tangled.org/core/appview/models" 8 9 ) ··· 59 60 60 61 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 61 62 } 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 + }
+236 -34
appview/db/db.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "log" 7 + "log/slog" 8 8 "reflect" 9 9 "strings" 10 10 11 11 _ "github.com/mattn/go-sqlite3" 12 + "tangled.org/core/log" 12 13 ) 13 14 14 15 type DB struct { 15 16 *sql.DB 17 + logger *slog.Logger 16 18 } 17 19 18 20 type Execer interface { ··· 26 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 29 } 28 30 29 - func Make(dbPath string) (*DB, error) { 31 + func Make(ctx context.Context, dbPath string) (*DB, error) { 30 32 // https://github.com/mattn/go-sqlite3#connection-string 31 33 opts := []string{ 32 34 "_foreign_keys=1", ··· 35 37 "_auto_vacuum=incremental", 36 38 } 37 39 40 + logger := log.FromContext(ctx) 41 + logger = log.SubLogger(logger, "db") 42 + 38 43 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 44 if err != nil { 40 45 return nil, err 41 46 } 42 - 43 - ctx := context.Background() 44 47 45 48 conn, err := db.Conn(ctx) 46 49 if err != nil { ··· 530 533 unique (repo_at, label_at) 531 534 ); 532 535 536 + create table if not exists notifications ( 537 + id integer primary key autoincrement, 538 + recipient_did text not null, 539 + actor_did text not null, 540 + type text not null, 541 + entity_type text not null, 542 + entity_id text not null, 543 + read integer not null default 0, 544 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 545 + repo_id integer references repos(id), 546 + issue_id integer references issues(id), 547 + pull_id integer references pulls(id) 548 + ); 549 + 550 + create table if not exists notification_preferences ( 551 + id integer primary key autoincrement, 552 + user_did text not null unique, 553 + repo_starred integer not null default 1, 554 + issue_created integer not null default 1, 555 + issue_commented integer not null default 1, 556 + pull_created integer not null default 1, 557 + pull_commented integer not null default 1, 558 + followed integer not null default 1, 559 + pull_merged integer not null default 1, 560 + issue_closed integer not null default 1, 561 + email_notifications integer not null default 0 562 + ); 563 + 533 564 create table if not exists migrations ( 534 565 id integer primary key autoincrement, 535 566 name text unique 536 567 ); 537 568 538 - -- indexes for better star query performance 569 + -- indexes for better performance 570 + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 539 572 create index if not exists idx_stars_created on stars(created); 540 573 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 541 574 `) ··· 544 577 } 545 578 546 579 // run migrations 547 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 580 + runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 548 581 tx.Exec(` 549 582 alter table repos add column description text check (length(description) <= 200); 550 583 `) 551 584 return nil 552 585 }) 553 586 554 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 587 + runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 555 588 // add unconstrained column 556 589 _, err := tx.Exec(` 557 590 alter table public_keys ··· 574 607 return nil 575 608 }) 576 609 577 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 610 + runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 578 611 _, err := tx.Exec(` 579 612 alter table comments drop column comment_at; 580 613 alter table comments add column rkey text; ··· 582 615 return err 583 616 }) 584 617 585 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 618 + runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 586 619 _, err := tx.Exec(` 587 620 alter table comments add column deleted text; -- timestamp 588 621 alter table comments add column edited text; -- timestamp ··· 590 623 return err 591 624 }) 592 625 593 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 626 + runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 594 627 _, err := tx.Exec(` 595 628 alter table pulls add column source_branch text; 596 629 alter table pulls add column source_repo_at text; ··· 599 632 return err 600 633 }) 601 634 602 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 635 + runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 603 636 _, err := tx.Exec(` 604 637 alter table repos add column source text; 605 638 `) ··· 611 644 // 612 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 613 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 614 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 647 + runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 615 648 _, err := tx.Exec(` 616 649 create table pulls_new ( 617 650 -- identifiers ··· 668 701 }) 669 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 670 703 671 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 704 + runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 672 705 tx.Exec(` 673 706 alter table repos add column spindle text; 674 707 `) ··· 678 711 // drop all knot secrets, add unique constraint to knots 679 712 // 680 713 // knots will henceforth use service auth for signed requests 681 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 714 + runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 682 715 _, err := tx.Exec(` 683 716 create table registrations_new ( 684 717 id integer primary key autoincrement, ··· 701 734 }) 702 735 703 736 // recreate and add rkey + created columns with default constraint 704 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 737 + runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 705 738 // create new table 706 739 // - repo_at instead of repo integer 707 740 // - rkey field ··· 755 788 return err 756 789 }) 757 790 758 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 791 + runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 759 792 _, err := tx.Exec(` 760 793 alter table issues add column rkey text not null default ''; 761 794 ··· 767 800 }) 768 801 769 802 // repurpose the read-only column to "needs-upgrade" 770 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 803 + runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 771 804 _, err := tx.Exec(` 772 805 alter table registrations rename column read_only to needs_upgrade; 773 806 `) ··· 775 808 }) 776 809 777 810 // require all knots to upgrade after the release of total xrpc 778 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 811 + runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 779 812 _, err := tx.Exec(` 780 813 update registrations set needs_upgrade = 1; 781 814 `) ··· 783 816 }) 784 817 785 818 // require all knots to upgrade after the release of total xrpc 786 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 819 + runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 787 820 _, err := tx.Exec(` 788 821 alter table spindles add column needs_upgrade integer not null default 0; 789 822 `) 790 - if err != nil { 791 - return err 792 - } 793 - 794 - _, err = tx.Exec(` 795 - update spindles set needs_upgrade = 1; 796 - `) 797 823 return err 798 824 }) 799 825 ··· 808 834 // 809 835 // disable foreign-keys for the next migration 810 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 811 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 837 + runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 812 838 _, err := tx.Exec(` 813 839 create table if not exists issues_new ( 814 840 -- identifiers ··· 878 904 // - new columns 879 905 // * column "reply_to" which can be any other comment 880 906 // * column "at-uri" which is a generated column 881 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 907 + runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 882 908 _, err := tx.Exec(` 883 909 create table if not exists issue_comments ( 884 910 -- identifiers ··· 931 957 return err 932 958 }) 933 959 934 - return &DB{db}, nil 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 935 1135 } 936 1136 937 1137 type migrationFn = func(*sql.Tx) error 938 1138 939 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1139 + func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1140 + logger = logger.With("migration", name) 1141 + 940 1142 tx, err := c.BeginTx(context.Background(), nil) 941 1143 if err != nil { 942 1144 return err ··· 953 1155 // run migration 954 1156 err = migrationFn(tx) 955 1157 if err != nil { 956 - log.Printf("Failed to run migration %s: %v", name, err) 1158 + logger.Error("failed to run migration", "err", err) 957 1159 return err 958 1160 } 959 1161 960 1162 // mark migration as complete 961 1163 _, err = tx.Exec("insert into migrations (name) values (?)", name) 962 1164 if err != nil { 963 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 1165 + logger.Error("failed to mark migration as complete", "err", err) 964 1166 return err 965 1167 } 966 1168 ··· 969 1171 return err 970 1172 } 971 1173 972 - log.Printf("migration %s applied successfully", name) 1174 + logger.Info("migration applied successfully") 973 1175 } else { 974 - log.Printf("skipped migration %s, already applied", name) 1176 + logger.Warn("skipped migration, already applied") 975 1177 } 976 1178 977 1179 return nil
+13 -9
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 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) 83 85 84 86 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 87 89 88 90 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 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) 92 98 } 93 99 94 100 query := ` ··· 104 110 return nil, err 105 111 } 106 112 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 113 110 114 for rows.Next() { 111 115 var email, did string
+72 -16
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 - args = append(args, pLower.Arg()...) 105 - args = append(args, pUpper.Arg()...) 106 - pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 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 + } 107 110 108 111 query := fmt.Sprintf( 109 112 ` ··· 128 131 %s 129 132 `, 130 133 whereClause, 131 - pagination, 134 + pageClause, 132 135 ) 133 136 134 137 rows, err := e.Query(query, args...) ··· 243 246 return issues, nil 244 247 } 245 248 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 + 246 266 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 - return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 267 + return GetIssuesPaginated(e, pagination.Page{}, filters...) 248 268 } 249 269 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) 270 + // GetIssueIDs gets list of all existing issue's IDs 271 + func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 272 + var ids []int64 273 + 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)) 282 + } 283 + 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 + } 253 291 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) 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...) 257 308 if err != nil { 258 309 return nil, err 259 310 } 311 + defer rows.Close() 260 312 261 - createdTime, err := time.Parse(time.RFC3339, createdAt) 262 - if err != nil { 263 - return nil, err 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) 264 321 } 265 - issue.Created = createdTime 266 322 267 - return &issue, nil 323 + return ids, nil 268 324 } 269 325 270 326 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" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 86 } 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 + }
+499
appview/db/notifications.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pagination" 14 + ) 15 + 16 + func CreateNotification(e Execer, notification *models.Notification) error { 17 + query := ` 18 + INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 19 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 20 + ` 21 + 22 + result, err := e.Exec(query, 23 + notification.RecipientDid, 24 + notification.ActorDid, 25 + string(notification.Type), 26 + notification.EntityType, 27 + notification.EntityId, 28 + notification.Read, 29 + notification.RepoId, 30 + notification.IssueId, 31 + notification.PullId, 32 + ) 33 + if err != nil { 34 + return fmt.Errorf("failed to create notification: %w", err) 35 + } 36 + 37 + id, err := result.LastInsertId() 38 + if err != nil { 39 + return fmt.Errorf("failed to get notification ID: %w", err) 40 + } 41 + 42 + notification.ID = id 43 + return nil 44 + } 45 + 46 + // GetNotificationsPaginated retrieves notifications with filters and pagination 47 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 48 + var conditions []string 49 + var args []any 50 + 51 + for _, filter := range filters { 52 + conditions = append(conditions, filter.Condition()) 53 + args = append(args, filter.Arg()...) 54 + } 55 + 56 + whereClause := "" 57 + if len(conditions) > 0 { 58 + whereClause = "WHERE " + conditions[0] 59 + for _, condition := range conditions[1:] { 60 + whereClause += " AND " + condition 61 + } 62 + } 63 + pageClause := "" 64 + if page.Limit > 0 { 65 + pageClause = " limit ? offset ? " 66 + args = append(args, page.Limit, page.Offset) 67 + } 68 + 69 + query := fmt.Sprintf(` 70 + select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 71 + from notifications 72 + %s 73 + order by created desc 74 + %s 75 + `, whereClause, pageClause) 76 + 77 + rows, err := e.QueryContext(context.Background(), query, args...) 78 + if err != nil { 79 + return nil, fmt.Errorf("failed to query notifications: %w", err) 80 + } 81 + defer rows.Close() 82 + 83 + var notifications []*models.Notification 84 + for rows.Next() { 85 + var n models.Notification 86 + var typeStr string 87 + var createdStr string 88 + err := rows.Scan( 89 + &n.ID, 90 + &n.RecipientDid, 91 + &n.ActorDid, 92 + &typeStr, 93 + &n.EntityType, 94 + &n.EntityId, 95 + &n.Read, 96 + &createdStr, 97 + &n.RepoId, 98 + &n.IssueId, 99 + &n.PullId, 100 + ) 101 + if err != nil { 102 + return nil, fmt.Errorf("failed to scan notification: %w", err) 103 + } 104 + n.Type = models.NotificationType(typeStr) 105 + n.Created, err = time.Parse(time.RFC3339, createdStr) 106 + if err != nil { 107 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 108 + } 109 + notifications = append(notifications, &n) 110 + } 111 + 112 + return notifications, nil 113 + } 114 + 115 + // GetNotificationsWithEntities retrieves notifications with their related entities 116 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 117 + var conditions []string 118 + var args []any 119 + 120 + for _, filter := range filters { 121 + conditions = append(conditions, filter.Condition()) 122 + args = append(args, filter.Arg()...) 123 + } 124 + 125 + whereClause := "" 126 + if len(conditions) > 0 { 127 + whereClause = "WHERE " + conditions[0] 128 + for _, condition := range conditions[1:] { 129 + whereClause += " AND " + condition 130 + } 131 + } 132 + 133 + query := fmt.Sprintf(` 134 + select 135 + n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 136 + 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, 138 + 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 + 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 + from notifications n 141 + left join repos r on n.repo_id = r.id 142 + left join issues i on n.issue_id = i.id 143 + left join pulls p on n.pull_id = p.id 144 + %s 145 + order by n.created desc 146 + limit ? offset ? 147 + `, whereClause) 148 + 149 + args = append(args, page.Limit, page.Offset) 150 + 151 + rows, err := e.QueryContext(context.Background(), query, args...) 152 + if err != nil { 153 + return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 154 + } 155 + defer rows.Close() 156 + 157 + var notifications []*models.NotificationWithEntity 158 + for rows.Next() { 159 + var n models.Notification 160 + var typeStr string 161 + var createdStr string 162 + var repo models.Repo 163 + var issue models.Issue 164 + var pull models.Pull 165 + var rId, iId, pId sql.NullInt64 166 + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 167 + var iDid sql.NullString 168 + var iIssueId sql.NullInt64 169 + var iTitle sql.NullString 170 + var iOpen sql.NullBool 171 + var pOwnerDid sql.NullString 172 + var pPullId sql.NullInt64 173 + var pTitle sql.NullString 174 + var pState sql.NullInt64 175 + 176 + err := rows.Scan( 177 + &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 178 + &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 179 + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 180 + &iId, &iDid, &iIssueId, &iTitle, &iOpen, 181 + &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 182 + ) 183 + if err != nil { 184 + return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 185 + } 186 + 187 + n.Type = models.NotificationType(typeStr) 188 + n.Created, err = time.Parse(time.RFC3339, createdStr) 189 + if err != nil { 190 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 191 + } 192 + 193 + nwe := &models.NotificationWithEntity{Notification: &n} 194 + 195 + // populate repo if present 196 + if rId.Valid { 197 + repo.Id = rId.Int64 198 + if rDid.Valid { 199 + repo.Did = rDid.String 200 + } 201 + if rName.Valid { 202 + repo.Name = rName.String 203 + } 204 + if rDescription.Valid { 205 + 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 + } 213 + nwe.Repo = &repo 214 + } 215 + 216 + // populate issue if present 217 + if iId.Valid { 218 + issue.Id = iId.Int64 219 + if iDid.Valid { 220 + issue.Did = iDid.String 221 + } 222 + if iIssueId.Valid { 223 + issue.IssueId = int(iIssueId.Int64) 224 + } 225 + if iTitle.Valid { 226 + issue.Title = iTitle.String 227 + } 228 + if iOpen.Valid { 229 + issue.Open = iOpen.Bool 230 + } 231 + nwe.Issue = &issue 232 + } 233 + 234 + // populate pull if present 235 + if pId.Valid { 236 + pull.ID = int(pId.Int64) 237 + if pOwnerDid.Valid { 238 + pull.OwnerDid = pOwnerDid.String 239 + } 240 + if pPullId.Valid { 241 + pull.PullId = int(pPullId.Int64) 242 + } 243 + if pTitle.Valid { 244 + pull.Title = pTitle.String 245 + } 246 + if pState.Valid { 247 + pull.State = models.PullState(pState.Int64) 248 + } 249 + nwe.Pull = &pull 250 + } 251 + 252 + notifications = append(notifications, nwe) 253 + } 254 + 255 + return notifications, nil 256 + } 257 + 258 + // GetNotifications retrieves notifications with filters 259 + func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 260 + return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 261 + } 262 + 263 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 264 + var conditions []string 265 + var args []any 266 + for _, filter := range filters { 267 + conditions = append(conditions, filter.Condition()) 268 + args = append(args, filter.Arg()...) 269 + } 270 + 271 + whereClause := "" 272 + if conditions != nil { 273 + whereClause = " where " + strings.Join(conditions, " and ") 274 + } 275 + 276 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 277 + var count int64 278 + err := e.QueryRow(query, args...).Scan(&count) 279 + 280 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 281 + return 0, err 282 + } 283 + 284 + return count, nil 285 + } 286 + 287 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 288 + idFilter := FilterEq("id", notificationID) 289 + recipientFilter := FilterEq("recipient_did", userDID) 290 + 291 + query := fmt.Sprintf(` 292 + UPDATE notifications 293 + SET read = 1 294 + WHERE %s AND %s 295 + `, idFilter.Condition(), recipientFilter.Condition()) 296 + 297 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 298 + 299 + result, err := e.Exec(query, args...) 300 + if err != nil { 301 + return fmt.Errorf("failed to mark notification as read: %w", err) 302 + } 303 + 304 + rowsAffected, err := result.RowsAffected() 305 + if err != nil { 306 + return fmt.Errorf("failed to get rows affected: %w", err) 307 + } 308 + 309 + if rowsAffected == 0 { 310 + return fmt.Errorf("notification not found or access denied") 311 + } 312 + 313 + return nil 314 + } 315 + 316 + func MarkAllNotificationsRead(e Execer, userDID string) error { 317 + recipientFilter := FilterEq("recipient_did", userDID) 318 + readFilter := FilterEq("read", 0) 319 + 320 + query := fmt.Sprintf(` 321 + UPDATE notifications 322 + SET read = 1 323 + WHERE %s AND %s 324 + `, recipientFilter.Condition(), readFilter.Condition()) 325 + 326 + args := append(recipientFilter.Arg(), readFilter.Arg()...) 327 + 328 + _, err := e.Exec(query, args...) 329 + if err != nil { 330 + return fmt.Errorf("failed to mark all notifications as read: %w", err) 331 + } 332 + 333 + return nil 334 + } 335 + 336 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 337 + idFilter := FilterEq("id", notificationID) 338 + recipientFilter := FilterEq("recipient_did", userDID) 339 + 340 + query := fmt.Sprintf(` 341 + DELETE FROM notifications 342 + WHERE %s AND %s 343 + `, idFilter.Condition(), recipientFilter.Condition()) 344 + 345 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 346 + 347 + result, err := e.Exec(query, args...) 348 + if err != nil { 349 + return fmt.Errorf("failed to delete notification: %w", err) 350 + } 351 + 352 + rowsAffected, err := result.RowsAffected() 353 + if err != nil { 354 + return fmt.Errorf("failed to get rows affected: %w", err) 355 + } 356 + 357 + if rowsAffected == 0 { 358 + return fmt.Errorf("notification not found or access denied") 359 + } 360 + 361 + return nil 362 + } 363 + 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 + } 369 + 370 + p, ok := prefs[syntax.DID(userDid)] 371 + if !ok { 372 + return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 373 + } 374 + 375 + return p, nil 376 + } 377 + 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 + 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 435 + } 436 + 437 + prefsMap[prefs.UserDid] = &prefs 438 + } 439 + 440 + if err := rows.Err(); err != nil { 441 + return nil, err 442 + } 443 + 444 + return prefsMap, nil 445 + } 446 + 447 + func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 448 + query := ` 449 + INSERT OR REPLACE INTO notification_preferences 450 + (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 454 + ` 455 + 456 + result, err := d.DB.ExecContext(ctx, query, 457 + prefs.UserDid, 458 + prefs.RepoStarred, 459 + prefs.IssueCreated, 460 + prefs.IssueCommented, 461 + prefs.PullCreated, 462 + prefs.PullCommented, 463 + prefs.Followed, 464 + prefs.UserMentioned, 465 + prefs.PullMerged, 466 + prefs.IssueClosed, 467 + prefs.EmailNotifications, 468 + ) 469 + if err != nil { 470 + return fmt.Errorf("failed to update notification preferences: %w", err) 471 + } 472 + 473 + if prefs.ID == 0 { 474 + id, err := result.LastInsertId() 475 + if err != nil { 476 + return fmt.Errorf("failed to get preferences ID: %w", err) 477 + } 478 + prefs.ID = id 479 + } 480 + 481 + return nil 482 + } 483 + 484 + func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 485 + cutoff := time.Now().Add(-olderThan) 486 + createdFilter := FilterLte("created", cutoff) 487 + 488 + query := fmt.Sprintf(` 489 + DELETE FROM notifications 490 + WHERE %s 491 + `, createdFilter.Condition()) 492 + 493 + _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 494 + if err != nil { 495 + return fmt.Errorf("failed to cleanup old notifications: %w", err) 496 + } 497 + 498 + return nil 499 + }
+26 -6
appview/db/profile.go
··· 129 129 did, 130 130 description, 131 131 include_bluesky, 132 - location 132 + location, 133 + pronouns 133 134 ) 134 - values (?, ?, ?, ?)`, 135 + values (?, ?, ?, ?, ?)`, 135 136 profile.Did, 136 137 profile.Description, 137 138 includeBskyValue, 138 139 profile.Location, 140 + profile.Pronouns, 139 141 ) 140 142 141 143 if err != nil { ··· 216 218 did, 217 219 description, 218 220 include_bluesky, 219 - location 221 + location, 222 + pronouns 220 223 from 221 224 profile 222 225 %s`, ··· 231 234 for rows.Next() { 232 235 var profile models.Profile 233 236 var includeBluesky int 237 + var pronouns sql.Null[string] 234 238 235 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 239 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 236 240 if err != nil { 237 241 return nil, err 238 242 } 239 243 240 244 if includeBluesky != 0 { 241 245 profile.IncludeBluesky = true 246 + } 247 + 248 + if pronouns.Valid { 249 + profile.Pronouns = pronouns.V 242 250 } 243 251 244 252 profileMap[profile.Did] = &profile ··· 302 310 303 311 func GetProfile(e Execer, did string) (*models.Profile, error) { 304 312 var profile models.Profile 313 + var pronouns sql.Null[string] 314 + 305 315 profile.Did = did 306 316 307 317 includeBluesky := 0 318 + 308 319 err := e.QueryRow( 309 - `select description, include_bluesky, location from profile where did = ?`, 320 + `select description, include_bluesky, location, pronouns from profile where did = ?`, 310 321 did, 311 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 322 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 312 323 if err == sql.ErrNoRows { 313 324 profile := models.Profile{} 314 325 profile.Did = did ··· 321 332 322 333 if includeBluesky != 0 { 323 334 profile.IncludeBluesky = true 335 + } 336 + 337 + if pronouns.Valid { 338 + profile.Pronouns = pronouns.V 324 339 } 325 340 326 341 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 412 427 // ensure description is not too long 413 428 if len(profile.Location) > 40 { 414 429 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.") 415 435 } 416 436 417 437 // ensure links are in order
+211 -220
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 9 + "slices" 7 10 "sort" 8 11 "strings" 9 12 "time" ··· 55 58 parentChangeId = &pull.ParentChangeId 56 59 } 57 60 58 - _, err = tx.Exec( 61 + result, err := tx.Exec( 59 62 ` 60 63 insert into pulls ( 61 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 78 81 if err != nil { 79 82 return err 80 83 } 84 + 85 + // Set the database primary key ID 86 + id, err := result.LastInsertId() 87 + if err != nil { 88 + return err 89 + } 90 + pull.ID = int(id) 81 91 82 92 _, err = tx.Exec(` 83 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 93 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 84 94 values (?, ?, ?, ?, ?) 85 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 95 + `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 86 96 return err 87 97 } 88 98 ··· 91 101 if err != nil { 92 102 return "", err 93 103 } 94 - return pull.PullAt(), err 104 + return pull.AtUri(), err 95 105 } 96 106 97 107 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 101 111 } 102 112 103 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 104 - pulls := make(map[int]*models.Pull) 114 + pulls := make(map[syntax.ATURI]*models.Pull) 105 115 106 116 var conditions []string 107 117 var args []any ··· 121 131 122 132 query := fmt.Sprintf(` 123 133 select 134 + id, 124 135 owner_did, 125 136 repo_at, 126 137 pull_id, ··· 154 165 var createdAt string 155 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 156 167 err := rows.Scan( 168 + &pull.ID, 157 169 &pull.OwnerDid, 158 170 &pull.RepoAt, 159 171 &pull.PullId, ··· 202 214 pull.ParentChangeId = parentChangeId.String 203 215 } 204 216 205 - pulls[pull.PullId] = &pull 217 + pulls[pull.AtUri()] = &pull 206 218 } 207 219 208 - // get latest round no. for each pull 209 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 210 - submissionsQuery := fmt.Sprintf(` 211 - select 212 - id, pull_id, round_number, patch, created, source_rev 213 - from 214 - pull_submissions 215 - where 216 - repo_at in (%s) and pull_id in (%s) 217 - `, inClause, inClause) 218 - 219 - args = make([]any, len(pulls)*2) 220 - idx := 0 220 + var pullAts []syntax.ATURI 221 221 for _, p := range pulls { 222 - args[idx] = p.RepoAt 223 - idx += 1 222 + pullAts = append(pullAts, p.AtUri()) 224 223 } 225 - for _, p := range pulls { 226 - args[idx] = p.PullId 227 - idx += 1 228 - } 229 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 230 225 if err != nil { 231 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 232 227 } 233 - defer submissionsRows.Close() 234 228 235 - for submissionsRows.Next() { 236 - var s models.PullSubmission 237 - var sourceRev sql.NullString 238 - var createdAt string 239 - err := submissionsRows.Scan( 240 - &s.ID, 241 - &s.PullId, 242 - &s.RoundNumber, 243 - &s.Patch, 244 - &createdAt, 245 - &sourceRev, 246 - ) 247 - if err != nil { 248 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 249 232 } 233 + } 250 234 251 - createdTime, err := time.Parse(time.RFC3339, createdAt) 252 - if err != nil { 253 - return nil, err 254 - } 255 - s.Created = createdTime 256 - 257 - if sourceRev.Valid { 258 - s.SourceRev = sourceRev.String 259 - } 260 - 261 - if p, ok := pulls[s.PullId]; ok { 262 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 263 - p.Submissions[s.RoundNumber] = &s 264 - } 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) 265 239 } 266 - if err := rows.Err(); err != nil { 267 - return nil, err 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 243 + } 268 244 } 269 245 270 - // get comment count on latest submission on each pull 271 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 272 - commentsQuery := fmt.Sprintf(` 273 - select 274 - count(id), pull_id 275 - from 276 - pull_comments 277 - where 278 - submission_id in (%s) 279 - group by 280 - submission_id 281 - `, inClause) 282 - 283 - args = []any{} 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 284 248 for _, p := range pulls { 285 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 286 252 } 287 - commentsRows, err := e.Query(commentsQuery, args...) 288 - if err != nil { 289 - return nil, err 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) 290 256 } 291 - defer commentsRows.Close() 292 - 293 - for commentsRows.Next() { 294 - var commentCount, pullId int 295 - err := commentsRows.Scan( 296 - &commentCount, 297 - &pullId, 298 - ) 299 - if err != nil { 300 - return nil, err 301 - } 302 - if p, ok := pulls[pullId]; ok { 303 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 304 - } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 305 260 } 306 - if err := rows.Err(); err != nil { 307 - return nil, err 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 + } 308 267 } 309 268 310 269 orderedByPullId := []*models.Pull{} ··· 322 281 return GetPullsWithLimit(e, 0, filters...) 323 282 } 324 283 325 - func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 326 - query := ` 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 + ` 327 316 select 328 - owner_did, 329 - pull_id, 330 - created, 331 - title, 332 - state, 333 - target_branch, 334 - repo_at, 335 - body, 336 - rkey, 337 - source_branch, 338 - source_repo_at, 339 - stack_id, 340 - change_id, 341 - parent_change_id 317 + id 342 318 from 343 319 pulls 344 - where 345 - repo_at = ? and pull_id = ? 346 - ` 347 - row := e.QueryRow(query, repoAt, pullId) 348 - 349 - var pull models.Pull 350 - var createdAt string 351 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 352 - err := row.Scan( 353 - &pull.OwnerDid, 354 - &pull.PullId, 355 - &createdAt, 356 - &pull.Title, 357 - &pull.State, 358 - &pull.TargetBranch, 359 - &pull.RepoAt, 360 - &pull.Body, 361 - &pull.Rkey, 362 - &sourceBranch, 363 - &sourceRepoAt, 364 - &stackId, 365 - &changeId, 366 - &parentChangeId, 320 + %s 321 + %s`, 322 + whereClause, 323 + pageClause, 367 324 ) 325 + args = append(args, opts.Page.Limit, opts.Page.Offset) 326 + rows, err := e.Query(query, args...) 368 327 if err != nil { 369 328 return nil, err 370 329 } 330 + defer rows.Close() 371 331 372 - createdTime, err := time.Parse(time.RFC3339, createdAt) 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)) 373 347 if err != nil { 374 348 return nil, err 375 349 } 376 - pull.Created = createdTime 377 - 378 - // populate source 379 - if sourceBranch.Valid { 380 - pull.PullSource = &models.PullSource{ 381 - Branch: sourceBranch.String, 382 - } 383 - if sourceRepoAt.Valid { 384 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 385 - if err != nil { 386 - return nil, err 387 - } 388 - pull.PullSource.RepoAt = &sourceRepoAtParsed 389 - } 350 + if len(pulls) == 0 { 351 + return nil, sql.ErrNoRows 390 352 } 391 353 392 - if stackId.Valid { 393 - pull.StackId = stackId.String 394 - } 395 - if changeId.Valid { 396 - pull.ChangeId = changeId.String 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()...) 397 364 } 398 - if parentChangeId.Valid { 399 - pull.ParentChangeId = parentChangeId.String 365 + 366 + whereClause := "" 367 + if conditions != nil { 368 + whereClause = " where " + strings.Join(conditions, " and ") 400 369 } 401 370 402 - submissionsQuery := ` 371 + query := fmt.Sprintf(` 403 372 select 404 - id, pull_id, repo_at, round_number, patch, created, source_rev 373 + id, 374 + pull_at, 375 + round_number, 376 + patch, 377 + combined, 378 + created, 379 + source_rev 405 380 from 406 381 pull_submissions 407 - where 408 - repo_at = ? and pull_id = ? 409 - ` 410 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 382 + %s 383 + order by 384 + round_number asc 385 + `, whereClause) 386 + 387 + rows, err := e.Query(query, args...) 411 388 if err != nil { 412 389 return nil, err 413 390 } 414 - defer submissionsRows.Close() 391 + defer rows.Close() 415 392 416 - submissionsMap := make(map[int]*models.PullSubmission) 393 + submissionMap := make(map[int]*models.PullSubmission) 417 394 418 - for submissionsRows.Next() { 395 + for rows.Next() { 419 396 var submission models.PullSubmission 420 397 var submissionCreatedStr string 421 - var submissionSourceRev sql.NullString 422 - err := submissionsRows.Scan( 398 + var submissionSourceRev, submissionCombined sql.NullString 399 + err := rows.Scan( 423 400 &submission.ID, 424 - &submission.PullId, 425 - &submission.RepoAt, 401 + &submission.PullAt, 426 402 &submission.RoundNumber, 427 403 &submission.Patch, 404 + &submissionCombined, 428 405 &submissionCreatedStr, 429 406 &submissionSourceRev, 430 407 ) ··· 432 409 return nil, err 433 410 } 434 411 435 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 436 - if err != nil { 437 - return nil, err 412 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 413 + submission.Created = t 438 414 } 439 - submission.Created = submissionCreatedTime 440 415 441 416 if submissionSourceRev.Valid { 442 417 submission.SourceRev = submissionSourceRev.String 443 418 } 444 419 445 - submissionsMap[submission.ID] = &submission 420 + if submissionCombined.Valid { 421 + submission.Combined = submissionCombined.String 422 + } 423 + 424 + submissionMap[submission.ID] = &submission 425 + } 426 + 427 + if err := rows.Err(); err != nil { 428 + return nil, err 446 429 } 447 - if err = submissionsRows.Close(); err != nil { 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 { 448 435 return nil, err 449 436 } 450 - if len(submissionsMap) == 0 { 451 - return &pull, nil 437 + for _, comment := range comments { 438 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 439 + submission.Comments = append(submission.Comments, comment) 440 + } 452 441 } 453 442 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 454 461 var args []any 455 - for k := range submissionsMap { 456 - args = append(args, k) 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 ") 457 470 } 458 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 459 - commentsQuery := fmt.Sprintf(` 471 + 472 + query := fmt.Sprintf(` 460 473 select 461 474 id, 462 475 pull_id, ··· 468 481 created 469 482 from 470 483 pull_comments 471 - where 472 - submission_id IN (%s) 484 + %s 473 485 order by 474 486 created asc 475 - `, inClause) 476 - commentsRows, err := e.Query(commentsQuery, args...) 487 + `, whereClause) 488 + 489 + rows, err := e.Query(query, args...) 477 490 if err != nil { 478 491 return nil, err 479 492 } 480 - defer commentsRows.Close() 493 + defer rows.Close() 481 494 482 - for commentsRows.Next() { 495 + var comments []models.PullComment 496 + for rows.Next() { 483 497 var comment models.PullComment 484 - var commentCreatedStr string 485 - err := commentsRows.Scan( 498 + var createdAt string 499 + err := rows.Scan( 486 500 &comment.ID, 487 501 &comment.PullId, 488 502 &comment.SubmissionId, ··· 490 504 &comment.OwnerDid, 491 505 &comment.CommentAt, 492 506 &comment.Body, 493 - &commentCreatedStr, 507 + &createdAt, 494 508 ) 495 509 if err != nil { 496 510 return nil, err 497 511 } 498 512 499 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 500 - if err != nil { 501 - return nil, err 502 - } 503 - comment.Created = commentCreatedTime 504 - 505 - // Add the comment to its submission 506 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 507 - submission.Comments = append(submission.Comments, comment) 513 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 514 + comment.Created = t 508 515 } 509 516 517 + comments = append(comments, comment) 510 518 } 511 - if err = commentsRows.Err(); err != nil { 519 + 520 + if err := rows.Err(); err != nil { 512 521 return nil, err 513 522 } 514 523 515 - var pullSourceRepo *models.Repo 516 - if pull.PullSource != nil { 517 - if pull.PullSource.RepoAt != nil { 518 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 519 - if err != nil { 520 - log.Printf("failed to get repo by at uri: %v", err) 521 - } else { 522 - pull.PullSource.Repo = pullSourceRepo 523 - } 524 - } 525 - } 526 - 527 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 528 - for _, submission := range submissionsMap { 529 - pull.Submissions[submission.RoundNumber] = submission 530 - } 531 - 532 - return &pull, nil 524 + return comments, nil 533 525 } 534 526 535 527 // timeframe here is directly passed into the sql query filter, and any ··· 663 655 return err 664 656 } 665 657 666 - func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 667 - newRoundNumber := len(pull.Submissions) 658 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 668 659 _, err := e.Exec(` 669 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 660 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 670 661 values (?, ?, ?, ?, ?) 671 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 662 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 672 663 673 664 return err 674 665 }
+34 -7
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 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{} 67 81 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 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 71 91 } 72 - countMap[kind] = count 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 73 99 } 74 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 75 102 } 76 103 77 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+80 -12
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 13 15 "tangled.org/core/appview/models" 14 16 ) 15 17 18 + type Repo struct { 19 + Id int64 20 + Did string 21 + Name string 22 + Knot string 23 + Rkey string 24 + Created time.Time 25 + Description string 26 + Spindle string 27 + 28 + // optionally, populate this when querying for reverse mappings 29 + RepoStats *models.RepoStats 30 + 31 + // optional 32 + Source string 33 + } 34 + 35 + func (r Repo) RepoAt() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 + } 38 + 39 + func (r Repo) DidSlashRepo() string { 40 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 + return p 42 + } 43 + 16 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 45 repoMap := make(map[syntax.ATURI]*models.Repo) 18 46 ··· 35 63 36 64 repoQuery := fmt.Sprintf( 37 65 `select 66 + id, 38 67 did, 39 68 name, 40 69 knot, 41 70 rkey, 42 71 created, 43 72 description, 73 + website, 74 + topics, 44 75 source, 45 76 spindle 46 77 from ··· 60 91 for rows.Next() { 61 92 var repo models.Repo 62 93 var createdAt string 63 - var description, source, spindle sql.NullString 94 + var description, website, topicStr, source, spindle sql.NullString 64 95 65 96 err := rows.Scan( 97 + &repo.Id, 66 98 &repo.Did, 67 99 &repo.Name, 68 100 &repo.Knot, 69 101 &repo.Rkey, 70 102 &createdAt, 71 103 &description, 104 + &website, 105 + &topicStr, 72 106 &source, 73 107 &spindle, 74 108 ) ··· 81 115 } 82 116 if description.Valid { 83 117 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) 84 124 } 85 125 if source.Valid { 86 126 repo.Source = source.String ··· 326 366 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 327 367 var repo models.Repo 328 368 var nullableDescription sql.NullString 369 + var nullableWebsite sql.NullString 370 + var nullableTopicStr sql.NullString 329 371 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 372 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 331 373 332 374 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 375 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 334 376 return nil, err 335 377 } 336 378 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 338 380 339 381 if nullableDescription.Valid { 340 382 repo.Description = nullableDescription.String 341 - } else { 342 - repo.Description = "" 383 + } 384 + if nullableWebsite.Valid { 385 + repo.Website = nullableWebsite.String 386 + } 387 + if nullableTopicStr.Valid { 388 + repo.Topics = strings.Fields(nullableTopicStr.String) 343 389 } 344 390 345 391 return &repo, nil 346 392 } 347 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 + } 404 + 348 405 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 349 406 _, err := tx.Exec( 350 407 `insert into repos 351 - (did, name, knot, rkey, at_uri, description, source) 352 - values (?, ?, ?, ?, ?, ?, ?)`, 353 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 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, 354 411 ) 355 412 if err != nil { 356 413 return fmt.Errorf("failed to insert repo: %w", err) ··· 386 443 var repos []models.Repo 387 444 388 445 rows, err := e.Query( 389 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 446 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 390 447 from repos r 391 448 left join collaborators c on r.at_uri = c.repo_at 392 449 where (r.did = ? or c.subject_did = ?) ··· 404 461 var repo models.Repo 405 462 var createdAt string 406 463 var nullableDescription sql.NullString 464 + var nullableWebsite sql.NullString 407 465 var nullableSource sql.NullString 408 466 409 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 467 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 410 468 if err != nil { 411 469 return nil, err 412 470 } ··· 440 498 var repo models.Repo 441 499 var createdAt string 442 500 var nullableDescription sql.NullString 501 + var nullableWebsite sql.NullString 502 + var nullableTopicStr sql.NullString 443 503 var nullableSource sql.NullString 444 504 445 505 row := e.QueryRow( 446 - `select did, name, knot, rkey, description, created, source 506 + `select id, did, name, knot, rkey, description, website, topics, created, source 447 507 from repos 448 508 where did = ? and name = ? and source is not null and source != ''`, 449 509 did, name, 450 510 ) 451 511 452 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 512 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 453 513 if err != nil { 454 514 return nil, err 455 515 } 456 516 457 517 if nullableDescription.Valid { 458 518 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) 459 527 } 460 528 461 529 if nullableSource.Valid {
+38 -10
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) ([]models.TimelineEvent, error) { 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 13 var events []models.TimelineEvent 14 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 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) 16 29 if err != nil { 17 30 return nil, err 18 31 } 19 32 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 21 34 if err != nil { 22 35 return nil, err 23 36 } 24 37 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 26 39 if err != nil { 27 40 return nil, err 28 41 } ··· 70 83 return isStarred, starCount 71 84 } 72 85 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 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...) 75 93 if err != nil { 76 94 return nil, err 77 95 } ··· 125 143 return events, nil 126 144 } 127 145 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 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...) 130 153 if err != nil { 131 154 return nil, err 132 155 } ··· 166 189 return events, nil 167 190 } 168 191 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 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...) 171 199 if err != nil { 172 200 return nil, err 173 201 }
+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) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 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{ 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 nil 44 + return result.ID, 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 + }
+8 -2
appview/ingester.go
··· 89 89 } 90 90 91 91 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 92 + l.Warn("refused to ingest 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 + 294 299 location := "" 295 300 if record.Location != nil { 296 301 location = *record.Location ··· 325 330 Links: links, 326 331 Stats: stats, 327 332 PinnedRepos: pinned, 333 + Pronouns: pronouns, 328 334 } 329 335 330 336 ddb, ok := i.Db.Execer.(*db.DB) ··· 1008 1014 if !ok { 1009 1015 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 1016 } 1011 - if err := i.Validator.ValidateLabelOp(def, &o); err != nil { 1017 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 1018 return fmt.Errorf("failed to validate labelop: %w", err) 1013 1019 } 1014 1020 }
+119 -52
appview/issues/issues.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "slices" 12 11 "time" 13 12 14 13 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" 22 23 "tangled.org/core/appview/models" 23 24 "tangled.org/core/appview/notify" 24 25 "tangled.org/core/appview/oauth" 25 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/markup" 26 28 "tangled.org/core/appview/pagination" 27 29 "tangled.org/core/appview/reporesolver" 28 30 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 31 "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 45 46 } 46 47 47 48 func New( ··· 53 54 config *config.Config, 54 55 notifier notify.Notifier, 55 56 validator *validator.Validator, 57 + indexer *issues_indexer.Indexer, 58 + logger *slog.Logger, 56 59 ) *Issues { 57 60 return &Issues{ 58 61 oauth: oauth, ··· 62 65 db: db, 63 66 config: config, 64 67 notifier: notifier, 65 - logger: tlog.New("issues"), 68 + logger: logger, 66 69 validator: validator, 70 + indexer: indexer, 67 71 } 68 72 } 69 73 ··· 72 76 user := rp.oauth.GetUser(r) 73 77 f, err := rp.repoResolver.Resolve(r) 74 78 if err != nil { 75 - log.Println("failed to get repo and knot", err) 79 + l.Error("failed to get repo and knot", "err", err) 76 80 return 77 81 } 78 82 ··· 83 87 return 84 88 } 85 89 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 90 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 91 if err != nil { 88 92 l.Error("failed to get issue reactions", "err", err) 89 93 } ··· 99 103 db.FilterContains("scope", tangled.RepoIssueNSID), 100 104 ) 101 105 if err != nil { 102 - log.Println("failed to fetch labels", err) 106 + l.Error("failed to fetch labels", "err", err) 103 107 rp.pages.Error503(w) 104 108 return 105 109 } ··· 115 119 Issue: issue, 116 120 CommentList: issue.CommentList(), 117 121 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 122 + Reactions: reactionMap, 119 123 UserReacted: userReactions, 120 124 LabelDefs: defs, 121 125 }) ··· 126 130 user := rp.oauth.GetUser(r) 127 131 f, err := rp.repoResolver.Resolve(r) 128 132 if err != nil { 129 - log.Println("failed to get repo and knot", err) 133 + l.Error("failed to get repo and knot", "err", err) 130 134 return 131 135 } 132 136 ··· 166 170 return 167 171 } 168 172 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 173 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 174 if err != nil { 171 175 l.Error("failed to get record", "err", err) 172 176 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 177 return 174 178 } 175 179 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 180 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 181 Collection: tangled.RepoIssueNSID, 178 182 Repo: user.Did, 179 183 Rkey: newIssue.Rkey, ··· 199 203 200 204 err = db.PutIssue(tx, newIssue) 201 205 if err != nil { 202 - log.Println("failed to edit issue", err) 206 + l.Error("failed to edit issue", "err", err) 203 207 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 208 return 205 209 } ··· 237 241 // delete from PDS 238 242 client, err := rp.oauth.AuthorizedClient(r) 239 243 if err != nil { 240 - log.Println("failed to get authorized client", err) 244 + l.Error("failed to get authorized client", "err", err) 241 245 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 246 return 243 247 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 248 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 249 Collection: tangled.RepoIssueNSID, 246 250 Repo: issue.Did, 247 251 Rkey: issue.Rkey, ··· 260 264 return 261 265 } 262 266 267 + rp.notifier.DeleteIssue(r.Context(), issue) 268 + 263 269 // return to all issues page 264 270 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 265 271 } ··· 282 288 283 289 collaborators, err := f.Collaborators(r.Context()) 284 290 if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 291 + l.Error("failed to fetch repo collaborators", "err", err) 286 292 } 287 293 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 294 return user.Did == collab.Did ··· 296 302 db.FilterEq("id", issue.Id), 297 303 ) 298 304 if err != nil { 299 - log.Println("failed to close issue", err) 305 + l.Error("failed to close issue", "err", err) 300 306 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 307 return 302 308 } 309 + // change the issue state (this will pass down to the notifiers) 310 + issue.Open = false 311 + 312 + // notify about the issue closure 313 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 303 314 304 315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 305 316 return 306 317 } else { 307 - log.Println("user is not permitted to close issue") 318 + l.Error("user is not permitted to close issue") 308 319 http.Error(w, "for biden", http.StatusUnauthorized) 309 320 return 310 321 } ··· 315 326 user := rp.oauth.GetUser(r) 316 327 f, err := rp.repoResolver.Resolve(r) 317 328 if err != nil { 318 - log.Println("failed to get repo and knot", err) 329 + l.Error("failed to get repo and knot", "err", err) 319 330 return 320 331 } 321 332 ··· 328 339 329 340 collaborators, err := f.Collaborators(r.Context()) 330 341 if err != nil { 331 - log.Println("failed to fetch repo collaborators: %w", err) 342 + l.Error("failed to fetch repo collaborators", "err", err) 332 343 } 333 344 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 334 345 return user.Did == collab.Did ··· 341 352 db.FilterEq("id", issue.Id), 342 353 ) 343 354 if err != nil { 344 - log.Println("failed to reopen issue", err) 355 + l.Error("failed to reopen issue", "err", err) 345 356 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 346 357 return 347 358 } 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 + 348 365 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 349 366 return 350 367 } else { 351 - log.Println("user is not the owner of the repo") 368 + l.Error("user is not the owner of the repo") 352 369 http.Error(w, "forbidden", http.StatusUnauthorized) 353 370 return 354 371 } ··· 405 422 } 406 423 407 424 // create a record first 408 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 425 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 409 426 Collection: tangled.RepoIssueCommentNSID, 410 427 Repo: comment.Did, 411 428 Rkey: comment.Rkey, ··· 434 451 435 452 // reset atUri to make rollback a no-op 436 453 atUri = "" 454 + 455 + // notify about the new comment 456 + 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) 468 + 437 469 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 438 470 } 439 471 ··· 530 562 newBody := r.FormValue("body") 531 563 client, err := rp.oauth.AuthorizedClient(r) 532 564 if err != nil { 533 - log.Println("failed to get authorized client", err) 565 + l.Error("failed to get authorized client", "err", err) 534 566 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 535 567 return 536 568 } ··· 543 575 544 576 _, err = db.AddIssueComment(rp.db, newComment) 545 577 if err != nil { 546 - log.Println("failed to perferom update-description query", err) 578 + l.Error("failed to perferom update-description query", "err", err) 547 579 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 548 580 return 549 581 } ··· 551 583 // rkey is optional, it was introduced later 552 584 if newComment.Rkey != "" { 553 585 // update the record on pds 554 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 586 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 555 587 if err != nil { 556 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 588 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 557 589 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 558 590 return 559 591 } 560 592 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 593 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 562 594 Collection: tangled.RepoIssueCommentNSID, 563 595 Repo: user.Did, 564 596 Rkey: newComment.Rkey, ··· 721 753 if comment.Rkey != "" { 722 754 client, err := rp.oauth.AuthorizedClient(r) 723 755 if err != nil { 724 - log.Println("failed to get authorized client", err) 756 + l.Error("failed to get authorized client", "err", err) 725 757 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 726 758 return 727 759 } 728 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 760 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 729 761 Collection: tangled.RepoIssueCommentNSID, 730 762 Repo: user.Did, 731 763 Rkey: comment.Rkey, 732 764 }) 733 765 if err != nil { 734 - log.Println(err) 766 + l.Error("failed to delete from PDS", "err", err) 735 767 } 736 768 } 737 769 ··· 749 781 } 750 782 751 783 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 784 + l := rp.logger.With("handler", "RepoIssues") 785 + 752 786 params := r.URL.Query() 753 787 state := params.Get("state") 754 788 isOpen := true ··· 761 795 isOpen = true 762 796 } 763 797 764 - page, ok := r.Context().Value("page").(pagination.Page) 765 - if !ok { 766 - log.Println("failed to get page") 767 - page = pagination.FirstPage() 768 - } 798 + page := pagination.FromContext(r.Context()) 769 799 770 800 user := rp.oauth.GetUser(r) 771 801 f, err := rp.repoResolver.Resolve(r) 772 802 if err != nil { 773 - log.Println("failed to get repo and knot", err) 803 + l.Error("failed to get repo and knot", "err", err) 774 804 return 775 805 } 776 806 777 - openVal := 0 778 - if isOpen { 779 - openVal = 1 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, 815 + } 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)) 780 831 } 781 - issues, err := db.GetIssuesPaginated( 832 + 833 + issues, err := db.GetIssues( 782 834 rp.db, 783 - page, 784 - db.FilterEq("repo_at", f.RepoAt()), 785 - db.FilterEq("open", openVal), 835 + db.FilterIn("id", ids), 786 836 ) 787 837 if err != nil { 788 - log.Println("failed to get issues", err) 838 + l.Error("failed to get issues", "err", err) 789 839 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 790 840 return 791 841 } 792 842 793 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 843 + labelDefs, err := db.GetLabelDefinitions( 844 + rp.db, 845 + db.FilterIn("at_uri", f.Repo.Labels), 846 + db.FilterContains("scope", tangled.RepoIssueNSID), 847 + ) 794 848 if err != nil { 795 - log.Println("failed to fetch labels", err) 849 + l.Error("failed to fetch labels", "err", err) 796 850 rp.pages.Error503(w) 797 851 return 798 852 } ··· 808 862 Issues: issues, 809 863 LabelDefs: defs, 810 864 FilteringByOpen: isOpen, 865 + FilterQuery: keyword, 811 866 Page: page, 812 867 }) 813 868 } ··· 834 889 Rkey: tid.TID(), 835 890 Title: r.FormValue("title"), 836 891 Body: r.FormValue("body"), 892 + Open: true, 837 893 Did: user.Did, 838 894 Created: time.Now(), 895 + Repo: &f.Repo, 839 896 } 840 897 841 898 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 853 910 rp.pages.Notice(w, "issues", "Failed to create issue.") 854 911 return 855 912 } 856 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 913 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 857 914 Collection: tangled.RepoIssueNSID, 858 915 Repo: user.Did, 859 916 Rkey: issue.Rkey, ··· 889 946 890 947 err = db.PutIssue(tx, issue) 891 948 if err != nil { 892 - log.Println("failed to create issue", err) 949 + l.Error("failed to create issue", "err", err) 893 950 rp.pages.Notice(w, "issues", "Failed to create issue.") 894 951 return 895 952 } 896 953 897 954 if err = tx.Commit(); err != nil { 898 - log.Println("failed to create issue", err) 955 + l.Error("failed to create issue", "err", err) 899 956 rp.pages.Notice(w, "issues", "Failed to create issue.") 900 957 return 901 958 } 902 959 903 960 // everything is successful, do not rollback the atproto record 904 961 atUri = "" 905 - rp.notifier.NewIssue(r.Context(), issue) 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) 906 973 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 907 974 return 908 975 } ··· 911 978 // this is used to rollback changes made to the PDS 912 979 // 913 980 // it is a no-op if the provided ATURI is empty 914 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 981 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 915 982 if aturi == "" { 916 983 return nil 917 984 } ··· 922 989 repo := parsed.Authority().String() 923 990 rkey := parsed.RecordKey().String() 924 991 925 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 992 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 926 993 Collection: collection, 927 994 Repo: repo, 928 995 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) 19 20 20 21 // authenticated routes 21 22 r.Group(func(r chi.Router) {
+15 -6
appview/knots/knots.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 145 146 } 146 147 147 148 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, "/") 148 155 if domain == "" { 149 156 k.Pages.Notice(w, noticeId, "Incomplete form.") 150 157 return ··· 185 192 return 186 193 } 187 194 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 195 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 196 var exCid *string 190 197 if ex != nil { 191 198 exCid = ex.Cid 192 199 } 193 200 194 201 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 202 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 203 Collection: tangled.KnotNSID, 197 204 Repo: user.Did, 198 205 Rkey: domain, ··· 323 330 return 324 331 } 325 332 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 333 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 334 Collection: tangled.KnotNSID, 328 335 Repo: user.Did, 329 336 Rkey: domain, ··· 431 438 return 432 439 } 433 440 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 441 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 442 var exCid *string 436 443 if ex != nil { 437 444 exCid = ex.Cid 438 445 } 439 446 440 447 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 448 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 449 Collection: tangled.KnotNSID, 443 450 Repo: user.Did, 444 451 Rkey: domain, ··· 526 533 } 527 534 528 535 member := r.FormValue("member") 536 + member = strings.TrimPrefix(member, "@") 529 537 if member == "" { 530 538 l.Error("empty member") 531 539 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 555 563 556 564 rkey := tid.TID() 557 565 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 566 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 567 Collection: tangled.KnotMemberNSID, 560 568 Repo: user.Did, 561 569 Rkey: rkey, ··· 626 634 } 627 635 628 636 member := r.FormValue("member") 637 + member = strings.TrimPrefix(member, "@") 629 638 if member == "" { 630 639 l.Error("empty member") 631 640 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+25 -17
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 - 17 12 "tangled.org/core/api/tangled" 18 13 "tangled.org/core/appview/db" 19 14 "tangled.org/core/appview/middleware" ··· 21 16 "tangled.org/core/appview/oauth" 22 17 "tangled.org/core/appview/pages" 23 18 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 - "tangled.org/core/log" 19 + "tangled.org/core/rbac" 26 20 "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 27 ) 28 28 29 29 type Labels struct { ··· 32 32 db *db.DB 33 33 logger *slog.Logger 34 34 validator *validator.Validator 35 + enforcer *rbac.Enforcer 35 36 } 36 37 37 38 func New( ··· 39 40 pages *pages.Pages, 40 41 db *db.DB, 41 42 validator *validator.Validator, 43 + enforcer *rbac.Enforcer, 44 + logger *slog.Logger, 42 45 ) *Labels { 43 - logger := log.New("labels") 44 - 45 46 return &Labels{ 46 47 oauth: oauth, 47 48 pages: pages, 48 49 db: db, 49 50 logger: logger, 50 51 validator: validator, 52 + enforcer: enforcer, 51 53 } 52 54 } 53 55 54 - func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 56 + func (l *Labels) Router() http.Handler { 55 57 r := chi.NewRouter() 56 58 57 59 r.Use(middleware.AuthMiddleware(l.oauth)) ··· 85 87 indexedAt := time.Now() 86 88 repoAt := r.Form.Get("repo") 87 89 subjectUri := r.Form.Get("subject") 90 + 91 + repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 92 + if err != nil { 93 + fail("Failed to get repository.", err) 94 + return 95 + } 88 96 89 97 // find all the labels that this repo subscribes to 90 98 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) ··· 152 160 } 153 161 } 154 162 155 - // reduce the opset 156 - labelOps = models.ReduceLabelOps(labelOps) 157 - 158 163 for i := range labelOps { 159 164 def := actx.Defs[labelOps[i].OperandKey] 160 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 165 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 161 166 fail(fmt.Sprintf("Invalid form data: %s", err), err) 162 167 return 163 168 } 164 169 } 170 + 171 + // reduce the opset 172 + labelOps = models.ReduceLabelOps(labelOps) 165 173 166 174 // next, apply all ops introduced in this request and filter out ones that are no-ops 167 175 validLabelOps := labelOps[:0] ··· 186 194 return 187 195 } 188 196 189 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 190 198 Collection: tangled.LabelOpNSID, 191 199 Repo: did, 192 200 Rkey: rkey, ··· 242 250 // this is used to rollback changes made to the PDS 243 251 // 244 252 // it is a no-op if the provided ATURI is empty 245 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 253 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 246 254 if aturi == "" { 247 255 return nil 248 256 } ··· 253 261 repo := parsed.Authority().String() 254 262 rkey := parsed.RecordKey().String() 255 263 256 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 264 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 257 265 Collection: collection, 258 266 Repo: repo, 259 267 Rkey: rkey,
+16 -21
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 49 returnURL := "/" ··· 63 63 } 64 64 } 65 65 66 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 67 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 69 redirectFunc(w, r) 70 70 return 71 71 } 72 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 75 redirectFunc(w, r) 76 76 return 77 77 } ··· 105 105 } 106 106 } 107 107 108 - ctx := context.WithValue(r.Context(), "page", page) 108 + ctx := pagination.IntoContext(r.Context(), page) 109 109 next.ServeHTTP(w, r.WithContext(ctx)) 110 110 }) 111 111 } ··· 180 180 return func(next http.Handler) http.Handler { 181 181 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 182 didOrHandle := chi.URLParam(req, "user") 183 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 184 + 183 185 if slices.Contains(excluded, didOrHandle) { 184 186 next.ServeHTTP(w, req) 185 187 return 186 188 } 187 - 188 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 189 190 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 191 if err != nil { ··· 206 206 return func(next http.Handler) http.Handler { 207 207 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 208 repoName := chi.URLParam(req, "repo") 209 + repoName = strings.TrimSuffix(repoName, ".git") 210 + 209 211 id, ok := req.Context().Value("resolvedId").(identity.Identity) 210 212 if !ok { 211 213 log.Println("malformed middleware") ··· 244 246 prId := chi.URLParam(r, "pull") 245 247 prIdInt, err := strconv.Atoi(prId) 246 248 if err != nil { 247 - http.Error(w, "bad pr id", http.StatusBadRequest) 248 249 log.Println("failed to parse pr id", err) 250 + mw.pages.Error404(w) 249 251 return 250 252 } 251 253 252 254 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 253 255 if err != nil { 254 256 log.Println("failed to get pull and comments", err) 257 + mw.pages.Error404(w) 255 258 return 256 259 } 257 260 ··· 292 295 issueId, err := strconv.Atoi(issueIdStr) 293 296 if err != nil { 294 297 log.Println("failed to fully resolve issue ID", err) 295 - mw.pages.ErrorKnot404(w) 298 + mw.pages.Error404(w) 296 299 return 297 300 } 298 301 299 - issues, err := db.GetIssues( 300 - mw.db, 301 - db.FilterEq("repo_at", f.RepoAt()), 302 - db.FilterEq("issue_id", issueId), 303 - ) 302 + issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId) 304 303 if err != nil { 305 304 log.Println("failed to get issues", "err", err) 306 - return 307 - } 308 - if len(issues) != 1 { 309 - log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 305 + mw.pages.Error404(w) 310 306 return 311 307 } 312 - issue := issues[0] 313 308 314 - ctx := context.WithValue(r.Context(), "issue", &issue) 309 + ctx := context.WithValue(r.Context(), "issue", issue) 315 310 next.ServeHTTP(w, r.WithContext(ctx)) 316 311 }) 317 312 }
+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 + 57 77 func (i *Issue) CommentList() []CommentListItem { 58 78 // Create a map to quickly find comments by their aturi 59 79 toplevel := make(map[string]*CommentListItem) ··· 167 187 168 188 func (i *IssueComment) IsTopLevel() bool { 169 189 return i.ReplyTo == nil 190 + } 191 + 192 + func (i *IssueComment) IsReply() bool { 193 + return i.ReplyTo != nil 170 194 } 171 195 172 196 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+30 -46
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" 18 17 "tangled.org/core/idresolver" 19 18 ) 20 19 ··· 232 231 } 233 232 234 233 var ops []LabelOp 235 - for _, o := range record.Add { 234 + // deletes first, then additions 235 + for _, o := range record.Delete { 236 236 if o != nil { 237 237 op := mkOp(o) 238 - op.Operation = LabelOperationAdd 238 + op.Operation = LabelOperationDel 239 239 ops = append(ops, op) 240 240 } 241 241 } 242 - for _, o := range record.Delete { 242 + for _, o := range record.Add { 243 243 if o != nil { 244 244 op := mkOp(o) 245 - op.Operation = LabelOperationDel 245 + op.Operation = LabelOperationAdd 246 246 ops = append(ops, op) 247 247 } 248 248 } ··· 460 460 return result 461 461 } 462 462 463 - func DefaultLabelDefs() []string { 464 - rkeys := []string{ 465 - "wontfix", 466 - "duplicate", 467 - "assignee", 468 - "good-first-issue", 469 - "documentation", 470 - } 471 - 472 - defs := make([]string, len(rkeys)) 473 - for i, r := range rkeys { 474 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 475 - } 476 - 477 - return defs 478 - } 479 - 480 - func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 481 - resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 482 - if err != nil { 483 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 484 - } 485 - pdsEndpoint := resolved.PDSEndpoint() 486 - if pdsEndpoint == "" { 487 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 488 - } 489 - client := &xrpc.Client{ 490 - Host: pdsEndpoint, 491 - } 463 + func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 464 + var labelDefs []LabelDefinition 465 + ctx := context.Background() 492 466 493 - var labelDefs []LabelDefinition 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 + } 494 475 495 - for _, dl := range DefaultLabelDefs() { 496 - atUri := syntax.ATURI(dl) 497 - parsedUri, err := syntax.ParseATURI(string(atUri)) 476 + owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 498 477 if err != nil { 499 - return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 478 + return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 500 479 } 480 + 481 + xrpcc := xrpc.Client{ 482 + Host: owner.PDSEndpoint(), 483 + } 484 + 501 485 record, err := atproto.RepoGetRecord( 502 - context.Background(), 503 - client, 486 + ctx, 487 + &xrpcc, 504 488 "", 505 - parsedUri.Collection().String(), 506 - parsedUri.Authority().String(), 507 - parsedUri.RecordKey().String(), 489 + atUri.Collection().String(), 490 + atUri.Authority().String(), 491 + atUri.RecordKey().String(), 508 492 ) 509 493 if err != nil { 510 494 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 524 508 } 525 509 526 510 labelDef, err := LabelDefinitionFromRecord( 527 - parsedUri.Authority().String(), 528 - parsedUri.RecordKey().String(), 511 + atUri.Authority().String(), 512 + atUri.RecordKey().String(), 529 513 labelRecord, 530 514 ) 531 515 if err != nil {
+141
appview/models/notifications.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type NotificationType string 10 + 11 + const ( 12 + NotificationTypeRepoStarred NotificationType = "repo_starred" 13 + NotificationTypeIssueCreated NotificationType = "issue_created" 14 + NotificationTypeIssueCommented NotificationType = "issue_commented" 15 + NotificationTypePullCreated NotificationType = "pull_created" 16 + NotificationTypePullCommented NotificationType = "pull_commented" 17 + NotificationTypeFollowed NotificationType = "followed" 18 + NotificationTypePullMerged NotificationType = "pull_merged" 19 + NotificationTypeIssueClosed NotificationType = "issue_closed" 20 + NotificationTypeIssueReopen NotificationType = "issue_reopen" 21 + NotificationTypePullClosed NotificationType = "pull_closed" 22 + NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 24 + ) 25 + 26 + type Notification struct { 27 + ID int64 28 + RecipientDid string 29 + ActorDid string 30 + Type NotificationType 31 + EntityType string 32 + EntityId string 33 + Read bool 34 + Created time.Time 35 + 36 + // foreign key references 37 + RepoId *int64 38 + IssueId *int64 39 + PullId *int64 40 + } 41 + 42 + // lucide icon that represents this notification 43 + func (n *Notification) Icon() string { 44 + switch n.Type { 45 + case NotificationTypeRepoStarred: 46 + return "star" 47 + case NotificationTypeIssueCreated: 48 + return "circle-dot" 49 + case NotificationTypeIssueCommented: 50 + return "message-square" 51 + case NotificationTypeIssueClosed: 52 + return "ban" 53 + case NotificationTypeIssueReopen: 54 + return "circle-dot" 55 + case NotificationTypePullCreated: 56 + return "git-pull-request-create" 57 + case NotificationTypePullCommented: 58 + return "message-square" 59 + case NotificationTypePullMerged: 60 + return "git-merge" 61 + case NotificationTypePullClosed: 62 + return "git-pull-request-closed" 63 + case NotificationTypePullReopen: 64 + return "git-pull-request-create" 65 + case NotificationTypeFollowed: 66 + return "user-plus" 67 + case NotificationTypeUserMentioned: 68 + return "at-sign" 69 + default: 70 + return "" 71 + } 72 + } 73 + 74 + type NotificationWithEntity struct { 75 + *Notification 76 + Repo *Repo 77 + Issue *Issue 78 + Pull *Pull 79 + } 80 + 81 + type NotificationPreferences struct { 82 + ID int64 83 + UserDid syntax.DID 84 + RepoStarred bool 85 + IssueCreated bool 86 + IssueCommented bool 87 + PullCreated bool 88 + PullCommented bool 89 + Followed bool 90 + UserMentioned bool 91 + PullMerged bool 92 + IssueClosed bool 93 + EmailNotifications bool 94 + } 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 22 23 } 23 24 24 25 func (p Profile) IsLinksEmpty() bool {
+77 -28
appview/models/pull.go
··· 77 77 PullSource *PullSource 78 78 79 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 80 + Labels LabelState 81 + Repo *Repo 81 82 } 82 83 83 84 func (p Pull) AsRecord() tangled.RepoPull { 84 85 var source *tangled.RepoPull_Source 85 86 if p.PullSource != nil { 86 - s := p.PullSource.AsRecord() 87 - source = &s 87 + source = &tangled.RepoPull_Source{} 88 + source.Branch = p.PullSource.Branch 88 89 source.Sha = p.LatestSha() 90 + if p.PullSource.RepoAt != nil { 91 + s := p.PullSource.RepoAt.String() 92 + source.Repo = &s 93 + } 89 94 } 90 95 91 96 record := tangled.RepoPull{ ··· 110 115 Repo *Repo 111 116 } 112 117 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 - 126 118 type PullSubmission struct { 127 119 // ids 128 - ID int 129 - PullId int 120 + ID int 130 121 131 122 // at ids 132 - RepoAt syntax.ATURI 123 + PullAt syntax.ATURI 133 124 134 125 // content 135 126 RoundNumber int 136 127 Patch string 128 + Combined string 137 129 Comments []PullComment 138 130 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 131 ··· 159 151 Created time.Time 160 152 } 161 153 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 - latestSubmission := p.Submissions[p.LastRoundNumber()] 164 - return latestSubmission.Patch 163 + return p.LatestSubmission().Patch 165 164 } 166 165 167 166 func (p *Pull) LatestSha() string { 168 - latestSubmission := p.Submissions[p.LastRoundNumber()] 169 - return latestSubmission.SourceRev 167 + return p.LatestSubmission().SourceRev 170 168 } 171 169 172 - func (p *Pull) PullAt() syntax.ATURI { 170 + func (p *Pull) AtUri() syntax.ATURI { 173 171 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 178 172 } 179 173 180 174 func (p *Pull) IsPatchBased() bool { ··· 207 201 return p.StackId != "" 208 202 } 209 203 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 + 210 226 func (s PullSubmission) IsFormatPatch() bool { 211 227 return patchutil.IsFormatPatch(s.Patch) 212 228 } ··· 219 235 } 220 236 221 237 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 222 266 } 223 267 224 268 type Stack []*Pull ··· 308 352 309 353 return mergeable 310 354 } 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 + }
+20 -1
appview/models/repo.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 10 11 ) 11 12 12 13 type Repo struct { 14 + Id int64 13 15 Did string 14 16 Name string 15 17 Knot string 16 18 Rkey string 17 19 Created time.Time 18 20 Description string 21 + Website string 22 + Topics []string 19 23 Spindle string 20 24 Labels []string 21 25 ··· 27 31 } 28 32 29 33 func (r *Repo) AsRecord() tangled.Repo { 30 - var source, spindle, description *string 34 + var source, spindle, description, website *string 31 35 32 36 if r.Source != "" { 33 37 source = &r.Source ··· 41 45 description = &r.Description 42 46 } 43 47 48 + if r.Website != "" { 49 + website = &r.Website 50 + } 51 + 44 52 return tangled.Repo{ 45 53 Knot: r.Knot, 46 54 Name: r.Name, 47 55 Description: description, 56 + Website: website, 57 + Topics: r.Topics, 48 58 CreatedAt: r.Created.Format(time.RFC3339), 49 59 Source: source, 50 60 Spindle: spindle, ··· 59 69 func (r Repo) DidSlashRepo() string { 60 70 p, _ := securejoin.SecureJoin(r.Did, r.Name) 61 71 return p 72 + } 73 + 74 + func (r Repo) TopicStr() string { 75 + return strings.Join(r.Topics, " ") 62 76 } 63 77 64 78 type RepoStats struct { ··· 85 99 RepoAt syntax.ATURI 86 100 LabelAt syntax.ATURI 87 101 } 102 + 103 + type RepoGroup struct { 104 + Repo *Repo 105 + Issues []Issue 106 + }
+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 + // }
+165
appview/notifications/notifications.go
··· 1 + package notifications 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/middleware" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/appview/pagination" 14 + ) 15 + 16 + type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + logger *slog.Logger 21 + } 22 + 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 24 + return &Notifications{ 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + logger: logger, 29 + } 30 + } 31 + 32 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 33 + r := chi.NewRouter() 34 + 35 + 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 + }) 44 + 45 + return r 46 + } 47 + 48 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 49 + l := n.logger.With("handler", "notificationsPage") 50 + user := n.oauth.GetUser(r) 51 + 52 + page := pagination.FromContext(r.Context()) 53 + 54 + total, err := db.CountNotifications( 55 + n.db, 56 + db.FilterEq("recipient_did", user.Did), 57 + ) 58 + if err != nil { 59 + l.Error("failed to get total notifications", "err", err) 60 + n.pages.Error500(w) 61 + return 62 + } 63 + 64 + notifications, err := db.GetNotificationsWithEntities( 65 + n.db, 66 + page, 67 + db.FilterEq("recipient_did", user.Did), 68 + ) 69 + if err != nil { 70 + l.Error("failed to get notifications", "err", err) 71 + n.pages.Error500(w) 72 + return 73 + } 74 + 75 + err = db.MarkAllNotificationsRead(n.db, user.Did) 76 + if err != nil { 77 + l.Error("failed to mark notifications as read", "err", err) 78 + } 79 + 80 + unreadCount := 0 81 + 82 + n.pages.Notifications(w, pages.NotificationsParams{ 83 + LoggedInUser: user, 84 + Notifications: notifications, 85 + UnreadCount: unreadCount, 86 + Page: page, 87 + Total: total, 88 + }) 89 + } 90 + 91 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 92 + user := n.oauth.GetUser(r) 93 + if user == nil { 94 + return 95 + } 96 + 97 + count, err := db.CountNotifications( 98 + n.db, 99 + db.FilterEq("recipient_did", user.Did), 100 + db.FilterEq("read", 0), 101 + ) 102 + if err != nil { 103 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 104 + return 105 + } 106 + 107 + params := pages.NotificationCountParams{ 108 + Count: count, 109 + } 110 + err = n.pages.NotificationCount(w, params) 111 + if err != nil { 112 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 113 + return 114 + } 115 + } 116 + 117 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 118 + userDid := n.oauth.GetDid(r) 119 + 120 + idStr := chi.URLParam(r, "id") 121 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 122 + if err != nil { 123 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 124 + return 125 + } 126 + 127 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 128 + if err != nil { 129 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 130 + return 131 + } 132 + 133 + w.WriteHeader(http.StatusNoContent) 134 + } 135 + 136 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 137 + userDid := n.oauth.GetDid(r) 138 + 139 + err := db.MarkAllNotificationsRead(n.db, userDid) 140 + if err != nil { 141 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 142 + return 143 + } 144 + 145 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 146 + } 147 + 148 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 149 + userDid := n.oauth.GetDid(r) 150 + 151 + idStr := chi.URLParam(r, "id") 152 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 153 + if err != nil { 154 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 155 + return 156 + } 157 + 158 + err = db.DeleteNotification(n.db, notificationID, userDid) 159 + if err != nil { 160 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 161 + return 162 + } 163 + 164 + w.WriteHeader(http.StatusOK) 165 + }
+489
appview/notify/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "maps" 7 + "slices" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/notify" 13 + "tangled.org/core/idresolver" 14 + ) 15 + 16 + const ( 17 + maxMentions = 5 18 + ) 19 + 20 + type databaseNotifier struct { 21 + db *db.DB 22 + res *idresolver.Resolver 23 + } 24 + 25 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 26 + return &databaseNotifier{ 27 + db: database, 28 + res: resolver, 29 + } 30 + } 31 + 32 + var _ notify.Notifier = &databaseNotifier{} 33 + 34 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 35 + // no-op for now 36 + } 37 + 38 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 39 + var err error 40 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 41 + if err != nil { 42 + log.Printf("NewStar: failed to get repos: %v", err) 43 + return 44 + } 45 + 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 54 + 55 + n.notifyEvent( 56 + actorDid, 57 + recipients, 58 + eventType, 59 + entityType, 60 + entityId, 61 + repoId, 62 + issueId, 63 + pullId, 64 + ) 65 + } 66 + 67 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 68 + // no-op 69 + } 70 + 71 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 72 + 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())) 79 + if err != nil { 80 + log.Printf("failed to fetch collaborators: %v", err) 81 + return 82 + } 83 + for _, c := range collaborators { 84 + recipients = append(recipients, c.SubjectDid) 85 + } 86 + 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 + 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 + ) 114 + } 115 + 116 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 117 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 118 + if err != nil { 119 + log.Printf("NewIssueComment: failed to get issues: %v", err) 120 + return 121 + } 122 + if len(issues) == 0 { 123 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 124 + return 125 + } 126 + issue := issues[0] 127 + 128 + var recipients []syntax.DID 129 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 130 + 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() 135 + 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 + } 141 + } 142 + } else { 143 + // not a reply, notify just the issue author 144 + recipients = append(recipients, syntax.DID(issue.Did)) 145 + } 146 + 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 153 + 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 + } 175 + 176 + func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 177 + // no-op for now 178 + } 179 + 180 + 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 187 + 188 + n.notifyEvent( 189 + actorDid, 190 + recipients, 191 + eventType, 192 + entityType, 193 + entityId, 194 + repoId, 195 + issueId, 196 + pullId, 197 + ) 198 + } 199 + 200 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 201 + // no-op 202 + } 203 + 204 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 205 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 206 + if err != nil { 207 + log.Printf("NewPull: failed to get repos: %v", err) 208 + return 209 + } 210 + 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())) 217 + if err != nil { 218 + log.Printf("failed to fetch collaborators: %v", err) 219 + return 220 + } 221 + for _, c := range collaborators { 222 + recipients = append(recipients, c.SubjectDid) 223 + } 224 + 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 233 + 234 + n.notifyEvent( 235 + actorDid, 236 + recipients, 237 + eventType, 238 + entityType, 239 + entityId, 240 + repoId, 241 + issueId, 242 + pullId, 243 + ) 244 + } 245 + 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 + ) 251 + if err != nil { 252 + log.Printf("NewPullComment: failed to get pulls: %v", err) 253 + return 254 + } 255 + 256 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 257 + if err != nil { 258 + log.Printf("NewPullComment: failed to get repos: %v", err) 259 + return 260 + } 261 + 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)) 269 + } 270 + 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 279 + 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 + ) 300 + } 301 + 302 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 303 + // no-op 304 + } 305 + 306 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 307 + // no-op 308 + } 309 + 310 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 311 + // no-op 312 + } 313 + 314 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 315 + // no-op 316 + } 317 + 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())) 326 + if err != nil { 327 + log.Printf("failed to fetch collaborators: %v", err) 328 + return 329 + } 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)) 335 + } 336 + 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 343 + 344 + if issue.Open { 345 + eventType = models.NotificationTypeIssueReopen 346 + } else { 347 + eventType = models.NotificationTypeIssueClosed 348 + } 349 + 350 + n.notifyEvent( 351 + actor, 352 + recipients, 353 + eventType, 354 + entityType, 355 + entityId, 356 + repoId, 357 + issueId, 358 + pullId, 359 + ) 360 + } 361 + 362 + func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 363 + // Get repo details 364 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 365 + if err != nil { 366 + log.Printf("NewPullState: failed to get repos: %v", err) 367 + return 368 + } 369 + 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())) 376 + if err != nil { 377 + log.Printf("failed to fetch collaborators: %v", err) 378 + return 379 + } 380 + for _, c := range collaborators { 381 + recipients = append(recipients, c.SubjectDid) 382 + } 383 + for _, p := range pull.Participants() { 384 + recipients = append(recipients, syntax.DID(p)) 385 + } 386 + 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) 401 + return 402 + } 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 + } 417 + 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 + } 437 + } 438 + 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 445 + return 446 + } 447 + 448 + // create a transaction for bulk notification storage 449 + tx, err := n.db.Begin() 450 + if err != nil { 451 + // failed to start tx 452 + return 453 + } 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 + } 479 + 480 + if err := db.CreateNotification(tx, notif); err != nil { 481 + log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 482 + } 483 + } 484 + 485 + if err := tx.Commit(); err != nil { 486 + // failed to commit 487 + return 488 + } 489 + }
+63 -42
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 6 + "reflect" 7 + "sync" 5 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 6 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/log" 7 12 ) 8 13 9 14 type mergedNotifier struct { 10 15 notifiers []Notifier 16 + logger *slog.Logger 11 17 } 12 18 13 - func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 - return &mergedNotifier{notifiers} 19 + func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 + return &mergedNotifier{notifiers, logger} 15 21 } 16 22 17 23 var _ Notifier = &mergedNotifier{} 18 24 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 + 19 45 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 22 - } 46 + m.fanout("NewRepo", ctx, repo) 23 47 } 24 48 25 49 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 50 + m.fanout("NewStar", ctx, star) 29 51 } 52 + 30 53 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 54 + m.fanout("DeleteStar", ctx, star) 55 + } 56 + 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) 63 + } 64 + 65 + func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 66 + m.fanout("NewIssueState", ctx, actor, issue) 34 67 } 35 68 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 69 + func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 70 + m.fanout("DeleteIssue", ctx, issue) 40 71 } 41 72 42 73 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 - for _, notifier := range m.notifiers { 44 - notifier.NewFollow(ctx, follow) 45 - } 74 + m.fanout("NewFollow", ctx, follow) 46 75 } 76 + 47 77 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 48 - for _, notifier := range m.notifiers { 49 - notifier.DeleteFollow(ctx, follow) 50 - } 78 + m.fanout("DeleteFollow", ctx, follow) 51 79 } 52 80 53 81 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewPull(ctx, pull) 56 - } 82 + m.fanout("NewPull", ctx, pull) 57 83 } 58 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 - for _, notifier := range m.notifiers { 60 - notifier.NewPullComment(ctx, comment) 61 - } 84 + 85 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 + m.fanout("NewPullComment", ctx, comment, mentions) 87 + } 88 + 89 + func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 90 + m.fanout("NewPullState", ctx, actor, pull) 62 91 } 63 92 64 93 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 65 - for _, notifier := range m.notifiers { 66 - notifier.UpdateProfile(ctx, profile) 67 - } 94 + m.fanout("UpdateProfile", ctx, profile) 68 95 } 69 96 70 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 71 - for _, notifier := range m.notifiers { 72 - notifier.NewString(ctx, string) 73 - } 97 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 98 + m.fanout("NewString", ctx, s) 74 99 } 75 100 76 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 77 - for _, notifier := range m.notifiers { 78 - notifier.EditString(ctx, string) 79 - } 101 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 102 + m.fanout("EditString", ctx, s) 80 103 } 81 104 82 105 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 - for _, notifier := range m.notifiers { 84 - notifier.DeleteString(ctx, did, rkey) 85 - } 106 + m.fanout("DeleteString", ctx, did, rkey) 86 107 }
+16 -5
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 + "github.com/bluesky-social/indigo/atproto/syntax" 6 7 "tangled.org/core/appview/models" 7 8 ) 8 9 ··· 12 13 NewStar(ctx context.Context, star *models.Star) 13 14 DeleteStar(ctx context.Context, star *models.Star) 14 15 15 - NewIssue(ctx context.Context, issue *models.Issue) 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) 16 20 17 21 NewFollow(ctx context.Context, follow *models.Follow) 18 22 DeleteFollow(ctx context.Context, follow *models.Follow) 19 23 20 24 NewPull(ctx context.Context, pull *models.Pull) 21 - NewPullComment(ctx context.Context, comment *models.PullComment) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 + NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 22 27 23 28 UpdateProfile(ctx context.Context, profile *models.Profile) 24 29 ··· 37 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 38 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 44 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 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) {} 41 50 42 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 43 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 53 45 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 46 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 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) {} 47 58 48 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49 60
+243
appview/notify/posthog/notifier.go
··· 1 + package posthog 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/posthog/posthog-go" 9 + "tangled.org/core/appview/models" 10 + "tangled.org/core/appview/notify" 11 + ) 12 + 13 + type posthogNotifier struct { 14 + client posthog.Client 15 + notify.BaseNotifier 16 + } 17 + 18 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 19 + return &posthogNotifier{ 20 + client, 21 + notify.BaseNotifier{}, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &posthogNotifier{} 26 + 27 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + err := n.client.Enqueue(posthog.Capture{ 29 + DistinctId: repo.Did, 30 + Event: "new_repo", 31 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 32 + }) 33 + if err != nil { 34 + log.Println("failed to enqueue posthog event:", err) 35 + } 36 + } 37 + 38 + func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 + err := n.client.Enqueue(posthog.Capture{ 40 + DistinctId: star.StarredByDid, 41 + Event: "star", 42 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 + }) 44 + if err != nil { 45 + log.Println("failed to enqueue posthog event:", err) 46 + } 47 + } 48 + 49 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 + err := n.client.Enqueue(posthog.Capture{ 51 + DistinctId: star.StarredByDid, 52 + Event: "unstar", 53 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 + }) 55 + if err != nil { 56 + log.Println("failed to enqueue posthog event:", err) 57 + } 58 + } 59 + 60 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 61 + err := n.client.Enqueue(posthog.Capture{ 62 + DistinctId: issue.Did, 63 + Event: "new_issue", 64 + Properties: posthog.Properties{ 65 + "repo_at": issue.RepoAt.String(), 66 + "issue_id": issue.IssueId, 67 + "mentions": mentions, 68 + }, 69 + }) 70 + if err != nil { 71 + log.Println("failed to enqueue posthog event:", err) 72 + } 73 + } 74 + 75 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 76 + err := n.client.Enqueue(posthog.Capture{ 77 + DistinctId: pull.OwnerDid, 78 + Event: "new_pull", 79 + Properties: posthog.Properties{ 80 + "repo_at": pull.RepoAt, 81 + "pull_id": pull.PullId, 82 + }, 83 + }) 84 + if err != nil { 85 + log.Println("failed to enqueue posthog event:", err) 86 + } 87 + } 88 + 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 + err := n.client.Enqueue(posthog.Capture{ 91 + DistinctId: comment.OwnerDid, 92 + Event: "new_pull_comment", 93 + Properties: posthog.Properties{ 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 97 + }, 98 + }) 99 + if err != nil { 100 + log.Println("failed to enqueue posthog event:", err) 101 + } 102 + } 103 + 104 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 105 + err := n.client.Enqueue(posthog.Capture{ 106 + DistinctId: pull.OwnerDid, 107 + Event: "pull_closed", 108 + Properties: posthog.Properties{ 109 + "repo_at": pull.RepoAt, 110 + "pull_id": pull.PullId, 111 + }, 112 + }) 113 + if err != nil { 114 + log.Println("failed to enqueue posthog event:", err) 115 + } 116 + } 117 + 118 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 119 + err := n.client.Enqueue(posthog.Capture{ 120 + DistinctId: follow.UserDid, 121 + Event: "follow", 122 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 123 + }) 124 + if err != nil { 125 + log.Println("failed to enqueue posthog event:", err) 126 + } 127 + } 128 + 129 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 130 + err := n.client.Enqueue(posthog.Capture{ 131 + DistinctId: follow.UserDid, 132 + Event: "unfollow", 133 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 134 + }) 135 + if err != nil { 136 + log.Println("failed to enqueue posthog event:", err) 137 + } 138 + } 139 + 140 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 141 + err := n.client.Enqueue(posthog.Capture{ 142 + DistinctId: profile.Did, 143 + Event: "edit_profile", 144 + }) 145 + if err != nil { 146 + log.Println("failed to enqueue posthog event:", err) 147 + } 148 + } 149 + 150 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 151 + err := n.client.Enqueue(posthog.Capture{ 152 + DistinctId: did, 153 + Event: "delete_string", 154 + Properties: posthog.Properties{"rkey": rkey}, 155 + }) 156 + if err != nil { 157 + log.Println("failed to enqueue posthog event:", err) 158 + } 159 + } 160 + 161 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 162 + err := n.client.Enqueue(posthog.Capture{ 163 + DistinctId: string.Did.String(), 164 + Event: "edit_string", 165 + Properties: posthog.Properties{"rkey": string.Rkey}, 166 + }) 167 + if err != nil { 168 + log.Println("failed to enqueue posthog event:", err) 169 + } 170 + } 171 + 172 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 173 + err := n.client.Enqueue(posthog.Capture{ 174 + DistinctId: string.Did.String(), 175 + Event: "new_string", 176 + Properties: posthog.Properties{"rkey": string.Rkey}, 177 + }) 178 + if err != nil { 179 + log.Println("failed to enqueue posthog event:", err) 180 + } 181 + } 182 + 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 184 + err := n.client.Enqueue(posthog.Capture{ 185 + DistinctId: comment.Did, 186 + Event: "new_issue_comment", 187 + Properties: posthog.Properties{ 188 + "issue_at": comment.IssueAt, 189 + "mentions": mentions, 190 + }, 191 + }) 192 + if err != nil { 193 + log.Println("failed to enqueue posthog event:", err) 194 + } 195 + } 196 + 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 + } 204 + err := n.client.Enqueue(posthog.Capture{ 205 + DistinctId: issue.Did, 206 + Event: event, 207 + Properties: posthog.Properties{ 208 + "repo_at": issue.RepoAt.String(), 209 + "actor": actor, 210 + "issue_id": issue.IssueId, 211 + }, 212 + }) 213 + if err != nil { 214 + log.Println("failed to enqueue posthog event:", err) 215 + } 216 + } 217 + 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 + } 231 + err := n.client.Enqueue(posthog.Capture{ 232 + DistinctId: pull.OwnerDid, 233 + Event: event, 234 + Properties: posthog.Properties{ 235 + "repo_at": pull.RepoAt, 236 + "pull_id": pull.PullId, 237 + "actor": actor, 238 + }, 239 + }) 240 + if err != nil { 241 + log.Println("failed to enqueue posthog event:", err) 242 + } 243 + }
-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 - }
+2 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 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 + }
+136 -203
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 + "log/slog" 6 7 "net/http" 7 - "net/url" 8 8 "time" 9 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 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" 11 16 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 17 + "github.com/posthog/posthog-go" 13 18 "tangled.org/core/appview/config" 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" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 18 22 ) 19 23 20 24 type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 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 24 36 } 25 37 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, 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"}) 31 50 } 32 - } 33 51 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) 52 + // configure client secret 53 + priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret) 41 54 if err != nil { 42 - return err 55 + return nil, err 56 + } 57 + if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil { 58 + return nil, err 43 59 } 44 60 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) 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 + }) 50 69 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 70 + return nil, err 52 71 } 53 72 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), 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 65 80 } 66 81 67 - return o.sess.SaveSession(r.Context(), session) 82 + clientName := config.Core.AppviewName 83 + 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 68 98 } 69 99 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) 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) 103 + if err != nil { 104 + return err 74 105 } 75 106 76 - did := userSession.Values[SessionDid].(string) 107 + userSession.Values[SessionDid] = sessData.AccountDID.String() 108 + userSession.Values[SessionPds] = sessData.HostURL 109 + userSession.Values[SessionId] = sessData.SessionID 110 + userSession.Values[SessionAuthenticated] = true 111 + return userSession.Save(r, w) 112 + } 77 113 78 - err = o.sess.DeleteSession(r.Context(), did) 114 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 115 + userSession, err := o.SessStore.Get(r, SessionName) 79 116 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 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") 81 121 } 82 122 83 - userSession.Options.MaxAge = -1 123 + d := userSession.Values[SessionDid].(string) 124 + sessDid, err := syntax.ParseDID(d) 125 + if err != nil { 126 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 127 + } 84 128 85 - return userSession.Save(r, w) 86 - } 129 + sessId := userSession.Values[SessionId].(string) 87 130 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) 131 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 132 + if err != nil { 133 + return nil, fmt.Errorf("failed to resume session: %w", err) 92 134 } 93 135 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 136 + return clientSess, nil 137 + } 96 138 97 - session, err := o.sess.GetSession(r.Context(), did) 139 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 140 + userSession, err := o.SessStore.Get(r, SessionName) 98 141 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 142 + return fmt.Errorf("error getting user session: %w", err) 143 + } 144 + if userSession.IsNew { 145 + return fmt.Errorf("no session available for user") 100 146 } 101 147 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 148 + d := userSession.Values[SessionDid].(string) 149 + sessDid, err := syntax.ParseDID(d) 103 150 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 151 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 152 } 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 - } 111 153 112 - self := o.ClientMetadata() 154 + sessId := userSession.Values[SessionId].(string) 113 155 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 156 + // delete the session 157 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 158 120 - if err != nil { 121 - return nil, false, err 122 - } 159 + // remove the cookie 160 + userSession.Options.MaxAge = -1 161 + err2 := o.SessStore.Save(r, w, userSession) 123 162 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 128 - 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 - } 134 - 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 - } 141 - 142 - return session, auth, nil 163 + return errors.Join(err1, err2) 143 164 } 144 165 145 166 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 167 + Did string 168 + Pds string 149 169 } 150 170 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 153 - 154 - if err != nil || clientSession.IsNew { 171 + func (o *OAuth) GetUser(r *http.Request) *User { 172 + sess, err := o.ResumeSession(r) 173 + if err != nil { 155 174 return nil 156 175 } 157 176 158 177 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 178 + Did: sess.Data.AccountDID.String(), 179 + Pds: sess.Data.HostURL, 162 180 } 163 181 } 164 182 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 "" 183 + func (o *OAuth) GetDid(r *http.Request) string { 184 + if u := o.GetUser(r); u != nil { 185 + return u.Did 170 186 } 171 187 172 - return clientSession.Values[SessionDid].(string) 188 + return "" 173 189 } 174 190 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 191 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 192 + session, err := o.ResumeSession(r) 177 193 if err != nil { 178 194 return nil, fmt.Errorf("error getting session: %w", err) 179 195 } 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 196 + return session.APIClient(), nil 208 197 } 209 198 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 199 // this is a higher level abstraction on ServerGetServiceAuth 213 200 type ServiceClientOpts struct { 214 201 service string ··· 259 246 return scheme + s.service 260 247 } 261 248 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 249 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 250 opts := ServiceClientOpts{} 264 251 for _, o := range os { 265 252 o(&opts) 266 253 } 267 254 268 - authorizedClient, err := o.AuthorizedClient(r) 255 + client, err := o.AuthorizedClient(r) 269 256 if err != nil { 270 257 return nil, err 271 258 } ··· 276 263 opts.exp = sixty 277 264 } 278 265 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 266 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 267 if err != nil { 281 268 return nil, err 282 269 } 283 270 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 271 + return &xrpc.Client{ 272 + Auth: &xrpc.AuthInfo{ 286 273 AccessJwt: resp.Token, 287 274 }, 288 275 Host: opts.Host(), ··· 291 278 }, 292 279 }, nil 293 280 } 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 + }
+31 -13
appview/pages/funcmap.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + "github.com/bluesky-social/indigo/atproto/syntax" 20 21 "github.com/dustin/go-humanize" 21 22 "github.com/go-enry/go-enry/v2" 22 23 "tangled.org/core/appview/filetree" ··· 38 39 "contains": func(s string, target string) bool { 39 40 return strings.Contains(s, target) 40 41 }, 42 + "stripPort": func(hostname string) string { 43 + if strings.Contains(hostname, ":") { 44 + return strings.Split(hostname, ":")[0] 45 + } 46 + return hostname 47 + }, 41 48 "mapContains": func(m any, key any) bool { 42 49 mapValue := reflect.ValueOf(m) 43 50 if mapValue.Kind() != reflect.Map { ··· 57 64 return "handle.invalid" 58 65 } 59 66 60 - return "@" + identity.Handle.String() 67 + return identity.Handle.String() 61 68 }, 62 69 "truncateAt30": func(s string) string { 63 70 if len(s) <= 30 { ··· 68 75 "splitOn": func(s, sep string) []string { 69 76 return strings.Split(s, sep) 70 77 }, 78 + "string": func(v any) string { 79 + return fmt.Sprint(v) 80 + }, 71 81 "int64": func(a int) int64 { 72 82 return int64(a) 73 83 }, ··· 117 127 return b 118 128 }, 119 129 "didOrHandle": func(did, handle string) string { 120 - if handle != "" { 121 - return fmt.Sprintf("@%s", handle) 130 + if handle != "" && handle != syntax.HandleInvalid.String() { 131 + return handle 122 132 } else { 123 133 return did 124 134 } ··· 236 246 sanitized := p.rctx.SanitizeDescription(htmlString) 237 247 return template.HTML(sanitized) 238 248 }, 249 + "trimUriScheme": func(text string) string { 250 + text = strings.TrimPrefix(text, "https://") 251 + text = strings.TrimPrefix(text, "http://") 252 + return text 253 + }, 239 254 "isNil": func(t any) bool { 240 255 // returns false for other "zero" values 241 256 return t == nil ··· 265 280 return nil 266 281 }, 267 282 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 283 + data, err := p.icon(name, classes) 269 284 if err != nil { 270 285 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 286 + data, _ = p.icon("airplay", classes) 272 287 } 273 288 return template.HTML(data) 274 289 }, 275 - "cssContentHash": CssContentHash, 290 + "cssContentHash": p.CssContentHash, 276 291 "fileTree": filetree.FileTree, 277 292 "pathEscape": func(s string) string { 278 293 return url.PathEscape(s) ··· 281 296 u, _ := url.PathUnescape(s) 282 297 return u 283 298 }, 284 - 299 + "safeUrl": func(s string) template.URL { 300 + return template.URL(s) 301 + }, 285 302 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 303 + return p.AvatarUrl(handle, "tiny") 287 304 }, 288 305 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 306 + return p.AvatarUrl(handle, "") 290 307 }, 291 308 "langColor": enry.GetColor, 292 309 "layoutSide": func() string { ··· 297 314 }, 298 315 299 316 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 317 + normalized := strings.ReplaceAll(s, ":", "_") 318 + normalized = strings.ReplaceAll(normalized, ".", "_") 319 + return normalized 302 320 }, 303 321 "sshFingerprint": func(pubKey string) string { 304 322 fp, err := crypto.SSHFingerprint(pubKey) ··· 310 328 } 311 329 } 312 330 313 - func (p *Pages) avatarUri(handle, size string) string { 331 + func (p *Pages) AvatarUrl(handle, size string) string { 314 332 handle = strings.TrimPrefix(handle, "@") 315 333 316 334 secret := p.avatar.SharedSecret ··· 325 343 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 344 } 327 345 328 - func icon(name string, classes []string) (template.HTML, error) { 346 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 329 347 iconPath := filepath.Join("static", "icons", name) 330 348 331 349 if filepath.Ext(name) == "" {
+5 -2
appview/pages/funcmap_test.go
··· 2 2 3 3 import ( 4 4 "html/template" 5 + "log/slog" 6 + "testing" 7 + 5 8 "tangled.org/core/appview/config" 6 9 "tangled.org/core/idresolver" 7 - "testing" 8 10 ) 9 11 10 12 func TestPages_funcMap(t *testing.T) { ··· 13 15 // Named input parameters for receiver constructor. 14 16 config *config.Config 15 17 res *idresolver.Resolver 18 + l *slog.Logger 16 19 want template.FuncMap 17 20 }{ 18 21 // TODO: Add test cases. 19 22 } 20 23 for _, tt := range tests { 21 24 t.Run(tt.name, func(t *testing.T) { 22 - p := NewPages(tt.config, tt.res) 25 + p := NewPages(tt.config, tt.res, tt.l) 23 26 got := p.funcMap() 24 27 // TODO: update the condition below to compare got with tt.want. 25 28 if true {
+156
appview/pages/legal/privacy.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+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 + }
+15 -17
appview/pages/markup/format.go
··· 1 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 16 } 15 17 16 - // ReadmeFilenames contains the list of common README filenames to search for, 17 - // in order of preference. Only includes well-supported formats. 18 - var ReadmeFilenames = []string{ 19 - "README.md", "readme.md", 20 - "README", 21 - "readme", 22 - "README.markdown", 23 - "readme.markdown", 24 - "README.txt", 25 - "readme.txt", 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 26 26 } 27 27 28 28 func GetFormat(filename string) Format { 29 - for format, extensions := range FileTypes { 30 - for _, extension := range extensions { 31 - if strings.HasSuffix(filename, extension) { 32 - return format 33 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 34 32 } 35 33 } 36 34 // default format
+38 -2
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 + "io/fs" 8 9 "net/url" 9 10 "path" 10 11 "strings" ··· 20 21 "github.com/yuin/goldmark/renderer/html" 21 22 "github.com/yuin/goldmark/text" 22 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 23 25 htmlparse "golang.org/x/net/html" 24 26 25 27 "tangled.org/core/api/tangled" 28 + textension "tangled.org/core/appview/pages/markup/extension" 26 29 "tangled.org/core/appview/pages/repoinfo" 27 30 ) 28 31 ··· 45 48 IsDev bool 46 49 RendererType RendererType 47 50 Sanitizer Sanitizer 51 + Files fs.FS 48 52 } 49 53 50 - func (rctx *RenderContext) RenderMarkdown(source string) string { 54 + func NewMarkdown() goldmark.Markdown { 51 55 md := goldmark.New( 52 56 goldmark.WithExtensions( 53 57 extension.GFM, ··· 62 66 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 67 ), 64 68 treeblood.MathML(), 69 + callout.CalloutExtention, 70 + textension.AtExt, 65 71 ), 66 72 goldmark.WithParserOptions( 67 73 parser.WithAutoHeadingID(), 68 74 ), 69 75 goldmark.WithRendererOptions(html.WithUnsafe()), 70 76 ) 77 + return md 78 + } 79 + 80 + func (rctx *RenderContext) RenderMarkdown(source string) string { 81 + md := NewMarkdown() 71 82 72 83 if rctx != nil { 73 84 var transformers []util.PrioritizedValue ··· 140 151 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 152 switch node.Type { 142 153 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 154 + switch node.Data { 155 + case "img", "source": 144 156 for i, attr := range node.Attr { 145 157 if attr.Key != "src" { 146 158 continue ··· 288 300 } 289 301 290 302 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 291 327 } 292 328 293 329 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 + 80 83 // centering content 81 84 policy.AllowElements("center") 82 85 ··· 113 116 } 114 117 policy.AllowNoAttrs().OnElements(mathElements...) 115 118 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 119 + 120 + // goldmark-callout 121 + policy.AllowAttrs("data-callout").OnElements("details") 116 122 117 123 return policy 118 124 }
+162 -71
appview/pages/pages.go
··· 15 15 "path/filepath" 16 16 "strings" 17 17 "sync" 18 + "time" 18 19 19 20 "tangled.org/core/api/tangled" 20 21 "tangled.org/core/appview/commitverify" ··· 38 39 "github.com/go-git/go-git/v5/plumbing/object" 39 40 ) 40 41 41 - //go:embed templates/* static 42 + //go:embed templates/* static legal 42 43 var Files embed.FS 43 44 44 45 type Pages struct { ··· 54 55 logger *slog.Logger 55 56 } 56 57 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 58 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 59 // initialized with safe defaults, can be overriden per use 59 60 rctx := &markup.RenderContext{ 60 61 IsDev: config.Core.Dev, 61 62 CamoUrl: config.Camo.Host, 62 63 CamoSecret: config.Camo.SharedSecret, 63 64 Sanitizer: markup.NewSanitizer(), 65 + Files: Files, 64 66 } 65 67 66 68 p := &Pages{ ··· 71 73 rctx: rctx, 72 74 resolver: res, 73 75 templateDir: "appview/pages", 74 - logger: slog.Default().With("component", "pages"), 76 + logger: logger, 75 77 } 76 78 77 79 if p.dev { ··· 220 222 221 223 type LoginParams struct { 222 224 ReturnUrl string 225 + ErrorCode string 223 226 } 224 227 225 228 func (p *Pages) Login(w io.Writer, params LoginParams) error { 226 229 return p.executePlain("user/login", w, params) 227 230 } 228 231 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 232 + type SignupParams struct { 233 + CloudflareSiteKey string 234 + } 235 + 236 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 237 + return p.executePlain("user/signup", w, params) 231 238 } 232 239 233 240 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 242 249 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 250 filename := "terms.md" 244 251 filePath := filepath.Join("legal", filename) 245 - markdownBytes, err := os.ReadFile(filePath) 252 + 253 + file, err := p.embedFS.Open(filePath) 254 + if err != nil { 255 + return fmt.Errorf("failed to read %s: %w", filename, err) 256 + } 257 + defer file.Close() 258 + 259 + markdownBytes, err := io.ReadAll(file) 246 260 if err != nil { 247 261 return fmt.Errorf("failed to read %s: %w", filename, err) 248 262 } ··· 263 277 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 278 filename := "privacy.md" 265 279 filePath := filepath.Join("legal", filename) 266 - markdownBytes, err := os.ReadFile(filePath) 280 + 281 + file, err := p.embedFS.Open(filePath) 282 + if err != nil { 283 + return fmt.Errorf("failed to read %s: %w", filename, err) 284 + } 285 + defer file.Close() 286 + 287 + markdownBytes, err := io.ReadAll(file) 267 288 if err != nil { 268 289 return fmt.Errorf("failed to read %s: %w", filename, err) 269 290 } ··· 276 297 return p.execute("legal/privacy", w, params) 277 298 } 278 299 300 + type BrandParams struct { 301 + LoggedInUser *oauth.User 302 + } 303 + 304 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 305 + return p.execute("brand/brand", w, params) 306 + } 307 + 279 308 type TimelineParams struct { 280 309 LoggedInUser *oauth.User 281 310 Timeline []models.TimelineEvent 282 311 Repos []models.Repo 312 + GfiLabel *models.LabelDefinition 283 313 } 284 314 285 315 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 316 return p.execute("timeline/timeline", w, params) 287 317 } 288 318 319 + type GoodFirstIssuesParams struct { 320 + LoggedInUser *oauth.User 321 + Issues []models.Issue 322 + RepoGroups []*models.RepoGroup 323 + LabelDefs map[string]*models.LabelDefinition 324 + GfiLabel *models.LabelDefinition 325 + Page pagination.Page 326 + } 327 + 328 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 329 + return p.execute("goodfirstissues/index", w, params) 330 + } 331 + 289 332 type UserProfileSettingsParams struct { 290 333 LoggedInUser *oauth.User 291 334 Tabs []map[string]any ··· 296 339 return p.execute("user/settings/profile", w, params) 297 340 } 298 341 342 + type NotificationsParams struct { 343 + LoggedInUser *oauth.User 344 + Notifications []*models.NotificationWithEntity 345 + UnreadCount int 346 + Page pagination.Page 347 + Total int64 348 + } 349 + 350 + func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 351 + return p.execute("notifications/list", w, params) 352 + } 353 + 354 + type NotificationItemParams struct { 355 + Notification *models.Notification 356 + } 357 + 358 + func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 359 + return p.executePlain("notifications/fragments/item", w, params) 360 + } 361 + 362 + type NotificationCountParams struct { 363 + Count int64 364 + } 365 + 366 + func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 367 + return p.executePlain("notifications/fragments/count", w, params) 368 + } 369 + 299 370 type UserKeysSettingsParams struct { 300 371 LoggedInUser *oauth.User 301 372 PubKeys []models.PublicKey ··· 316 387 317 388 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 318 389 return p.execute("user/settings/emails", w, params) 390 + } 391 + 392 + type UserNotificationSettingsParams struct { 393 + LoggedInUser *oauth.User 394 + Preferences *models.NotificationPreferences 395 + Tabs []map[string]any 396 + Tab string 397 + } 398 + 399 + func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 400 + return p.execute("user/settings/notifications", w, params) 319 401 } 320 402 321 403 type UpgradeBannerParams struct { ··· 484 566 485 567 type FollowCard struct { 486 568 UserDid string 569 + LoggedInUser *oauth.User 487 570 FollowStatus models.FollowStatus 488 571 FollowersCount int64 489 572 FollowingCount int64 ··· 557 640 return p.executePlain("repo/fragments/repoStar", w, params) 558 641 } 559 642 560 - type RepoDescriptionParams struct { 561 - RepoInfo repoinfo.RepoInfo 562 - } 563 - 564 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 565 - return p.executePlain("repo/fragments/editRepoDescription", w, params) 566 - } 567 - 568 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 569 - return p.executePlain("repo/fragments/repoDescription", w, params) 570 - } 571 - 572 643 type RepoIndexParams struct { 573 644 LoggedInUser *oauth.User 574 645 RepoInfo repoinfo.RepoInfo ··· 578 649 TagsTrunc []*types.TagReference 579 650 BranchesTrunc []types.Branch 580 651 // ForkInfo *types.ForkInfo 581 - HTMLReadme template.HTML 582 - Raw bool 583 - EmailToDidOrHandle map[string]string 584 - VerifiedCommits commitverify.VerifiedCommits 585 - Languages []types.RepoLanguageDetails 586 - Pipelines map[string]models.Pipeline 587 - NeedsKnotUpgrade bool 652 + HTMLReadme template.HTML 653 + Raw bool 654 + EmailToDid map[string]string 655 + VerifiedCommits commitverify.VerifiedCommits 656 + Languages []types.RepoLanguageDetails 657 + Pipelines map[string]models.Pipeline 658 + NeedsKnotUpgrade bool 588 659 types.RepoIndexResponse 589 660 } 590 661 ··· 619 690 } 620 691 621 692 type RepoLogParams struct { 622 - LoggedInUser *oauth.User 623 - RepoInfo repoinfo.RepoInfo 624 - TagMap map[string][]string 693 + LoggedInUser *oauth.User 694 + RepoInfo repoinfo.RepoInfo 695 + TagMap map[string][]string 696 + Active string 697 + EmailToDid map[string]string 698 + VerifiedCommits commitverify.VerifiedCommits 699 + Pipelines map[string]models.Pipeline 700 + 625 701 types.RepoLogResponse 626 - Active string 627 - EmailToDidOrHandle map[string]string 628 - VerifiedCommits commitverify.VerifiedCommits 629 - Pipelines map[string]models.Pipeline 630 702 } 631 703 632 704 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 635 707 } 636 708 637 709 type RepoCommitParams struct { 638 - LoggedInUser *oauth.User 639 - RepoInfo repoinfo.RepoInfo 640 - Active string 641 - EmailToDidOrHandle map[string]string 642 - Pipeline *models.Pipeline 643 - DiffOpts types.DiffOpts 710 + LoggedInUser *oauth.User 711 + RepoInfo repoinfo.RepoInfo 712 + Active string 713 + EmailToDid map[string]string 714 + Pipeline *models.Pipeline 715 + DiffOpts types.DiffOpts 644 716 645 717 // singular because it's always going to be just one 646 718 VerifiedCommit commitverify.VerifiedCommits ··· 654 726 } 655 727 656 728 type RepoTreeParams struct { 657 - LoggedInUser *oauth.User 658 - RepoInfo repoinfo.RepoInfo 659 - Active string 660 - BreadCrumbs [][]string 661 - TreePath string 662 - Readme string 663 - ReadmeFileName string 664 - HTMLReadme template.HTML 665 - Raw bool 729 + LoggedInUser *oauth.User 730 + RepoInfo repoinfo.RepoInfo 731 + Active string 732 + BreadCrumbs [][]string 733 + TreePath string 734 + Raw bool 735 + HTMLReadme template.HTML 666 736 types.RepoTreeResponse 667 737 } 668 738 ··· 690 760 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 761 params.Active = "overview" 692 762 693 - if params.ReadmeFileName != "" { 694 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 763 + p.rctx.RepoInfo = params.RepoInfo 764 + p.rctx.RepoInfo.Ref = params.Ref 765 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 695 766 767 + if params.ReadmeFileName != "" { 696 768 ext := filepath.Ext(params.ReadmeFileName) 697 769 switch ext { 698 770 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 889 961 LabelDefs map[string]*models.LabelDefinition 890 962 Page pagination.Page 891 963 FilteringByOpen bool 964 + FilterQuery string 892 965 } 893 966 894 967 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 905 978 LabelDefs map[string]*models.LabelDefinition 906 979 907 980 OrderedReactionKinds []models.ReactionKind 908 - Reactions map[models.ReactionKind]int 981 + Reactions map[models.ReactionKind]models.ReactionDisplayData 909 982 UserReacted map[models.ReactionKind]bool 910 983 } 911 984 ··· 930 1003 ThreadAt syntax.ATURI 931 1004 Kind models.ReactionKind 932 1005 Count int 1006 + Users []string 933 1007 IsReacted bool 934 1008 } 935 1009 ··· 1018 1092 Pulls []*models.Pull 1019 1093 Active string 1020 1094 FilteringBy models.PullState 1095 + FilterQuery string 1021 1096 Stacks map[string]models.Stack 1022 1097 Pipelines map[string]models.Pipeline 1098 + LabelDefs map[string]*models.LabelDefinition 1023 1099 } 1024 1100 1025 1101 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1046 1122 } 1047 1123 1048 1124 type RepoSinglePullParams struct { 1049 - LoggedInUser *oauth.User 1050 - RepoInfo repoinfo.RepoInfo 1051 - Active string 1052 - Pull *models.Pull 1053 - Stack models.Stack 1054 - AbandonedPulls []*models.Pull 1055 - MergeCheck types.MergeCheckResponse 1056 - ResubmitCheck ResubmitResult 1057 - Pipelines map[string]models.Pipeline 1125 + LoggedInUser *oauth.User 1126 + RepoInfo repoinfo.RepoInfo 1127 + Active string 1128 + Pull *models.Pull 1129 + Stack models.Stack 1130 + AbandonedPulls []*models.Pull 1131 + BranchDeleteStatus *models.BranchDeleteStatus 1132 + MergeCheck types.MergeCheckResponse 1133 + ResubmitCheck ResubmitResult 1134 + Pipelines map[string]models.Pipeline 1058 1135 1059 1136 OrderedReactionKinds []models.ReactionKind 1060 - Reactions map[models.ReactionKind]int 1137 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1061 1138 UserReacted map[models.ReactionKind]bool 1139 + 1140 + LabelDefs map[string]*models.LabelDefinition 1062 1141 } 1063 1142 1064 1143 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1148 1227 } 1149 1228 1150 1229 type PullActionsParams struct { 1151 - LoggedInUser *oauth.User 1152 - RepoInfo repoinfo.RepoInfo 1153 - Pull *models.Pull 1154 - RoundNumber int 1155 - MergeCheck types.MergeCheckResponse 1156 - ResubmitCheck ResubmitResult 1157 - Stack models.Stack 1230 + LoggedInUser *oauth.User 1231 + RepoInfo repoinfo.RepoInfo 1232 + Pull *models.Pull 1233 + RoundNumber int 1234 + MergeCheck types.MergeCheckResponse 1235 + ResubmitCheck ResubmitResult 1236 + BranchDeleteStatus *models.BranchDeleteStatus 1237 + Stack models.Stack 1158 1238 } 1159 1239 1160 1240 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1270 1350 Name string 1271 1351 Command string 1272 1352 Collapsed bool 1353 + StartTime time.Time 1273 1354 } 1274 1355 1275 1356 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1276 1357 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1277 1358 } 1278 1359 1360 + type LogBlockEndParams struct { 1361 + Id int 1362 + StartTime time.Time 1363 + EndTime time.Time 1364 + } 1365 + 1366 + func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1367 + return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1368 + } 1369 + 1279 1370 type LogLineParams struct { 1280 1371 Id int 1281 1372 Content string ··· 1391 1482 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1392 1483 } 1393 1484 1394 - sub, err := fs.Sub(Files, "static") 1485 + sub, err := fs.Sub(p.embedFS, "static") 1395 1486 if err != nil { 1396 1487 p.logger.Error("no static dir found? that's crazy", "err", err) 1397 1488 panic(err) ··· 1414 1505 }) 1415 1506 } 1416 1507 1417 - func CssContentHash() string { 1418 - cssFile, err := Files.Open("static/tw.css") 1508 + func (p *Pages) CssContentHash() string { 1509 + cssFile, err := p.embedFS.Open("static/tw.css") 1419 1510 if err != nil { 1420 1511 slog.Debug("Error opening CSS file", "err", err) 1421 1512 return ""
+7 -7
appview/pages/repoinfo/repoinfo.go
··· 1 1 package repoinfo 2 2 3 3 import ( 4 - "fmt" 5 4 "path" 6 5 "slices" 7 - "strings" 8 6 9 7 "github.com/bluesky-social/indigo/atproto/syntax" 10 8 "tangled.org/core/appview/models" 11 9 "tangled.org/core/appview/state/userutil" 12 10 ) 13 11 14 - func (r RepoInfo) OwnerWithAt() string { 12 + func (r RepoInfo) Owner() string { 15 13 if r.OwnerHandle != "" { 16 - return fmt.Sprintf("@%s", r.OwnerHandle) 14 + return r.OwnerHandle 17 15 } else { 18 16 return r.OwnerDid 19 17 } 20 18 } 21 19 22 20 func (r RepoInfo) FullName() string { 23 - return path.Join(r.OwnerWithAt(), r.Name) 21 + return path.Join(r.Owner(), r.Name) 24 22 } 25 23 26 24 func (r RepoInfo) OwnerWithoutAt() string { 27 - if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 - return after 25 + if r.OwnerHandle != "" { 26 + return r.OwnerHandle 29 27 } else { 30 28 return userutil.FlattenDid(r.OwnerDid) 31 29 } ··· 56 54 OwnerDid string 57 55 OwnerHandle string 58 56 Description string 57 + Website string 58 + Topics []string 59 59 Knot string 60 60 Spindle string 61 61 RepoAt syntax.ATURI
+224
appview/pages/templates/brand/brand.html
··· 1 + {{ define "title" }}brand{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Assets and guidelines for using Tangled's logo and brand elements. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="space-y-16"> 14 + 15 + <!-- Introduction Section --> 16 + <section> 17 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 + Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 + follow the below guidelines when using Dolly and the logotype. 20 + </p> 21 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 + All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 + </p> 24 + </section> 25 + 26 + <!-- Black Logotype Section --> 27 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 28 + <div class="order-2 lg:order-1"> 29 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 30 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 31 + alt="Tangled logo - black version" 32 + class="w-full max-w-sm mx-auto" /> 33 + </div> 34 + </div> 35 + <div class="order-1 lg:order-2"> 36 + <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 38 + <p class="text-gray-700 dark:text-gray-300"> 39 + This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 + backgrounds and designs. 41 + </p> 42 + </div> 43 + </section> 44 + 45 + <!-- White Logotype Section --> 46 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 47 + <div class="order-2 lg:order-1"> 48 + <div class="bg-black p-8 sm:p-16 rounded"> 49 + <img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg" 50 + alt="Tangled logo - white version" 51 + class="w-full max-w-sm mx-auto" /> 52 + </div> 53 + </div> 54 + <div class="order-1 lg:order-2"> 55 + <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 57 + <p class="text-gray-700 dark:text-gray-300"> 58 + This version features white text and elements, ideal for dark backgrounds 59 + and inverted designs. 60 + </p> 61 + </div> 62 + </section> 63 + 64 + <!-- Mark Only Section --> 65 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 66 + <div class="order-2 lg:order-1"> 67 + <div class="grid grid-cols-2 gap-2"> 68 + <!-- Black mark on light background --> 69 + <div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded"> 70 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 71 + alt="Dolly face - black version" 72 + class="w-full max-w-16 mx-auto" /> 73 + </div> 74 + <!-- White mark on dark background --> 75 + <div class="bg-black p-8 sm:p-12 rounded"> 76 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 77 + alt="Dolly face - white version" 78 + class="w-full max-w-16 mx-auto" /> 79 + </div> 80 + </div> 81 + </div> 82 + <div class="order-1 lg:order-2"> 83 + <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 85 + When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 + </p> 87 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 88 + <strong class="font-semibold">Note</strong>: for situations where the background 89 + is unknown, use the black version for ideal contrast in most environments. 90 + </p> 91 + </div> 92 + </section> 93 + 94 + <!-- Colored Backgrounds Section --> 95 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 96 + <div class="order-2 lg:order-1"> 97 + <div class="grid grid-cols-2 gap-2"> 98 + <!-- Pastel Green background --> 99 + <div class="bg-green-500 p-8 sm:p-12 rounded"> 100 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 101 + alt="Tangled logo on pastel green background" 102 + class="w-full max-w-16 mx-auto" /> 103 + </div> 104 + <!-- Pastel Blue background --> 105 + <div class="bg-blue-500 p-8 sm:p-12 rounded"> 106 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 107 + alt="Tangled logo on pastel blue background" 108 + class="w-full max-w-16 mx-auto" /> 109 + </div> 110 + <!-- Pastel Yellow background --> 111 + <div class="bg-yellow-500 p-8 sm:p-12 rounded"> 112 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 113 + alt="Tangled logo on pastel yellow background" 114 + class="w-full max-w-16 mx-auto" /> 115 + </div> 116 + <!-- Pastel Red background --> 117 + <div class="bg-red-500 p-8 sm:p-12 rounded"> 118 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 119 + alt="Tangled logo on pastel red background" 120 + class="w-full max-w-16 mx-auto" /> 121 + </div> 122 + </div> 123 + </div> 124 + <div class="order-1 lg:order-2"> 125 + <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 127 + White logo mark on colored backgrounds. 128 + </p> 129 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 130 + The white logo mark provides contrast on colored backgrounds. 131 + Perfect for more fun design contexts. 132 + </p> 133 + </div> 134 + </section> 135 + 136 + <!-- Black Logo on Pastel Backgrounds Section --> 137 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 138 + <div class="order-2 lg:order-1"> 139 + <div class="grid grid-cols-2 gap-2"> 140 + <!-- Pastel Green background --> 141 + <div class="bg-green-200 p-8 sm:p-12 rounded"> 142 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 143 + alt="Tangled logo on pastel green background" 144 + class="w-full max-w-16 mx-auto" /> 145 + </div> 146 + <!-- Pastel Blue background --> 147 + <div class="bg-blue-200 p-8 sm:p-12 rounded"> 148 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 149 + alt="Tangled logo on pastel blue background" 150 + class="w-full max-w-16 mx-auto" /> 151 + </div> 152 + <!-- Pastel Yellow background --> 153 + <div class="bg-yellow-200 p-8 sm:p-12 rounded"> 154 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 155 + alt="Tangled logo on pastel yellow background" 156 + class="w-full max-w-16 mx-auto" /> 157 + </div> 158 + <!-- Pastel Pink background --> 159 + <div class="bg-pink-200 p-8 sm:p-12 rounded"> 160 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 161 + alt="Tangled logo on pastel pink background" 162 + class="w-full max-w-16 mx-auto" /> 163 + </div> 164 + </div> 165 + </div> 166 + <div class="order-1 lg:order-2"> 167 + <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 169 + Dark logo mark on lighter, pastel backgrounds. 170 + </p> 171 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 172 + The dark logo mark works beautifully on pastel backgrounds, 173 + providing crisp contrast. 174 + </p> 175 + </div> 176 + </section> 177 + 178 + <!-- Recoloring Section --> 179 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 180 + <div class="order-2 lg:order-1"> 181 + <div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded"> 182 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 183 + alt="Recolored Tangled logotype in gray/sand color" 184 + class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" /> 185 + </div> 186 + </div> 187 + <div class="order-1 lg:order-2"> 188 + <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 190 + Custom coloring of the logotype is permitted. 191 + </p> 192 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 193 + Recoloring the logotype is allowed as long as readability is maintained. 194 + </p> 195 + <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 + <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 + </p> 198 + </div> 199 + </section> 200 + 201 + <!-- Silhouette Section --> 202 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 + <div class="order-2 lg:order-1"> 204 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 + <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 + alt="Dolly silhouette" 207 + class="w-full max-w-32 mx-auto" /> 208 + </div> 209 + </div> 210 + <div class="order-1 lg:order-2"> 211 + <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 + <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 + <p class="text-gray-700 dark:text-gray-300"> 214 + The silhouette can be used where a subtle brand presence is needed, 215 + or as a background element. Works on any background color with proper contrast. 216 + For example, we use this as the site's favicon. 217 + </p> 218 + </div> 219 + </section> 220 + 221 + </div> 222 + </main> 223 + </div> 224 + {{ end }}
+4 -11
appview/pages/templates/errors/500.html
··· 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 28 </div>
+82 -54
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.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> 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> 56 84 {{ end }}
+60 -22
appview/pages/templates/fragments/dolly/silhouette.html
··· 2 2 <svg 3 3 version="1.1" 4 4 id="svg1" 5 - width="32" 6 - height="32" 5 + width="25" 6 + height="25" 7 7 viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_silhouette.png" 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)" 9 13 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 14 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 15 xmlns="http://www.w3.org/2000/svg" 12 - xmlns:svg="http://www.w3.org/2000/svg"> 13 - <style> 14 - .dolly { 15 - color: #000000; 16 - } 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 + } 17 23 18 - @media (prefers-color-scheme: dark) { 19 - .dolly { 20 - color: #ffffff; 21 - } 22 - } 23 - </style> 24 - <title>Dolly</title> 25 - <defs 26 - id="defs1" /> 24 + @media (prefers-color-scheme: dark) { 25 + .dolly { 26 + color: #ffffff; 27 + } 28 + } 29 + </style> 27 30 <sodipodi:namedview 28 31 id="namedview1" 29 32 pagecolor="#ffffff" ··· 32 35 inkscape:showpageshadow="2" 33 36 inkscape:pageopacity="0.0" 34 37 inkscape:pagecheckerboard="true" 35 - inkscape:deskcolor="#d1d1d1"> 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"> 36 49 <inkscape:page 37 50 x="0" 38 51 y="0" ··· 45 58 <g 46 59 inkscape:groupmode="layer" 47 60 inkscape:label="Image" 48 - id="g1"> 61 + id="g1" 62 + transform="translate(-0.42924038,-0.87777209)"> 49 63 <path 50 64 class="dolly" 51 65 fill="currentColor" 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" /> 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" /> 55 70 </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> 56 94 </svg> 57 95 {{ 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 }}
+17 -9
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 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"> 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"> 17 19 {{ block "addKnotMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 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> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button" ··· 54 62 </div> 55 63 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 64 </form> 57 - {{ end }} 65 + {{ 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="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="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"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+17 -12
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> 12 13 13 14 <!-- preconnect to image cdn --> 14 15 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 16 <link rel="preconnect" href="https://camo.tangled.sh" /> 17 + 18 + <!-- pwa manifest --> 19 + <link rel="manifest" href="/pwa-manifest.json" /> 16 20 17 21 <!-- preload main font --> 18 22 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> ··· 21 25 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 26 {{ block "extrameta" . }}{{ end }} 23 27 </head> 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);"> 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"> 26 29 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 31 29 32 {{ if .LoggedInUser }} 30 33 <div id="upgrade-banner" ··· 38 41 {{ end }} 39 42 40 43 {{ block "mainLayout" . }} 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"> 44 + <div class="flex-grow"> 45 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 46 + {{ block "contentLayout" . }} 47 + <main> 44 48 {{ block "content" . }}{{ end }} 45 49 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 + {{ end }} 51 + 52 + {{ block "contentAfterLayout" . }} 53 + <main> 50 54 {{ block "contentAfter" . }}{{ end }} 51 55 </main> 52 - {{ end }} 56 + {{ end }} 57 + </div> 53 58 </div> 54 59 {{ end }} 55 60 56 61 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 62 + <footer class="mt-12"> 58 63 {{ template "layouts/fragments/footer" . }} 59 64 </footer> 60 65 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 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> 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> 10 13 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> 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> 19 46 </div> 20 47 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> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 26 51 </div> 52 + </div> 27 53 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 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> 33 64 </div> 34 65 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 38 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 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> 39 93 </div> 40 - </div> 41 94 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 44 98 </div> 45 99 </div> 46 100 </div>
+22 -16
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 5 + <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 + <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 + alpha 10 + </span> 7 11 </a> 8 12 </div> 9 13 10 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 11 15 {{ with .LoggedInUser }} 12 16 {{ block "newButton" . }} {{ end }} 13 - {{ block "dropDown" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 18 + {{ block "profileDropdown" . }} {{ end }} 14 19 {{ else }} 15 20 <a href="/login">login</a> 16 21 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 26 31 {{ define "newButton" }} 27 32 <details class="relative inline-block text-left nav-dropdown"> 28 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 30 35 </summary> 31 - <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"> 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"> 32 37 <a href="/repo/new" class="flex items-center gap-2"> 33 38 {{ i "book-plus" "w-4 h-4" }} 34 39 new repository ··· 41 46 </details> 42 47 {{ end }} 43 48 44 - {{ define "dropDown" }} 49 + {{ define "profileDropdown" }} 45 50 <details class="relative inline-block text-left nav-dropdown"> 46 - <summary 47 - class="cursor-pointer list-none flex items-center" 48 - > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 51 + <summary class="cursor-pointer list-none flex items-center gap-1"> 52 + {{ $user := .Did }} 53 + <img 54 + src="{{ tinyAvatar $user }}" 55 + alt="" 56 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 57 + /> 58 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 51 59 </summary> 52 - <div 53 - 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" 54 - > 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"> 55 61 <a href="/{{ $user }}">profile</a> 56 62 <a href="/{{ $user }}?tab=repos">repositories</a> 57 63 <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 }} 4 5 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 6 <meta property="og:type" content="profile" /> 6 7 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 8 <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 }}" /> 8 17 {{ end }} 9 18 10 19 {{ define "content" }}
+53 -25
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 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> 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> 20 49 </div> 21 50 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> 51 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 29 52 {{ template "repo/fragments/repoStar" .RepoInfo }} 30 53 <a 31 54 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" ··· 36 59 fork 37 60 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 61 </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> 39 68 </div> 40 69 </div> 41 - {{ template "repo/fragments/repoDescription" . }} 42 70 </section> 43 71 44 72 <section class="w-full flex flex-col" > ··· 79 107 </div> 80 108 </nav> 81 109 {{ block "repoContentLayout" . }} 82 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 110 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 83 111 {{ block "repoContent" . }}{{ end }} 84 112 </section> 85 113 {{ block "repoAfter" . }}{{ end }}
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }}privacy policy{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
··· 1 + {{define "notifications/fragments/bell"}} 2 + <div class="relative" 3 + hx-get="/notifications/count" 4 + hx-target="#notification-count" 5 + hx-trigger="load, every 30s"> 6 + <a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group"> 7 + {{ i "bell" "w-5 h-5" }} 8 + <span id="notification-count"></span> 9 + </a> 10 + </div> 11 + {{end}}
+7
appview/pages/templates/notifications/fragments/count.html
··· 1 + {{define "notifications/fragments/count"}} 2 + {{if and .Count (gt .Count 0)}} 3 + <span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"> 4 + {{if gt .Count 99}}99+{{else}}{{.Count}}{{end}} 5 + </span> 6 + {{end}} 7 + {{end}}
+90
appview/pages/templates/notifications/fragments/item.html
··· 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> 17 + 18 + </div> 19 + </a> 20 + {{end}} 21 + 22 + {{ define "notificationIcon" }} 23 + <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 24 + <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"> 26 + {{ i .Icon "size-3 text-black dark:text-white" }} 27 + </div> 28 + </div> 29 + {{ end }} 30 + 31 + {{ define "notificationHeader" }} 32 + {{ $actor := resolve .ActorDid }} 33 + 34 + <span class="text-black dark:text-white w-fit">{{ $actor }}</span> 35 + {{ if eq .Type "repo_starred" }} 36 + starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span> 37 + {{ else if eq .Type "issue_created" }} 38 + opened an issue 39 + {{ else if eq .Type "issue_commented" }} 40 + commented on an issue 41 + {{ else if eq .Type "issue_closed" }} 42 + closed an issue 43 + {{ else if eq .Type "issue_reopen" }} 44 + reopened an issue 45 + {{ else if eq .Type "pull_created" }} 46 + created a pull request 47 + {{ else if eq .Type "pull_commented" }} 48 + commented on a pull request 49 + {{ else if eq .Type "pull_merged" }} 50 + merged a pull request 51 + {{ else if eq .Type "pull_closed" }} 52 + closed a pull request 53 + {{ else if eq .Type "pull_reopen" }} 54 + reopened a pull request 55 + {{ else if eq .Type "followed" }} 56 + followed you 57 + {{ else if eq .Type "user_mentioned" }} 58 + mentioned you 59 + {{ else }} 60 + {{ end }} 61 + {{ end }} 62 + 63 + {{ define "notificationSummary" }} 64 + {{ if eq .Type "repo_starred" }} 65 + <!-- no summary --> 66 + {{ else if .Issue }} 67 + #{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 68 + {{ else if .Pull }} 69 + #{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 70 + {{ else if eq .Type "followed" }} 71 + <!-- no summary --> 72 + {{ else }} 73 + {{ end }} 74 + {{ end }} 75 + 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 }} 88 + 89 + {{ $url }} 90 + {{ end }}
+65
appview/pages/templates/notifications/list.html
··· 1 + {{ define "title" }}notifications{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex items-center justify-between"> 6 + <p class="text-xl font-bold dark:text-white">Notifications</p> 7 + <a href="/settings/notifications" class="flex items-center gap-2"> 8 + {{ i "settings" "w-4 h-4" }} 9 + preferences 10 + </a> 11 + </div> 12 + </div> 13 + 14 + {{if .Notifications}} 15 + <div class="flex flex-col gap-2" id="notifications-list"> 16 + {{range .Notifications}} 17 + {{template "notifications/fragments/item" .}} 18 + {{end}} 19 + </div> 20 + 21 + {{else}} 22 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 + <div class="text-center py-12"> 24 + <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 + {{ i "bell-off" "w-16 h-16" }} 26 + </div> 27 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 + <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 29 + </div> 30 + </div> 31 + {{end}} 32 + 33 + {{ template "pagination" . }} 34 + {{ end }} 35 + 36 + {{ define "pagination" }} 37 + <div class="flex justify-end mt-4 gap-2"> 38 + {{ if gt .Page.Offset 0 }} 39 + {{ $prev := .Page.Previous }} 40 + <a 41 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 + hx-boost="true" 43 + href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 + > 45 + {{ i "chevron-left" "w-4 h-4" }} 46 + previous 47 + </a> 48 + {{ else }} 49 + <div></div> 50 + {{ end }} 51 + 52 + {{ $next := .Page.Next }} 53 + {{ if lt $next.Offset .Total }} 54 + {{ $next := .Page.Next }} 55 + <a 56 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 + hx-boost="true" 58 + href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 + > 60 + next 61 + {{ i "chevron-right" "w-4 h-4" }} 62 + </a> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+14 -14
appview/pages/templates/repo/commit.html
··· 24 24 </div> 25 25 </div> 26 26 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 }} 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 }} 30 30 31 - {{ if $didOrHandle }} 32 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 31 + {{ if $did }} 32 + {{ template "user/fragments/picHandleLink" $did }} 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 + 36 37 <span class="px-1 select-none before:content-['\00B7']"></span> 37 38 {{ template "repo/fragments/time" $commit.Author.When }} 38 39 <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"> 42 41 <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 - {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 61 + {{ $committerDid := index $.EmailToDid $commit.Committer.Email }} 62 + {{ template "user/fragments/picHandleLink" $committerDid }} 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="px-1 col-span-full" style="z-index: 20;"> 83 + <header class="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 col-span-full flex flex-col gap-4"> 89 + <div class="px-1 flex-grow 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="px-1 col-span-full mt-12"> 108 + <footer class="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 }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .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 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <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.sh" }} 4 + {{ $knot = "tangled.org" }} 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/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .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 }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 51 + data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot | stripPort }}:{{ .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"
+20 -18
appview/pages/templates/repo/fragments/diffOpts.html
··· 5 5 {{ if .Split }} 6 6 {{ $active = "split" }} 7 7 {{ end }} 8 - {{ $values := list "unified" "split" }} 9 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ 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) }} 10 28 </section> 11 29 {{ end }} 12 30 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"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+9 -1
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 - 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 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 }}" /> 11 19 {{ 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 }}
+6 -1
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 5 + leading-4 px-3 gap-1 relative group 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 }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 1 {{ define "repo/fragments/readme" }} 2 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 7 </div> 8 8 {{- end -}} 9 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
-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 }}
+3 -13
appview/pages/templates/repo/index.html
··· 222 222 class="mx-1 before:content-['·'] before:select-none" 223 223 ></span> 224 224 <span> 225 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 226 - <a 227 - href="{{ if $didOrHandle }} 228 - /{{ $didOrHandle }} 229 - {{ else }} 230 - mailto:{{ .Author.Email }} 231 - {{ end }}" 225 + {{ $did := index $.EmailToDid .Author.Email }} 226 + <a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 232 227 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 233 - >{{ if $didOrHandle }} 234 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 235 - {{ else }} 236 - {{ .Author.Name }} 237 - {{ end }}</a 238 - > 228 + >{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a> 239 229 </span> 240 230 <div class="inline-block px-1 select-none after:content-['·']"></div> 241 231 {{ 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" 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 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" 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 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 }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 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 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 143 148 </div> 144 149 {{ end }} 145 150 {{ 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 }}
+9 -35
appview/pages/templates/repo/issues/issue.html
··· 2 2 3 3 4 4 {{ define "extrameta" }} 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) }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 9 6 {{ end }} 10 7 11 8 {{ define "repoContentLayout" }} ··· 22 19 "Defs" $.LabelDefs 23 20 "Subject" $.Issue.AtUri 24 21 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 22 + {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 26 24 </div> 27 25 </div> 28 26 {{ end }} ··· 87 85 88 86 {{ define "editIssue" }} 89 87 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 88 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 91 89 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 90 hx-swap="innerHTML" 93 91 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 95 98 96 {{ define "deleteIssue" }} 99 97 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 98 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 101 99 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 100 hx-confirm="Are you sure you want to delete your issue?" 103 101 hx-swap="none"> ··· 110 108 <div class="flex items-center gap-2"> 111 109 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 110 {{ range $kind := .OrderedReactionKinds }} 111 + {{ $reactionData := index $.Reactions $kind }} 113 112 {{ 114 113 template "repo/fragments/reaction" 115 114 (dict 116 115 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 116 + "Count" $reactionData.Count 118 117 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 118 + "ThreadAt" $.Issue.AtUri 119 + "Users" $reactionData.Users) 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 }} 151 125 152 126 {{ define "repoAfter" }} 153 127 <div class="flex flex-col gap-4 mt-4">
+45 -76
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 12 - <div class="flex gap-4"> 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> 13 47 <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> 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 29 48 href="/{{ .RepoInfo.FullName }}/issues/new" 30 - class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 31 - > 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 + > 32 51 {{ i "circle-plus" "w-4 h-4" }} 33 52 <span>new</span> 34 - </a> 35 - </div> 36 - <div class="error" id="issues"></div> 53 + </a> 54 + </div> 55 + <div class="error" id="issues"></div> 37 56 {{ end }} 38 57 39 58 {{ define "repoAfter" }} 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 }} 59 + <div class="mt-2"> 60 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 61 </div> 93 62 {{ block "pagination" . }} {{ end }} 94 63 {{ end }} ··· 105 74 <a 106 75 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 107 76 hx-boost="true" 108 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 77 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 109 78 > 110 79 {{ i "chevron-left" "w-4 h-4" }} 111 80 previous ··· 119 88 <a 120 89 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 121 90 hx-boost="true" 122 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 91 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 123 92 > 124 93 next 125 94 {{ 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 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 - {{ if $didOrHandle }} 32 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 30 + {{ $did := index $.EmailToDid $commit.Author.Email }} 31 + {{ if $did }} 32 + {{ template "user/fragments/picHandleLink" $did }} 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 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 - <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 156 + {{ $did := index $.EmailToDid $commit.Author.Email }} 157 + <a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 159 + {{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 160 </a> 161 161 </span> 162 162 <div class="inline-block px-1 select-none after:content-['·']"></div>
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 6 13 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 14 + {{ end }} 19 15 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 29 21 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 41 52 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 71 64 </div> 72 - </form> 73 - </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 74 176 {{ end }}
+7 -6
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"> 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> 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> 11 7 </summary> 12 8 <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> 13 9 </details> 14 10 </div> 15 11 {{ 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 +
+15 -3
appview/pages/templates/repo/pipelines/pipelines.html
··· 12 12 {{ range .Pipelines }} 13 13 {{ block "pipeline" (list $ .) }} {{ end }} 14 14 {{ else }} 15 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 16 - No pipelines run for this repository. 17 - </p> 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> 18 30 {{ end }} 19 31 </div> 20 32 </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" }} 18 19 {{ end }} 19 20 20 21 {{ define "sidebar" }} ··· 58 59 hx-ext="ws" 59 60 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 60 61 <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> 61 67 </div> 62 68 </div> 63 69 {{ 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 }}
+81 -72
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 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 }} 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 }} 51 61 52 - {{ if and $isPullAuthor $isOpen $isLastRound }} 53 - {{ $disabled := "" }} 54 - {{ if $isUpToDate }} 55 - {{ $disabled = "disabled" }} 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" 56 74 {{ 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 }} 65 75 66 - hx-disabled-elt="#resubmitBtn" 67 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 76 + hx-disabled-elt="#resubmitBtn" 77 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 68 78 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 }} 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 }} 80 90 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 }} 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 }} 91 101 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> 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 }} 103 112 </div> 104 113 {{ end }} 105 114
+15 -11
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 .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 -}} 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 }} 54 56 </span> 55 57 {{ end }} 56 58 </span> ··· 66 68 <div class="flex items-center gap-2 mt-2"> 67 69 {{ template "repo/fragments/reactionsPopUp" . }} 68 70 {{ range $kind := . }} 71 + {{ $reactionData := index $.Reactions $kind }} 69 72 {{ 70 73 template "repo/fragments/reaction" 71 74 (dict 72 75 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 76 + "Count" $reactionData.Count 74 77 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 78 + "ThreadAt" $.Pull.AtUri 79 + "Users" $reactionData.Users) 76 80 }} 77 81 {{ end }} 78 82 </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 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -14
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 - 37 31 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 33 {{ block "contentLayout" . }} 40 34 {{ block "content" . }}{{ end }} 41 35 {{ end }} ··· 52 46 {{ end }} 53 47 </div> 54 48 {{ 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 - 62 49 63 50 {{ define "contentAfter" }} 64 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
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 - 43 37 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 45 39 {{ block "contentLayout" . }} 46 40 {{ block "content" . }}{{ end }} 47 41 {{ end }} ··· 57 51 </div> 58 52 {{ end }} 59 53 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 54 {{ end }} 67 55 68 56 {{ define "contentAfter" }}
+49 -20
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "extrameta" }} 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 }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 + {{ end }} 8 8 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 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> 10 27 {{ end }} 11 - 12 28 13 29 {{ define "repoContent" }} 14 30 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 55 {{ with $item }} 40 56 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 57 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 58 + <div class="flex flex-wrap gap-2 items-stretch"> 43 59 <!-- round number --> 44 60 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 61 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 62 </div> 47 63 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 64 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 65 <span class="gap-1 flex items-center"> 50 66 {{ $owner := resolve $.Pull.OwnerDid }} 51 67 {{ $re := "re" }} ··· 72 88 <span class="hidden md:inline">diff</span> 73 89 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 90 </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> 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> 99 + {{ end }} 83 100 <span id="interdiff-error-{{.RoundNumber}}"></span> 84 - {{ end }} 85 101 </div> 86 102 </summary> 87 103 ··· 146 162 147 163 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 164 {{ range $cidx, $c := .Comments }} 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"> 165 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 166 {{ if gt $cidx 0 }} 151 167 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 168 {{ end }} ··· 169 185 {{ end }} 170 186 171 187 {{ if $.LoggedInUser }} 172 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 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) }} 173 198 {{ else }} 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 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 177 206 </div> 178 207 {{ end }} 179 208 </div>
+59 -34
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 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> 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) }} 42 52 </div> 43 - <div class="error" id="pulls"></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> 44 62 {{ end }} 45 63 46 64 {{ define "repoAfter" }} ··· 108 126 <span class="before:content-['·']"></span> 109 127 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 128 {{ 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 }} 111 136 </div> 112 137 </div> 113 138 {{ if .StackId }} ··· 126 151 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 127 152 </div> 128 153 </summary> 129 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 154 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 130 155 </details> 131 156 {{ end }} 132 157 {{ end }} ··· 135 160 </div> 136 161 {{ end }} 137 162 138 - {{ define "pullList" }} 163 + {{ define "stackedPullList" }} 139 164 {{ $list := index . 0 }} 140 165 {{ $root := index . 1 }} 141 166 <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">
+17 -8
appview/pages/templates/repo/settings/access.html
··· 66 66 <div 67 67 id="add-collaborator-modal" 68 68 popover 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"> 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"> 70 73 {{ template "addCollaboratorModal" . }} 71 74 </div> 72 75 {{ end }} ··· 82 85 ADD COLLABORATOR 83 86 </label> 84 87 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 - <input 86 - type="text" 87 - id="add-collaborator" 88 - name="collaborator" 89 - required 90 - placeholder="@foo.bsky.social" 91 - /> 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> 92 101 <div class="flex gap-2 pt-2"> 93 102 <button 94 103 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" . }} 9 10 {{ template "branchSettings" . }} 10 11 {{ template "defaultLabelSettings" . }} 11 12 {{ template "customLabelSettings" . }} ··· 13 14 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 15 </div> 15 16 </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> 16 63 {{ end }} 17 64 18 65 {{ define "branchSettings" }}
+1 -1
appview/pages/templates/repo/tree.html
··· 91 91 92 92 {{ define "repoAfter" }} 93 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 94 + {{ template "repo/fragments/readme" . }} 95 95 {{- end -}} 96 96 {{ end }}
+16 -8
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 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"> 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"> 17 19 {{ block "addSpindleMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 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> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button"
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 5 {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 - <p class="">Store and share code snippets with ease.</p> 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+1 -1
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 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"> 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"> 51 51 <span> 52 52 {{ .String.Filename }} 53 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 26 {{ end }} 27 27 28 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 29 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 - <div class="font-medium dark:text-white flex gap-2 items-center"> 31 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 32 35 </div> 33 36 {{ with .Description }} 34 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 45 43 46 {{ define "stringCardInfo" }} 44 47 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 - <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 - {{ template "user/fragments/picHandle" $resolved }} 49 - </a> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 50 <span class="select-none [&:before]:content-['·']"></span> 53 51 {{ with .Edited }}
+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 new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 7 + Tangled is a decentralized Git hosting and collaboration platform. 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>
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 82 {{ $event := index . 1 }} 83 83 {{ $follow := $event.Follow }} 84 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 86 87 87 88 {{ $userHandle := resolve $follow.UserDid }} 88 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 95 </div> 95 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4"> 96 - <div class="flex items-center gap-4 flex-1"> 97 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 98 - <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 99 - </div> 100 - 101 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 102 - <a href="/{{ $subjectHandle }}"> 103 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 104 - </a> 105 - {{ with $profile }} 106 - {{ with .Description }} 107 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 108 - {{ end }} 109 - {{ end }} 110 - {{ with $stat }} 111 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 112 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 113 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 114 - <span class="select-none after:content-['·']"></span> 115 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 116 - </div> 117 - {{ end }} 118 - </div> 119 - </div> 120 - 121 - {{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }} 122 - <div class="flex-shrink-0 w-fit ml-auto"> 123 - {{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }} 124 - </div> 125 - {{ end }} 126 - </div> 96 + {{ template "user/fragments/followCard" 97 + (dict 98 + "LoggedInUser" $root.LoggedInUser 99 + "UserDid" $follow.SubjectDid 100 + "Profile" $profile 101 + "FollowStatus" $followStatus 102 + "FollowersCount" $followStats.Followers 103 + "FollowingCount" $followStats.Following) }} 127 104 {{ end }}
+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" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <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" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ 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" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 23 {{ end }}
+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"> 23 34 <label class="m-0 p-0" for="location">location</label> 24 35 <div class="flex items-center gap-2 w-full"> 25 36 {{ $location := "" }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }} 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 + {{ i "user-round-plus" "w-4 h-4" }} follow 17 + {{ else }} 18 + {{ i "user-round-minus" "w-4 h-4" }} unfollow 19 + {{ end }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+21 -18
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 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 }}" /> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['·']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 + <a href="/{{ $userIdent }}"> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 + </a> 14 + {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 + {{ end }} 17 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span class="select-none after:content-['·']"></span> 21 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 + </div> 19 23 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 + {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 25 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 24 26 {{ template "user/fragments/follow" . }} 25 27 </div> 26 - {{ end }} 28 + {{ end }} 29 + </div> 27 30 </div> 28 31 </div> 29 - {{ end }} 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+19 -6
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 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 16 20 </div> 17 21 18 22 <div class="md:hidden"> ··· 67 71 {{ end }} 68 72 </div> 69 73 {{ end }} 70 - {{ if ne .FollowStatus.String "IsSelf" }} 71 - {{ template "user/fragments/follow" . }} 72 - {{ else }} 74 + 75 + <div class="flex mt-2 items-center gap-2"> 76 + {{ if ne .FollowStatus.String "IsSelf" }} 77 + {{ template "user/fragments/follow" . }} 78 + {{ else }} 73 79 <button id="editBtn" 74 - class="btn mt-2 w-full flex items-center gap-2 group" 80 + class="btn w-full flex items-center gap-2 group" 75 81 hx-target="#profile-bio" 76 82 hx-get="/profile/edit-bio" 77 83 hx-swap="innerHTML"> ··· 79 85 edit 80 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 87 </button> 82 - {{ end }} 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 + 83 96 </div> 84 97 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 98 </div>
+10 -10
appview/pages/templates/user/fragments/repoCard.html
··· 14 14 {{ with $repo }} 15 15 <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 16 16 <div class="font-medium dark:text-white flex items-center justify-between"> 17 - <div class="flex items-center"> 18 - {{ if .Source }} 19 - {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 - {{ else }} 21 - {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 - {{ end }} 23 - 17 + <div class="flex items-center min-w-0 flex-1 mr-2"> 18 + {{ if .Source }} 19 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 + {{ else }} 21 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 + {{ end }} 24 23 {{ $repoOwner := resolve .Did }} 25 24 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 27 26 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 29 28 {{- end -}} 30 29 </div> 31 - 32 30 {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 33 32 {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 34 34 {{ end }} 35 35 </div> 36 36 {{ with .Description }}
+25 -3
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" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> 14 15 <body class="flex items-center justify-center min-h-screen"> 15 - <main class="max-w-md px-6 -mt-4"> 16 + <main class="max-w-md px-7 mt-4"> 16 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 17 18 {{ template "fragments/logotype" }} 18 19 </h1> ··· 20 21 tightly-knit social coding. 21 22 </h2> 22 23 <form 23 - class="mt-4 max-w-sm mx-auto" 24 + class="mt-4" 24 25 hx-post="/login" 25 26 hx-swap="none" 26 27 hx-disabled-elt="#login-button" ··· 28 29 <div class="flex flex-col"> 29 30 <label for="handle">handle</label> 30 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 31 35 type="text" 32 36 id="handle" 33 37 name="handle" ··· 36 40 placeholder="akshay.tngl.sh" 37 41 /> 38 42 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 43 + Use your <a href="https://atproto.com">AT Protocol</a> 40 44 handle to log in. If you're unsure, this is likely 41 45 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 46 </span> ··· 52 56 <span>login</span> 53 57 </button> 54 58 </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 }} 55 77 <p class="text-sm text-gray-500"> 56 78 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 57 79 </p>
+187
appview/pages/templates/user/settings/notifications.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 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 + <span class="font-bold">Email notifications</span> 162 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 163 + <span>Receive notifications via email in addition to in-app notifications.</span> 164 + </div> 165 + </div> 166 + </div> 167 + <label class="flex items-center gap-2"> 168 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 169 + </label> 170 + </div> 171 + </div> 172 + 173 + <div class="flex justify-end pt-2"> 174 + <button 175 + type="submit" 176 + class="btn-create flex items-center gap-2 group" 177 + > 178 + {{ i "save" "w-4 h-4" }} 179 + save 180 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 181 + </button> 182 + </div> 183 + <div id="settings-notifications-success"></div> 184 + 185 + <div id="settings-notifications-error" class="error"></div> 186 + </form> 187 + {{ end }}
+1 -3
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 }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+7 -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" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p>
+47 -1
appview/pagination/page.go
··· 1 1 package pagination 2 2 3 + import "context" 4 + 3 5 type Page struct { 4 6 Offset int // where to start from 5 7 Limit int // number of items in a page ··· 8 10 func FirstPage() Page { 9 11 return Page{ 10 12 Offset: 0, 11 - Limit: 10, 13 + Limit: 30, 12 14 } 13 15 } 14 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 + } 37 + 15 38 func (p Page) Previous() Page { 16 39 if p.Offset-p.Limit < 0 { 17 40 return FirstPage() ··· 29 52 Limit: p.Limit, 30 53 } 31 54 } 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 + }
+37 -17
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" 20 19 "tangled.org/core/rbac" 21 20 spindlemodel "tangled.org/core/spindle/models" 22 21 ··· 36 35 logger *slog.Logger 37 36 } 38 37 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 + 39 47 func New( 40 48 oauth *oauth.OAuth, 41 49 repoResolver *reporesolver.RepoResolver, ··· 45 53 db *db.DB, 46 54 config *config.Config, 47 55 enforcer *rbac.Enforcer, 56 + logger *slog.Logger, 48 57 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 - return &Pipelines{oauth: oauth, 58 + return &Pipelines{ 59 + oauth: oauth, 52 60 repoResolver: repoResolver, 53 61 pages: pages, 54 62 idResolver: idResolver, ··· 228 236 // start a goroutine to read from spindle 229 237 go readLogs(spindleConn, evChan) 230 238 231 - stepIdx := 0 239 + stepStartTimes := make(map[int]time.Time) 232 240 var fragment bytes.Buffer 233 241 for { 234 242 select { ··· 260 268 261 269 switch logLine.Kind { 262 270 case spindlemodel.LogKindControl: 263 - // control messages create a new step block 264 - stepIdx++ 265 - collapsed := false 266 - if logLine.StepKind == spindlemodel.StepKindSystem { 267 - collapsed = true 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 + }) 268 293 } 269 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 270 - Id: stepIdx, 271 - Name: logLine.Content, 272 - Command: logLine.StepCommand, 273 - Collapsed: collapsed, 274 - }) 294 + 275 295 case spindlemodel.LogKindData: 276 296 // data messages simply insert new log lines into current step 277 297 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 278 - Id: stepIdx, 298 + Id: logLine.StepId, 279 299 Content: logLine.Content, 280 300 }) 281 301 }
-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 - }
-164
appview/posthog/notifier.go
··· 1 - package posthog_service 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - "github.com/posthog/posthog-go" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/notify" 10 - ) 11 - 12 - type posthogNotifier struct { 13 - client posthog.Client 14 - notify.BaseNotifier 15 - } 16 - 17 - func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 - return &posthogNotifier{ 19 - client, 20 - notify.BaseNotifier{}, 21 - } 22 - } 23 - 24 - var _ notify.Notifier = &posthogNotifier{} 25 - 26 - func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 - err := n.client.Enqueue(posthog.Capture{ 28 - DistinctId: repo.Did, 29 - Event: "new_repo", 30 - Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 - }) 32 - if err != nil { 33 - log.Println("failed to enqueue posthog event:", err) 34 - } 35 - } 36 - 37 - func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 - err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 - Event: "star", 41 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 - }) 43 - if err != nil { 44 - log.Println("failed to enqueue posthog event:", err) 45 - } 46 - } 47 - 48 - func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 - err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 - Event: "unstar", 52 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 - }) 54 - if err != nil { 55 - log.Println("failed to enqueue posthog event:", err) 56 - } 57 - } 58 - 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 - err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.Did, 62 - Event: "new_issue", 63 - Properties: posthog.Properties{ 64 - "repo_at": issue.RepoAt.String(), 65 - "issue_id": issue.IssueId, 66 - }, 67 - }) 68 - if err != nil { 69 - log.Println("failed to enqueue posthog event:", err) 70 - } 71 - } 72 - 73 - func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 - err := n.client.Enqueue(posthog.Capture{ 75 - DistinctId: pull.OwnerDid, 76 - Event: "new_pull", 77 - Properties: posthog.Properties{ 78 - "repo_at": pull.RepoAt, 79 - "pull_id": pull.PullId, 80 - }, 81 - }) 82 - if err != nil { 83 - log.Println("failed to enqueue posthog event:", err) 84 - } 85 - } 86 - 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 - err := n.client.Enqueue(posthog.Capture{ 89 - DistinctId: comment.OwnerDid, 90 - Event: "new_pull_comment", 91 - Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 - }, 95 - }) 96 - if err != nil { 97 - log.Println("failed to enqueue posthog event:", err) 98 - } 99 - } 100 - 101 - func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 102 - err := n.client.Enqueue(posthog.Capture{ 103 - DistinctId: follow.UserDid, 104 - Event: "follow", 105 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 - }) 107 - if err != nil { 108 - log.Println("failed to enqueue posthog event:", err) 109 - } 110 - } 111 - 112 - func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 113 - err := n.client.Enqueue(posthog.Capture{ 114 - DistinctId: follow.UserDid, 115 - Event: "unfollow", 116 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 - }) 118 - if err != nil { 119 - log.Println("failed to enqueue posthog event:", err) 120 - } 121 - } 122 - 123 - func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 124 - err := n.client.Enqueue(posthog.Capture{ 125 - DistinctId: profile.Did, 126 - Event: "edit_profile", 127 - }) 128 - if err != nil { 129 - log.Println("failed to enqueue posthog event:", err) 130 - } 131 - } 132 - 133 - func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 - err := n.client.Enqueue(posthog.Capture{ 135 - DistinctId: did, 136 - Event: "delete_string", 137 - Properties: posthog.Properties{"rkey": rkey}, 138 - }) 139 - if err != nil { 140 - log.Println("failed to enqueue posthog event:", err) 141 - } 142 - } 143 - 144 - func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 145 - err := n.client.Enqueue(posthog.Capture{ 146 - DistinctId: string.Did.String(), 147 - Event: "edit_string", 148 - Properties: posthog.Properties{"rkey": string.Rkey}, 149 - }) 150 - if err != nil { 151 - log.Println("failed to enqueue posthog event:", err) 152 - } 153 - } 154 - 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) { 156 - err := n.client.Enqueue(posthog.Capture{ 157 - DistinctId: string.Did.String(), 158 - Event: "create_string", 159 - Properties: posthog.Properties{"rkey": string.Rkey}, 160 - }) 161 - if err != nil { 162 - log.Println("failed to enqueue posthog event:", err) 163 - } 164 - }
+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 + }
+289 -194
appview/pulls/pulls.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "log" 9 + "log/slog" 9 10 "net/http" 11 + "slices" 10 12 "sort" 11 13 "strconv" 12 14 "strings" ··· 15 17 "tangled.org/core/api/tangled" 16 18 "tangled.org/core/appview/config" 17 19 "tangled.org/core/appview/db" 20 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 18 21 "tangled.org/core/appview/models" 19 22 "tangled.org/core/appview/notify" 20 23 "tangled.org/core/appview/oauth" 21 24 "tangled.org/core/appview/pages" 22 25 "tangled.org/core/appview/pages/markup" 23 26 "tangled.org/core/appview/reporesolver" 27 + "tangled.org/core/appview/validator" 24 28 "tangled.org/core/appview/xrpcclient" 25 29 "tangled.org/core/idresolver" 26 30 "tangled.org/core/patchutil" 31 + "tangled.org/core/rbac" 27 32 "tangled.org/core/tid" 28 33 "tangled.org/core/types" 29 34 30 - "github.com/bluekeyes/go-gitdiff/gitdiff" 31 35 comatproto "github.com/bluesky-social/indigo/api/atproto" 36 + "github.com/bluesky-social/indigo/atproto/syntax" 32 37 lexutil "github.com/bluesky-social/indigo/lex/util" 33 38 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 34 39 "github.com/go-chi/chi/v5" ··· 43 48 db *db.DB 44 49 config *config.Config 45 50 notifier notify.Notifier 51 + enforcer *rbac.Enforcer 52 + logger *slog.Logger 53 + validator *validator.Validator 54 + indexer *pulls_indexer.Indexer 46 55 } 47 56 48 57 func New( ··· 53 62 db *db.DB, 54 63 config *config.Config, 55 64 notifier notify.Notifier, 65 + enforcer *rbac.Enforcer, 66 + validator *validator.Validator, 67 + indexer *pulls_indexer.Indexer, 68 + logger *slog.Logger, 56 69 ) *Pulls { 57 70 return &Pulls{ 58 71 oauth: oauth, ··· 62 75 db: db, 63 76 config: config, 64 77 notifier: notifier, 78 + enforcer: enforcer, 79 + logger: logger, 80 + validator: validator, 81 + indexer: indexer, 65 82 } 66 83 } 67 84 ··· 98 115 } 99 116 100 117 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 118 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 101 119 resubmitResult := pages.Unknown 102 120 if user.Did == pull.OwnerDid { 103 121 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 122 } 105 123 106 124 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 107 - LoggedInUser: user, 108 - RepoInfo: f.RepoInfo(user), 109 - Pull: pull, 110 - RoundNumber: roundNumber, 111 - MergeCheck: mergeCheckResponse, 112 - ResubmitCheck: resubmitResult, 113 - Stack: stack, 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, 114 133 }) 115 134 return 116 135 } ··· 135 154 stack, _ := r.Context().Value("stack").(models.Stack) 136 155 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 156 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 - 155 157 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 158 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 156 159 resubmitResult := pages.Unknown 157 160 if user != nil && user.Did == pull.OwnerDid { 158 161 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 189 192 m[p.Sha] = p 190 193 } 191 194 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 195 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 193 196 if err != nil { 194 197 log.Println("failed to get pull reactions") 195 198 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 197 200 198 201 userReactions := map[models.ReactionKind]bool{} 199 202 if user != nil { 200 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 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 201 220 } 202 221 203 222 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 - LoggedInUser: user, 205 - RepoInfo: repoInfo, 206 - Pull: pull, 207 - Stack: stack, 208 - AbandonedPulls: abandonedPulls, 209 - MergeCheck: mergeCheckResponse, 210 - ResubmitCheck: resubmitResult, 211 - Pipelines: m, 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, 212 232 213 233 OrderedReactionKinds: models.OrderedReactionKinds, 214 - Reactions: reactionCountMap, 234 + Reactions: reactionMap, 215 235 UserReacted: userReactions, 236 + 237 + LabelDefs: defs, 216 238 }) 217 239 } 218 240 ··· 283 305 return result 284 306 } 285 307 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 + 286 363 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 287 364 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 288 365 return pages.Unknown ··· 330 407 331 408 targetBranch := branchResp 332 409 333 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 410 + latestSourceRev := pull.LatestSha() 334 411 335 412 if pull.IsStacked() && stack != nil { 336 413 top := stack[0] 337 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 414 + latestSourceRev = top.LatestSha() 338 415 } 339 416 340 417 if latestSourceRev != targetBranch.Hash { ··· 374 451 return 375 452 } 376 453 377 - patch := pull.Submissions[roundIdInt].Patch 454 + patch := pull.Submissions[roundIdInt].CombinedPatch() 378 455 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 379 456 380 457 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 425 502 return 426 503 } 427 504 428 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 505 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 429 506 if err != nil { 430 507 log.Println("failed to interdiff; current patch malformed") 431 508 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 432 509 return 433 510 } 434 511 435 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 512 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 436 513 if err != nil { 437 514 log.Println("failed to interdiff; previous patch malformed") 438 515 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 472 549 } 473 550 474 551 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 552 + l := s.logger.With("handler", "RepoPulls") 553 + 475 554 user := s.oauth.GetUser(r) 476 555 params := r.URL.Query() 477 556 ··· 489 568 return 490 569 } 491 570 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 + 492 598 pulls, err := db.GetPulls( 493 599 s.db, 494 - db.FilterEq("repo_at", f.RepoAt()), 495 - db.FilterEq("state", state), 600 + db.FilterIn("id", ids), 496 601 ) 497 602 if err != nil { 498 603 log.Println("failed to get pulls", err) ··· 557 662 m[p.Sha] = p 558 663 } 559 664 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 + 560 681 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 682 LoggedInUser: s.oauth.GetUser(r), 562 683 RepoInfo: f.RepoInfo(user), 563 684 Pulls: pulls, 685 + LabelDefs: defs, 564 686 FilteringBy: state, 687 + FilterQuery: keyword, 565 688 Stacks: stacks, 566 689 Pipelines: m, 567 690 }) 568 691 } 569 692 570 693 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 694 + l := s.logger.With("handler", "PullComment") 571 695 user := s.oauth.GetUser(r) 572 696 f, err := s.repoResolver.Resolve(r) 573 697 if err != nil { ··· 617 741 618 742 createdAt := time.Now().Format(time.RFC3339) 619 743 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 - 627 744 client, err := s.oauth.AuthorizedClient(r) 628 745 if err != nil { 629 746 log.Println("failed to get authorized client", err) 630 747 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 631 748 return 632 749 } 633 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 750 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 634 751 Collection: tangled.RepoPullCommentNSID, 635 752 Repo: user.Did, 636 753 Rkey: tid.TID(), 637 754 Record: &lexutil.LexiconTypeDecoder{ 638 755 Val: &tangled.RepoPullComment{ 639 - Pull: string(pullAt), 756 + Pull: pull.AtUri().String(), 640 757 Body: body, 641 758 CreatedAt: createdAt, 642 759 }, ··· 672 789 return 673 790 } 674 791 675 - s.notifier.NewPullComment(r.Context(), comment) 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) 676 802 677 803 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 678 804 return ··· 884 1010 } 885 1011 886 1012 sourceRev := comparison.Rev2 887 - patch := comparison.Patch 1013 + patch := comparison.FormatPatchRaw 1014 + combined := comparison.CombinedPatchRaw 888 1015 889 - if !patchutil.IsPatchValid(patch) { 1016 + if err := s.validator.ValidatePatch(&patch); err != nil { 1017 + s.logger.Error("failed to validate patch", "err", err) 890 1018 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 891 1019 return 892 1020 } ··· 899 1027 Sha: comparison.Rev2, 900 1028 } 901 1029 902 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1030 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 903 1031 } 904 1032 905 1033 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 906 - if !patchutil.IsPatchValid(patch) { 1034 + if err := s.validator.ValidatePatch(&patch); err != nil { 1035 + s.logger.Error("patch validation failed", "err", err) 907 1036 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 908 1037 return 909 1038 } 910 1039 911 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 1040 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 912 1041 } 913 1042 914 1043 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) { ··· 991 1120 } 992 1121 993 1122 sourceRev := comparison.Rev2 994 - patch := comparison.Patch 1123 + patch := comparison.FormatPatchRaw 1124 + combined := comparison.CombinedPatchRaw 995 1125 996 - if !patchutil.IsPatchValid(patch) { 1126 + if err := s.validator.ValidatePatch(&patch); err != nil { 1127 + s.logger.Error("failed to validate patch", "err", err) 997 1128 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 998 1129 return 999 1130 } ··· 1011 1142 Sha: sourceRev, 1012 1143 } 1013 1144 1014 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1145 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1015 1146 } 1016 1147 1017 1148 func (s *Pulls) createPullRequest( ··· 1021 1152 user *oauth.User, 1022 1153 title, body, targetBranch string, 1023 1154 patch string, 1155 + combined string, 1024 1156 sourceRev string, 1025 1157 pullSource *models.PullSource, 1026 1158 recordPullSource *tangled.RepoPull_Source, ··· 1058 1190 1059 1191 // We've already checked earlier if it's diff-based and title is empty, 1060 1192 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1193 + if title == "" || body == "" { 1062 1194 formatPatches, err := patchutil.ExtractPatches(patch) 1063 1195 if err != nil { 1064 1196 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 1201 return 1070 1202 } 1071 1203 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1204 + if title == "" { 1205 + title = formatPatches[0].Title 1206 + } 1207 + if body == "" { 1208 + body = formatPatches[0].Body 1209 + } 1074 1210 } 1075 1211 1076 1212 rkey := tid.TID() 1077 1213 initialSubmission := models.PullSubmission{ 1078 1214 Patch: patch, 1215 + Combined: combined, 1079 1216 SourceRev: sourceRev, 1080 1217 } 1081 1218 pull := &models.Pull{ ··· 1103 1240 return 1104 1241 } 1105 1242 1106 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1243 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1107 1244 Collection: tangled.RepoPullNSID, 1108 1245 Repo: user.Did, 1109 1246 Rkey: rkey, ··· 1114 1251 Repo: string(f.RepoAt()), 1115 1252 Branch: targetBranch, 1116 1253 }, 1117 - Patch: patch, 1118 - Source: recordPullSource, 1254 + Patch: patch, 1255 + Source: recordPullSource, 1256 + CreatedAt: time.Now().Format(time.RFC3339), 1119 1257 }, 1120 1258 }, 1121 1259 }) ··· 1200 1338 } 1201 1339 writes = append(writes, &write) 1202 1340 } 1203 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1341 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1204 1342 Repo: user.Did, 1205 1343 Writes: writes, 1206 1344 }) ··· 1250 1388 return 1251 1389 } 1252 1390 1253 - if patch == "" || !patchutil.IsPatchValid(patch) { 1391 + if err := s.validator.ValidatePatch(&patch); err != nil { 1392 + s.logger.Error("faield to validate patch", "err", err) 1254 1393 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1255 1394 return 1256 1395 } ··· 1504 1643 1505 1644 patch := r.FormValue("patch") 1506 1645 1507 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1646 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1508 1647 } 1509 1648 1510 1649 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1565 1704 } 1566 1705 1567 1706 sourceRev := comparison.Rev2 1568 - patch := comparison.Patch 1707 + patch := comparison.FormatPatchRaw 1708 + combined := comparison.CombinedPatchRaw 1569 1709 1570 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1710 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1571 1711 } 1572 1712 1573 1713 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1599 1739 return 1600 1740 } 1601 1741 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 - 1628 1742 // update the hidden tracking branch to latest 1629 1743 client, err := s.oauth.ServiceClient( 1630 1744 r, ··· 1656 1770 return 1657 1771 } 1658 1772 1659 - // Use the fork comparison we already made 1660 - comparison := forkComparison 1661 - 1662 - sourceRev := comparison.Rev2 1663 - patch := comparison.Patch 1664 - 1665 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1666 - } 1667 - 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.") 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" 1672 1778 } 1673 - 1674 - if patch == pull.LatestPatch() { 1675 - return fmt.Errorf("Patch is identical to previous submission.") 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 1676 1791 } 1677 1792 1678 - if !patchutil.IsPatchValid(patch) { 1679 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 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 1680 1798 } 1681 1799 1682 - return nil 1800 + // Use the fork comparison we already made 1801 + comparison := forkComparison 1802 + 1803 + sourceRev := comparison.Rev2 1804 + patch := comparison.FormatPatchRaw 1805 + combined := comparison.CombinedPatchRaw 1806 + 1807 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1683 1808 } 1684 1809 1685 1810 func (s *Pulls) resubmitPullHelper( ··· 1689 1814 user *oauth.User, 1690 1815 pull *models.Pull, 1691 1816 patch string, 1817 + combined string, 1692 1818 sourceRev string, 1693 1819 ) { 1694 1820 if pull.IsStacked() { ··· 1697 1823 return 1698 1824 } 1699 1825 1700 - if err := validateResubmittedPatch(pull, patch); err != nil { 1826 + if err := s.validator.ValidatePatch(&patch); err != nil { 1701 1827 s.pages.Notice(w, "resubmit-error", err.Error()) 1702 1828 return 1703 1829 } 1704 1830 1831 + if patch == pull.LatestPatch() { 1832 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1833 + return 1834 + } 1835 + 1705 1836 // validate sourceRev if branch/fork based 1706 1837 if pull.IsBranchBased() || pull.IsForkBased() { 1707 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1838 + if sourceRev == pull.LatestSha() { 1708 1839 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1709 1840 return 1710 1841 } ··· 1718 1849 } 1719 1850 defer tx.Rollback() 1720 1851 1721 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 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) 1722 1858 if err != nil { 1723 1859 log.Println("failed to create pull request", err) 1724 1860 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1731 1867 return 1732 1868 } 1733 1869 1734 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1870 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1735 1871 if err != nil { 1736 1872 // failed to get record 1737 1873 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1754 1890 } 1755 1891 } 1756 1892 1757 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1893 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1758 1894 Collection: tangled.RepoPullNSID, 1759 1895 Repo: user.Did, 1760 1896 Rkey: pull.Rkey, ··· 1766 1902 Repo: string(f.RepoAt()), 1767 1903 Branch: pull.TargetBranch, 1768 1904 }, 1769 - Patch: patch, // new patch 1770 - Source: recordPullSource, 1905 + Patch: patch, // new patch 1906 + Source: recordPullSource, 1907 + CreatedAt: time.Now().Format(time.RFC3339), 1771 1908 }, 1772 1909 }, 1773 1910 }) ··· 1818 1955 // commits that got deleted: corresponding pull is closed 1819 1956 // commits that got added: new pull is created 1820 1957 // 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 1823 1958 additions := make(map[string]*models.Pull) 1824 1959 deletions := make(map[string]*models.Pull) 1825 - unchanged := make(map[string]struct{}) 1826 1960 updated := make(map[string]struct{}) 1827 1961 1828 1962 // pulls in orignal stack but not in new one ··· 1844 1978 for _, np := range newStack { 1845 1979 if op, ok := origById[np.ChangeId]; ok { 1846 1980 // pull exists in both stacks 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 - } 1981 + updated[op.ChangeId] = struct{}{} 1867 1982 } 1868 1983 } 1869 1984 ··· 1930 2045 continue 1931 2046 } 1932 2047 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 - 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) 1977 2055 if err != nil { 1978 2056 log.Println("failed to update pull", err, op.PullId) 1979 2057 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1980 2058 return 1981 2059 } 1982 2060 1983 - record := op.AsRecord() 1984 - record.Patch = newSubmission.Patch 2061 + record := np.AsRecord() 1985 2062 1986 2063 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1987 2064 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2026 2103 return 2027 2104 } 2028 2105 2029 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2106 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2030 2107 Repo: user.Did, 2031 2108 Writes: writes, 2032 2109 }) ··· 2040 2117 } 2041 2118 2042 2119 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2120 + user := s.oauth.GetUser(r) 2043 2121 f, err := s.repoResolver.Resolve(r) 2044 2122 if err != nil { 2045 2123 log.Println("failed to resolve repo:", err) ··· 2137 2215 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2138 2216 return 2139 2217 } 2218 + p.State = models.PullMerged 2140 2219 } 2141 2220 2142 2221 err = tx.Commit() ··· 2147 2226 return 2148 2227 } 2149 2228 2229 + // notify about the pull merge 2230 + for _, p := range pullsToMerge { 2231 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2232 + } 2233 + 2150 2234 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2151 2235 } 2152 2236 ··· 2205 2289 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2206 2290 return 2207 2291 } 2292 + p.State = models.PullClosed 2208 2293 } 2209 2294 2210 2295 // Commit the transaction ··· 2214 2299 return 2215 2300 } 2216 2301 2302 + for _, p := range pullsToClose { 2303 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2304 + } 2305 + 2217 2306 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2218 2307 } 2219 2308 ··· 2273 2362 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2274 2363 return 2275 2364 } 2365 + p.State = models.PullOpen 2276 2366 } 2277 2367 2278 2368 // Commit the transaction ··· 2280 2370 log.Println("failed to commit transaction", err) 2281 2371 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2282 2372 return 2373 + } 2374 + 2375 + for _, p := range pullsToReopen { 2376 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2283 2377 } 2284 2378 2285 2379 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2313 2407 initialSubmission := models.PullSubmission{ 2314 2408 Patch: fp.Raw, 2315 2409 SourceRev: fp.SHA, 2410 + Combined: fp.Raw, 2316 2411 } 2317 2412 pull := models.Pull{ 2318 2413 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) 26 27 27 28 r.Route("/round/{round}", func(r chi.Router) { 28 29 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 + }
+11 -10
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" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview/db" 22 15 "tangled.org/core/appview/models" ··· 25 18 "tangled.org/core/appview/xrpcclient" 26 19 "tangled.org/core/tid" 27 20 "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" 28 29 ) 29 30 30 31 // TODO: proper statuses here on early exit ··· 60 61 return 61 62 } 62 63 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 64 65 if err != nil { 65 66 log.Println("failed to upload blob", err) 66 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 73 rkey := tid.TID() 73 74 createdAt := time.Now() 74 75 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 76 77 Collection: tangled.RepoArtifactNSID, 77 78 Repo: user.Did, 78 79 Rkey: rkey, ··· 249 250 return 250 251 } 251 252 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 253 254 Collection: tangled.RepoArtifactNSID, 254 255 Repo: user.Did, 255 256 Rkey: artifact.Rkey,
+219
appview/repo/blob.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "net/http" 7 + "net/url" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pages/markup" 15 + xrpcclient "tangled.org/core/appview/xrpcclient" 16 + 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 22 + l := rp.logger.With("handler", "RepoBlob") 23 + f, err := rp.repoResolver.Resolve(r) 24 + if err != nil { 25 + l.Error("failed to get repo and knot", "err", err) 26 + return 27 + } 28 + ref := chi.URLParam(r, "ref") 29 + ref, _ = url.PathUnescape(ref) 30 + filePath := chi.URLParam(r, "*") 31 + filePath, _ = url.PathUnescape(filePath) 32 + scheme := "http" 33 + if !rp.config.Core.Dev { 34 + scheme = "https" 35 + } 36 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 37 + xrpcc := &indigoxrpc.Client{ 38 + Host: host, 39 + } 40 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 41 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 42 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 43 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 44 + rp.pages.Error503(w) 45 + return 46 + } 47 + // Use XRPC response directly instead of converting to internal types 48 + var breadcrumbs [][]string 49 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 50 + if filePath != "" { 51 + for idx, elem := range strings.Split(filePath, "/") { 52 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 53 + } 54 + } 55 + showRendered := false 56 + renderToggle := false 57 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 58 + renderToggle = true 59 + showRendered = r.URL.Query().Get("code") != "true" 60 + } 61 + var unsupported bool 62 + var isImage bool 63 + var isVideo bool 64 + var contentSrc string 65 + if resp.IsBinary != nil && *resp.IsBinary { 66 + ext := strings.ToLower(filepath.Ext(resp.Path)) 67 + switch ext { 68 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 69 + isImage = true 70 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 71 + isVideo = true 72 + default: 73 + unsupported = true 74 + } 75 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 76 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 77 + baseURL := &url.URL{ 78 + Scheme: scheme, 79 + Host: f.Knot, 80 + Path: "/xrpc/sh.tangled.repo.blob", 81 + } 82 + query := baseURL.Query() 83 + query.Set("repo", repoName) 84 + query.Set("ref", ref) 85 + query.Set("path", filePath) 86 + query.Set("raw", "true") 87 + baseURL.RawQuery = query.Encode() 88 + blobURL := baseURL.String() 89 + contentSrc = blobURL 90 + if !rp.config.Core.Dev { 91 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 92 + } 93 + } 94 + lines := 0 95 + if resp.IsBinary == nil || !*resp.IsBinary { 96 + lines = strings.Count(resp.Content, "\n") + 1 97 + } 98 + var sizeHint uint64 99 + if resp.Size != nil { 100 + sizeHint = uint64(*resp.Size) 101 + } else { 102 + sizeHint = uint64(len(resp.Content)) 103 + } 104 + user := rp.oauth.GetUser(r) 105 + // Determine if content is binary (dereference pointer) 106 + isBinary := false 107 + if resp.IsBinary != nil { 108 + isBinary = *resp.IsBinary 109 + } 110 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 111 + LoggedInUser: user, 112 + RepoInfo: f.RepoInfo(user), 113 + BreadCrumbs: breadcrumbs, 114 + ShowRendered: showRendered, 115 + RenderToggle: renderToggle, 116 + Unsupported: unsupported, 117 + IsImage: isImage, 118 + IsVideo: isVideo, 119 + ContentSrc: contentSrc, 120 + RepoBlob_Output: resp, 121 + Contents: resp.Content, 122 + Lines: lines, 123 + SizeHint: sizeHint, 124 + IsBinary: isBinary, 125 + }) 126 + } 127 + 128 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 129 + l := rp.logger.With("handler", "RepoBlobRaw") 130 + f, err := rp.repoResolver.Resolve(r) 131 + if err != nil { 132 + l.Error("failed to get repo and knot", "err", err) 133 + w.WriteHeader(http.StatusBadRequest) 134 + return 135 + } 136 + ref := chi.URLParam(r, "ref") 137 + ref, _ = url.PathUnescape(ref) 138 + filePath := chi.URLParam(r, "*") 139 + filePath, _ = url.PathUnescape(filePath) 140 + scheme := "http" 141 + if !rp.config.Core.Dev { 142 + scheme = "https" 143 + } 144 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 145 + baseURL := &url.URL{ 146 + Scheme: scheme, 147 + Host: f.Knot, 148 + Path: "/xrpc/sh.tangled.repo.blob", 149 + } 150 + query := baseURL.Query() 151 + query.Set("repo", repo) 152 + query.Set("ref", ref) 153 + query.Set("path", filePath) 154 + query.Set("raw", "true") 155 + baseURL.RawQuery = query.Encode() 156 + blobURL := baseURL.String() 157 + req, err := http.NewRequest("GET", blobURL, nil) 158 + if err != nil { 159 + l.Error("failed to create request", "err", err) 160 + return 161 + } 162 + // forward the If-None-Match header 163 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 164 + req.Header.Set("If-None-Match", clientETag) 165 + } 166 + client := &http.Client{} 167 + resp, err := client.Do(req) 168 + if err != nil { 169 + l.Error("failed to reach knotserver", "err", err) 170 + rp.pages.Error503(w) 171 + return 172 + } 173 + defer resp.Body.Close() 174 + // forward 304 not modified 175 + if resp.StatusCode == http.StatusNotModified { 176 + w.WriteHeader(http.StatusNotModified) 177 + return 178 + } 179 + if resp.StatusCode != http.StatusOK { 180 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 181 + w.WriteHeader(resp.StatusCode) 182 + _, _ = io.Copy(w, resp.Body) 183 + return 184 + } 185 + contentType := resp.Header.Get("Content-Type") 186 + body, err := io.ReadAll(resp.Body) 187 + if err != nil { 188 + l.Error("error reading response body from knotserver", "err", err) 189 + w.WriteHeader(http.StatusInternalServerError) 190 + return 191 + } 192 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 193 + // serve all textual content as text/plain 194 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 195 + w.Write(body) 196 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 197 + // serve images and videos with their original content type 198 + w.Header().Set("Content-Type", contentType) 199 + w.Write(body) 200 + } else { 201 + w.WriteHeader(http.StatusUnsupportedMediaType) 202 + w.Write([]byte("unsupported content type")) 203 + return 204 + } 205 + } 206 + 207 + func isTextualMimeType(mimeType string) bool { 208 + textualTypes := []string{ 209 + "application/json", 210 + "application/xml", 211 + "application/yaml", 212 + "application/x-yaml", 213 + "application/toml", 214 + "application/javascript", 215 + "application/ecmascript", 216 + "message/", 217 + } 218 + return slices.Contains(textualTypes, mimeType) 219 + }
+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) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + func (rp *Repo) AtomFeed(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)
+37 -39
appview/repo/index.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 6 + "log/slog" 7 7 "net/http" 8 8 "net/url" 9 9 "slices" ··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 25 "tangled.org/core/appview/reporesolver" 27 26 "tangled.org/core/appview/xrpcclient" 28 27 "tangled.org/core/types" ··· 31 30 "github.com/go-enry/go-enry/v2" 32 31 ) 33 32 34 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 35 36 ref := chi.URLParam(r, "ref") 36 37 ref, _ = url.PathUnescape(ref) 37 38 38 39 f, err := rp.repoResolver.Resolve(r) 39 40 if err != nil { 40 - log.Println("failed to fully resolve repo", err) 41 + l.Error("failed to fully resolve repo", "err", err) 41 42 return 42 43 } 43 44 ··· 57 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 58 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 59 60 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 60 - log.Println("failed to call XRPC repo.index", err) 61 + l.Error("failed to call XRPC repo.index", "err", err) 61 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 62 63 LoggedInUser: user, 63 64 NeedsKnotUpgrade: true, ··· 67 68 } 68 69 69 70 rp.pages.Error503(w) 70 - log.Println("failed to build index response", err) 71 + l.Error("failed to build index response", "err", err) 71 72 return 72 73 } 73 74 ··· 120 121 emails := uniqueEmails(commitsTrunc) 121 122 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 122 123 if err != nil { 123 - log.Println("failed to get email to did map", err) 124 + l.Error("failed to get email to did map", "err", err) 124 125 } 125 126 126 127 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 127 128 if err != nil { 128 - log.Println(err) 129 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 129 130 } 130 131 131 132 // TODO: a bit dirty 132 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 133 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 133 134 if err != nil { 134 - log.Printf("failed to compute language percentages: %s", err) 135 + l.Warn("failed to compute language percentages", "err", err) 135 136 // non-fatal 136 137 } 137 138 ··· 141 142 } 142 143 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 143 144 if err != nil { 144 - log.Printf("failed to fetch pipeline statuses: %s", err) 145 + l.Error("failed to fetch pipeline statuses", "err", err) 145 146 // non-fatal 146 147 } 147 148 ··· 153 154 CommitsTrunc: commitsTrunc, 154 155 TagsTrunc: tagsTrunc, 155 156 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 156 - BranchesTrunc: branchesTrunc, 157 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 158 - VerifiedCommits: vc, 159 - Languages: languageInfo, 160 - Pipelines: pipelines, 157 + BranchesTrunc: branchesTrunc, 158 + EmailToDid: emailToDidMap, 159 + VerifiedCommits: vc, 160 + Languages: languageInfo, 161 + Pipelines: pipelines, 161 162 }) 162 163 } 163 164 164 165 func (rp *Repo) getLanguageInfo( 165 166 ctx context.Context, 167 + l *slog.Logger, 166 168 f *reporesolver.ResolvedRepo, 167 169 xrpcc *indigoxrpc.Client, 168 170 currentRef string, ··· 181 183 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 182 184 if err != nil { 183 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 184 - log.Println("failed to call XRPC repo.languages", xrpcerr) 186 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 185 187 return nil, xrpcerr 186 188 } 187 189 return nil, err ··· 201 203 }) 202 204 } 203 205 206 + tx, err := rp.db.Begin() 207 + if err != nil { 208 + return nil, err 209 + } 210 + defer tx.Rollback() 211 + 204 212 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 213 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 206 214 if err != nil { 207 215 // non-fatal 208 - log.Println("failed to cache lang results", err) 216 + l.Error("failed to cache lang results", "err", err) 217 + } 218 + 219 + err = tx.Commit() 220 + if err != nil { 221 + return nil, err 209 222 } 210 223 } 211 224 ··· 328 341 } 329 342 }() 330 343 331 - // readme content 332 - wg.Add(1) 333 - go func() { 334 - defer wg.Done() 335 - for _, filename := range markup.ReadmeFilenames { 336 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 - if err != nil { 338 - continue 339 - } 340 - 341 - if blobResp == nil { 342 - continue 343 - } 344 - 345 - readmeContent = blobResp.Content 346 - readmeFileName = filename 347 - break 348 - } 349 - }() 350 - 351 344 wg.Wait() 352 345 353 346 if errs != nil { ··· 374 367 } 375 368 files = append(files, niceFile) 376 369 } 370 + } 371 + 372 + if treeResp != nil && treeResp.Readme != nil { 373 + readmeFileName = treeResp.Readme.Filename 374 + readmeContent = treeResp.Readme.Contents 377 375 } 378 376 379 377 result := &types.RepoIndexResponse{
+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 + }
+66 -1369
appview/repo/repo.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "encoding/json" 7 6 "errors" 8 7 "fmt" 9 - "io" 10 - "log" 11 8 "log/slog" 12 9 "net/http" 13 10 "net/url" 14 - "path/filepath" 15 11 "slices" 16 - "strconv" 17 12 "strings" 18 13 "time" 19 14 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" 23 15 "tangled.org/core/api/tangled" 24 - "tangled.org/core/appview/commitverify" 25 16 "tangled.org/core/appview/config" 26 17 "tangled.org/core/appview/db" 27 18 "tangled.org/core/appview/models" 28 19 "tangled.org/core/appview/notify" 29 20 "tangled.org/core/appview/oauth" 30 21 "tangled.org/core/appview/pages" 31 - "tangled.org/core/appview/pages/markup" 32 22 "tangled.org/core/appview/reporesolver" 33 23 "tangled.org/core/appview/validator" 34 24 xrpcclient "tangled.org/core/appview/xrpcclient" 35 25 "tangled.org/core/eventconsumer" 36 26 "tangled.org/core/idresolver" 37 - "tangled.org/core/patchutil" 38 27 "tangled.org/core/rbac" 39 28 "tangled.org/core/tid" 40 - "tangled.org/core/types" 41 29 "tangled.org/core/xrpc/serviceauth" 42 30 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" 43 35 securejoin "github.com/cyphar/filepath-securejoin" 44 36 "github.com/go-chi/chi/v5" 45 - "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 37 ) 49 38 50 39 type Repo struct { ··· 89 78 } 90 79 } 91 80 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 - // readme content 453 - var ( 454 - readmeContent string 455 - readmeFileName string 456 - ) 457 - 458 - for _, filename := range markup.ReadmeFilenames { 459 - path := fmt.Sprintf("%s/%s", treePath, filename) 460 - blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo) 461 - if err != nil { 462 - continue 463 - } 464 - 465 - if blobResp == nil { 466 - continue 467 - } 468 - 469 - readmeContent = blobResp.Content 470 - readmeFileName = path 471 - break 472 - } 473 - 474 - // Convert XRPC response to internal types.RepoTreeResponse 475 - files := make([]types.NiceTree, len(xrpcResp.Files)) 476 - for i, xrpcFile := range xrpcResp.Files { 477 - file := types.NiceTree{ 478 - Name: xrpcFile.Name, 479 - Mode: xrpcFile.Mode, 480 - Size: int64(xrpcFile.Size), 481 - IsFile: xrpcFile.Is_file, 482 - IsSubtree: xrpcFile.Is_subtree, 483 - } 484 - 485 - // Convert last commit info if present 486 - if xrpcFile.Last_commit != nil { 487 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 488 - file.LastCommit = &types.LastCommitInfo{ 489 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 490 - Message: xrpcFile.Last_commit.Message, 491 - When: commitWhen, 492 - } 493 - } 494 - 495 - files[i] = file 496 - } 497 - 498 - result := types.RepoTreeResponse{ 499 - Ref: xrpcResp.Ref, 500 - Files: files, 501 - } 502 - 503 - if xrpcResp.Parent != nil { 504 - result.Parent = *xrpcResp.Parent 505 - } 506 - if xrpcResp.Dotdot != nil { 507 - result.DotDot = *xrpcResp.Dotdot 508 - } 509 - 510 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 511 - // so we can safely redirect to the "parent" (which is the same file). 512 - if len(result.Files) == 0 && result.Parent == treePath { 513 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 514 - http.Redirect(w, r, redirectTo, http.StatusFound) 515 - return 516 - } 517 - 518 - user := rp.oauth.GetUser(r) 519 - 520 - var breadcrumbs [][]string 521 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 522 - if treePath != "" { 523 - for idx, elem := range strings.Split(treePath, "/") { 524 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 525 - } 526 - } 527 - 528 - sortFiles(result.Files) 529 - 530 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 531 - LoggedInUser: user, 532 - BreadCrumbs: breadcrumbs, 533 - TreePath: treePath, 534 - RepoInfo: f.RepoInfo(user), 535 - Readme: readmeContent, 536 - ReadmeFileName: readmeFileName, 537 - RepoTreeResponse: result, 538 - }) 539 - } 540 - 541 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 542 - f, err := rp.repoResolver.Resolve(r) 543 - if err != nil { 544 - log.Println("failed to get repo and knot", err) 545 - return 546 - } 547 - 548 - scheme := "http" 549 - if !rp.config.Core.Dev { 550 - scheme = "https" 551 - } 552 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 553 - xrpcc := &indigoxrpc.Client{ 554 - Host: host, 555 - } 556 - 557 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 558 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 559 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 560 - log.Println("failed to call XRPC repo.tags", xrpcerr) 561 - rp.pages.Error503(w) 562 - return 563 - } 564 - 565 - var result types.RepoTagsResponse 566 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 567 - log.Println("failed to decode XRPC response", err) 568 - rp.pages.Error503(w) 569 - return 570 - } 571 - 572 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 573 - if err != nil { 574 - log.Println("failed grab artifacts", err) 575 - return 576 - } 577 - 578 - // convert artifacts to map for easy UI building 579 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 580 - for _, a := range artifacts { 581 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 582 - } 583 - 584 - var danglingArtifacts []models.Artifact 585 - for _, a := range artifacts { 586 - found := false 587 - for _, t := range result.Tags { 588 - if t.Tag != nil { 589 - if t.Tag.Hash == a.Tag { 590 - found = true 591 - } 592 - } 593 - } 594 - 595 - if !found { 596 - danglingArtifacts = append(danglingArtifacts, a) 597 - } 598 - } 599 - 600 - user := rp.oauth.GetUser(r) 601 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 602 - LoggedInUser: user, 603 - RepoInfo: f.RepoInfo(user), 604 - RepoTagsResponse: result, 605 - ArtifactMap: artifactMap, 606 - DanglingArtifacts: danglingArtifacts, 607 - }) 608 - } 609 - 610 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 611 - f, err := rp.repoResolver.Resolve(r) 612 - if err != nil { 613 - log.Println("failed to get repo and knot", err) 614 - return 615 - } 616 - 617 - scheme := "http" 618 - if !rp.config.Core.Dev { 619 - scheme = "https" 620 - } 621 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 622 - xrpcc := &indigoxrpc.Client{ 623 - Host: host, 624 - } 625 - 626 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 627 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 628 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 629 - log.Println("failed to call XRPC repo.branches", xrpcerr) 630 - rp.pages.Error503(w) 631 - return 632 - } 633 - 634 - var result types.RepoBranchesResponse 635 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 636 - log.Println("failed to decode XRPC response", err) 637 - rp.pages.Error503(w) 638 - return 639 - } 640 - 641 - sortBranches(result.Branches) 642 - 643 - user := rp.oauth.GetUser(r) 644 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 645 - LoggedInUser: user, 646 - RepoInfo: f.RepoInfo(user), 647 - RepoBranchesResponse: result, 648 - }) 649 - } 650 - 651 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 652 - f, err := rp.repoResolver.Resolve(r) 653 - if err != nil { 654 - log.Println("failed to get repo and knot", err) 655 - return 656 - } 657 - 658 - ref := chi.URLParam(r, "ref") 659 - ref, _ = url.PathUnescape(ref) 660 - 661 - filePath := chi.URLParam(r, "*") 662 - filePath, _ = url.PathUnescape(filePath) 663 - 664 - scheme := "http" 665 - if !rp.config.Core.Dev { 666 - scheme = "https" 667 - } 668 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 669 - xrpcc := &indigoxrpc.Client{ 670 - Host: host, 671 - } 672 - 673 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 674 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 675 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 676 - log.Println("failed to call XRPC repo.blob", xrpcerr) 677 - rp.pages.Error503(w) 678 - return 679 - } 680 - 681 - // Use XRPC response directly instead of converting to internal types 682 - 683 - var breadcrumbs [][]string 684 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 685 - if filePath != "" { 686 - for idx, elem := range strings.Split(filePath, "/") { 687 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 688 - } 689 - } 690 - 691 - showRendered := false 692 - renderToggle := false 693 - 694 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 695 - renderToggle = true 696 - showRendered = r.URL.Query().Get("code") != "true" 697 - } 698 - 699 - var unsupported bool 700 - var isImage bool 701 - var isVideo bool 702 - var contentSrc string 703 - 704 - if resp.IsBinary != nil && *resp.IsBinary { 705 - ext := strings.ToLower(filepath.Ext(resp.Path)) 706 - switch ext { 707 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 708 - isImage = true 709 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 710 - isVideo = true 711 - default: 712 - unsupported = true 713 - } 714 - 715 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 716 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 717 - 718 - baseURL := &url.URL{ 719 - Scheme: scheme, 720 - Host: f.Knot, 721 - Path: "/xrpc/sh.tangled.repo.blob", 722 - } 723 - query := baseURL.Query() 724 - query.Set("repo", repoName) 725 - query.Set("ref", ref) 726 - query.Set("path", filePath) 727 - query.Set("raw", "true") 728 - baseURL.RawQuery = query.Encode() 729 - blobURL := baseURL.String() 730 - 731 - contentSrc = blobURL 732 - if !rp.config.Core.Dev { 733 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 734 - } 735 - } 736 - 737 - lines := 0 738 - if resp.IsBinary == nil || !*resp.IsBinary { 739 - lines = strings.Count(resp.Content, "\n") + 1 740 - } 741 - 742 - var sizeHint uint64 743 - if resp.Size != nil { 744 - sizeHint = uint64(*resp.Size) 745 - } else { 746 - sizeHint = uint64(len(resp.Content)) 747 - } 748 - 749 - user := rp.oauth.GetUser(r) 750 - 751 - // Determine if content is binary (dereference pointer) 752 - isBinary := false 753 - if resp.IsBinary != nil { 754 - isBinary = *resp.IsBinary 755 - } 756 - 757 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 758 - LoggedInUser: user, 759 - RepoInfo: f.RepoInfo(user), 760 - BreadCrumbs: breadcrumbs, 761 - ShowRendered: showRendered, 762 - RenderToggle: renderToggle, 763 - Unsupported: unsupported, 764 - IsImage: isImage, 765 - IsVideo: isVideo, 766 - ContentSrc: contentSrc, 767 - RepoBlob_Output: resp, 768 - Contents: resp.Content, 769 - Lines: lines, 770 - SizeHint: sizeHint, 771 - IsBinary: isBinary, 772 - }) 773 - } 774 - 775 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 776 - f, err := rp.repoResolver.Resolve(r) 777 - if err != nil { 778 - log.Println("failed to get repo and knot", err) 779 - w.WriteHeader(http.StatusBadRequest) 780 - return 781 - } 782 - 783 - ref := chi.URLParam(r, "ref") 784 - ref, _ = url.PathUnescape(ref) 785 - 786 - filePath := chi.URLParam(r, "*") 787 - filePath, _ = url.PathUnescape(filePath) 788 - 789 - scheme := "http" 790 - if !rp.config.Core.Dev { 791 - scheme = "https" 792 - } 793 - 794 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 795 - baseURL := &url.URL{ 796 - Scheme: scheme, 797 - Host: f.Knot, 798 - Path: "/xrpc/sh.tangled.repo.blob", 799 - } 800 - query := baseURL.Query() 801 - query.Set("repo", repo) 802 - query.Set("ref", ref) 803 - query.Set("path", filePath) 804 - query.Set("raw", "true") 805 - baseURL.RawQuery = query.Encode() 806 - blobURL := baseURL.String() 807 - 808 - req, err := http.NewRequest("GET", blobURL, nil) 809 - if err != nil { 810 - log.Println("failed to create request", err) 811 - return 812 - } 813 - 814 - // forward the If-None-Match header 815 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 816 - req.Header.Set("If-None-Match", clientETag) 817 - } 818 - 819 - client := &http.Client{} 820 - resp, err := client.Do(req) 821 - if err != nil { 822 - log.Println("failed to reach knotserver", err) 823 - rp.pages.Error503(w) 824 - return 825 - } 826 - defer resp.Body.Close() 827 - 828 - // forward 304 not modified 829 - if resp.StatusCode == http.StatusNotModified { 830 - w.WriteHeader(http.StatusNotModified) 831 - return 832 - } 833 - 834 - if resp.StatusCode != http.StatusOK { 835 - log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 836 - w.WriteHeader(resp.StatusCode) 837 - _, _ = io.Copy(w, resp.Body) 838 - return 839 - } 840 - 841 - contentType := resp.Header.Get("Content-Type") 842 - body, err := io.ReadAll(resp.Body) 843 - if err != nil { 844 - log.Printf("error reading response body from knotserver: %v", err) 845 - w.WriteHeader(http.StatusInternalServerError) 846 - return 847 - } 848 - 849 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 850 - // serve all textual content as text/plain 851 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 852 - w.Write(body) 853 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 854 - // serve images and videos with their original content type 855 - w.Header().Set("Content-Type", contentType) 856 - w.Write(body) 857 - } else { 858 - w.WriteHeader(http.StatusUnsupportedMediaType) 859 - w.Write([]byte("unsupported content type")) 860 - return 861 - } 862 - } 863 - 864 81 // isTextualMimeType returns true if the MIME type represents textual content 865 - // that should be served as text/plain 866 - func isTextualMimeType(mimeType string) bool { 867 - textualTypes := []string{ 868 - "application/json", 869 - "application/xml", 870 - "application/yaml", 871 - "application/x-yaml", 872 - "application/toml", 873 - "application/javascript", 874 - "application/ecmascript", 875 - "message/", 876 - } 877 - 878 - return slices.Contains(textualTypes, mimeType) 879 - } 880 82 881 83 // modify the spindle configured for this repo 882 84 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 883 85 user := rp.oauth.GetUser(r) 884 86 l := rp.logger.With("handler", "EditSpindle") 885 87 l = l.With("did", user.Did) 886 - l = l.With("handle", user.Handle) 887 88 888 89 errorId := "operation-error" 889 90 fail := func(msg string, err error) { ··· 936 137 return 937 138 } 938 139 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 140 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 141 if err != nil { 941 142 fail("Failed to update spindle, no record found on PDS.", err) 942 143 return 943 144 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 145 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 945 146 Collection: tangled.RepoNSID, 946 147 Repo: newRepo.Did, 947 148 Rkey: newRepo.Rkey, ··· 971 172 user := rp.oauth.GetUser(r) 972 173 l := rp.logger.With("handler", "AddLabel") 973 174 l = l.With("did", user.Did) 974 - l = l.With("handle", user.Handle) 975 175 976 176 f, err := rp.repoResolver.Resolve(r) 977 177 if err != nil { ··· 1040 240 1041 241 // emit a labelRecord 1042 242 labelRecord := label.AsRecord() 1043 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 243 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1044 244 Collection: tangled.LabelDefinitionNSID, 1045 245 Repo: label.Did, 1046 246 Rkey: label.Rkey, ··· 1063 263 newRepo.Labels = append(newRepo.Labels, aturi) 1064 264 repoRecord := newRepo.AsRecord() 1065 265 1066 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 266 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1067 267 if err != nil { 1068 268 fail("Failed to update labels, no record found on PDS.", err) 1069 269 return 1070 270 } 1071 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 271 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1072 272 Collection: tangled.RepoNSID, 1073 273 Repo: newRepo.Did, 1074 274 Rkey: newRepo.Rkey, ··· 1131 331 user := rp.oauth.GetUser(r) 1132 332 l := rp.logger.With("handler", "DeleteLabel") 1133 333 l = l.With("did", user.Did) 1134 - l = l.With("handle", user.Handle) 1135 334 1136 335 f, err := rp.repoResolver.Resolve(r) 1137 336 if err != nil { ··· 1161 360 } 1162 361 1163 362 // delete label record from PDS 1164 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 363 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1165 364 Collection: tangled.LabelDefinitionNSID, 1166 365 Repo: label.Did, 1167 366 Rkey: label.Rkey, ··· 1183 382 newRepo.Labels = updated 1184 383 repoRecord := newRepo.AsRecord() 1185 384 1186 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 385 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1187 386 if err != nil { 1188 387 fail("Failed to update labels, no record found on PDS.", err) 1189 388 return 1190 389 } 1191 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 390 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1192 391 Collection: tangled.RepoNSID, 1193 392 Repo: newRepo.Did, 1194 393 Rkey: newRepo.Rkey, ··· 1240 439 user := rp.oauth.GetUser(r) 1241 440 l := rp.logger.With("handler", "SubscribeLabel") 1242 441 l = l.With("did", user.Did) 1243 - l = l.With("handle", user.Handle) 1244 442 1245 443 f, err := rp.repoResolver.Resolve(r) 1246 444 if err != nil { ··· 1281 479 return 1282 480 } 1283 481 1284 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 482 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1285 483 if err != nil { 1286 484 fail("Failed to update labels, no record found on PDS.", err) 1287 485 return 1288 486 } 1289 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 487 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1290 488 Collection: tangled.RepoNSID, 1291 489 Repo: newRepo.Did, 1292 490 Rkey: newRepo.Rkey, ··· 1327 525 user := rp.oauth.GetUser(r) 1328 526 l := rp.logger.With("handler", "UnsubscribeLabel") 1329 527 l = l.With("did", user.Did) 1330 - l = l.With("handle", user.Handle) 1331 528 1332 529 f, err := rp.repoResolver.Resolve(r) 1333 530 if err != nil { ··· 1370 567 return 1371 568 } 1372 569 1373 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 570 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1374 571 if err != nil { 1375 572 fail("Failed to update labels, no record found on PDS.", err) 1376 573 return 1377 574 } 1378 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 575 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1379 576 Collection: tangled.RepoNSID, 1380 577 Repo: newRepo.Did, 1381 578 Rkey: newRepo.Rkey, ··· 1421 618 db.FilterContains("scope", subject.Collection().String()), 1422 619 ) 1423 620 if err != nil { 1424 - log.Println("failed to fetch label defs", err) 621 + l.Error("failed to fetch label defs", "err", err) 1425 622 return 1426 623 } 1427 624 ··· 1432 629 1433 630 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1434 631 if err != nil { 1435 - log.Println("failed to build label state", err) 632 + l.Error("failed to build label state", "err", err) 1436 633 return 1437 634 } 1438 635 state := states[subject] ··· 1469 666 db.FilterContains("scope", subject.Collection().String()), 1470 667 ) 1471 668 if err != nil { 1472 - log.Println("failed to fetch labels", err) 669 + l.Error("failed to fetch labels", "err", err) 1473 670 return 1474 671 } 1475 672 ··· 1480 677 1481 678 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1482 679 if err != nil { 1483 - log.Println("failed to build label state", err) 680 + l.Error("failed to build label state", "err", err) 1484 681 return 1485 682 } 1486 683 state := states[subject] ··· 1499 696 user := rp.oauth.GetUser(r) 1500 697 l := rp.logger.With("handler", "AddCollaborator") 1501 698 l = l.With("did", user.Did) 1502 - l = l.With("handle", user.Handle) 1503 699 1504 700 f, err := rp.repoResolver.Resolve(r) 1505 701 if err != nil { ··· 1546 742 currentUser := rp.oauth.GetUser(r) 1547 743 rkey := tid.TID() 1548 744 createdAt := time.Now() 1549 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 745 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1550 746 Collection: tangled.RepoCollaboratorNSID, 1551 747 Repo: currentUser.Did, 1552 748 Rkey: rkey, ··· 1628 824 1629 825 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1630 826 user := rp.oauth.GetUser(r) 827 + l := rp.logger.With("handler", "DeleteRepo") 1631 828 1632 829 noticeId := "operation-error" 1633 830 f, err := rp.repoResolver.Resolve(r) 1634 831 if err != nil { 1635 - log.Println("failed to get repo and knot", err) 832 + l.Error("failed to get repo and knot", "err", err) 1636 833 return 1637 834 } 1638 835 1639 836 // remove record from pds 1640 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 837 + atpClient, err := rp.oauth.AuthorizedClient(r) 1641 838 if err != nil { 1642 - log.Println("failed to get authorized client", err) 839 + l.Error("failed to get authorized client", "err", err) 1643 840 return 1644 841 } 1645 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 842 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1646 843 Collection: tangled.RepoNSID, 1647 844 Repo: user.Did, 1648 845 Rkey: f.Rkey, 1649 846 }) 1650 847 if err != nil { 1651 - log.Printf("failed to delete record: %s", err) 848 + l.Error("failed to delete record", "err", err) 1652 849 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1653 850 return 1654 851 } 1655 - log.Println("removed repo record ", f.RepoAt().String()) 852 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1656 853 1657 854 client, err := rp.oauth.ServiceClient( 1658 855 r, ··· 1661 858 oauth.WithDev(rp.config.Core.Dev), 1662 859 ) 1663 860 if err != nil { 1664 - log.Println("failed to connect to knot server:", err) 861 + l.Error("failed to connect to knot server", "err", err) 1665 862 return 1666 863 } 1667 864 ··· 1678 875 rp.pages.Notice(w, noticeId, err.Error()) 1679 876 return 1680 877 } 1681 - log.Println("deleted repo from knot") 878 + l.Info("deleted repo from knot") 1682 879 1683 880 tx, err := rp.db.BeginTx(r.Context(), nil) 1684 881 if err != nil { 1685 - log.Println("failed to start tx") 882 + l.Error("failed to start tx") 1686 883 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1687 884 return 1688 885 } ··· 1690 887 tx.Rollback() 1691 888 err = rp.enforcer.E.LoadPolicy() 1692 889 if err != nil { 1693 - log.Println("failed to rollback policies") 890 + l.Error("failed to rollback policies") 1694 891 } 1695 892 }() 1696 893 ··· 1704 901 did := c[0] 1705 902 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1706 903 } 1707 - log.Println("removed collaborators") 904 + l.Info("removed collaborators") 1708 905 1709 906 // remove repo RBAC 1710 907 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1719 916 rp.pages.Notice(w, noticeId, "Failed to update appview") 1720 917 return 1721 918 } 1722 - log.Println("removed repo from db") 919 + l.Info("removed repo from db") 1723 920 1724 921 err = tx.Commit() 1725 922 if err != nil { 1726 - log.Println("failed to commit changes", err) 923 + l.Error("failed to commit changes", "err", err) 1727 924 http.Error(w, err.Error(), http.StatusInternalServerError) 1728 925 return 1729 926 } 1730 927 1731 928 err = rp.enforcer.E.SavePolicy() 1732 929 if err != nil { 1733 - log.Println("failed to update ACLs", err) 930 + l.Error("failed to update ACLs", "err", err) 1734 931 http.Error(w, err.Error(), http.StatusInternalServerError) 1735 932 return 1736 933 } ··· 1738 935 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1739 936 } 1740 937 1741 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1742 - f, err := rp.repoResolver.Resolve(r) 1743 - if err != nil { 1744 - log.Println("failed to get repo and knot", err) 1745 - return 1746 - } 1747 - 1748 - noticeId := "operation-error" 1749 - branch := r.FormValue("branch") 1750 - if branch == "" { 1751 - http.Error(w, "malformed form", http.StatusBadRequest) 1752 - return 1753 - } 938 + func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 939 + l := rp.logger.With("handler", "SyncRepoFork") 1754 940 1755 - client, err := rp.oauth.ServiceClient( 1756 - r, 1757 - oauth.WithService(f.Knot), 1758 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1759 - oauth.WithDev(rp.config.Core.Dev), 1760 - ) 1761 - if err != nil { 1762 - log.Println("failed to connect to knot server:", err) 1763 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1764 - return 1765 - } 1766 - 1767 - xe := tangled.RepoSetDefaultBranch( 1768 - r.Context(), 1769 - client, 1770 - &tangled.RepoSetDefaultBranch_Input{ 1771 - Repo: f.RepoAt().String(), 1772 - DefaultBranch: branch, 1773 - }, 1774 - ) 1775 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1776 - log.Println("xrpc failed", "err", xe) 1777 - rp.pages.Notice(w, noticeId, err.Error()) 1778 - return 1779 - } 1780 - 1781 - rp.pages.HxRefresh(w) 1782 - } 1783 - 1784 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1785 - user := rp.oauth.GetUser(r) 1786 - l := rp.logger.With("handler", "Secrets") 1787 - l = l.With("handle", user.Handle) 1788 - l = l.With("did", user.Did) 1789 - 1790 - f, err := rp.repoResolver.Resolve(r) 1791 - if err != nil { 1792 - log.Println("failed to get repo and knot", err) 1793 - return 1794 - } 1795 - 1796 - if f.Spindle == "" { 1797 - log.Println("empty spindle cannot add/rm secret", err) 1798 - return 1799 - } 1800 - 1801 - lxm := tangled.RepoAddSecretNSID 1802 - if r.Method == http.MethodDelete { 1803 - lxm = tangled.RepoRemoveSecretNSID 1804 - } 1805 - 1806 - spindleClient, err := rp.oauth.ServiceClient( 1807 - r, 1808 - oauth.WithService(f.Spindle), 1809 - oauth.WithLxm(lxm), 1810 - oauth.WithExp(60), 1811 - oauth.WithDev(rp.config.Core.Dev), 1812 - ) 1813 - if err != nil { 1814 - log.Println("failed to create spindle client", err) 1815 - return 1816 - } 1817 - 1818 - key := r.FormValue("key") 1819 - if key == "" { 1820 - w.WriteHeader(http.StatusBadRequest) 1821 - return 1822 - } 1823 - 1824 - switch r.Method { 1825 - case http.MethodPut: 1826 - errorId := "add-secret-error" 1827 - 1828 - value := r.FormValue("value") 1829 - if value == "" { 1830 - w.WriteHeader(http.StatusBadRequest) 1831 - return 1832 - } 1833 - 1834 - err = tangled.RepoAddSecret( 1835 - r.Context(), 1836 - spindleClient, 1837 - &tangled.RepoAddSecret_Input{ 1838 - Repo: f.RepoAt().String(), 1839 - Key: key, 1840 - Value: value, 1841 - }, 1842 - ) 1843 - if err != nil { 1844 - l.Error("Failed to add secret.", "err", err) 1845 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1846 - return 1847 - } 1848 - 1849 - case http.MethodDelete: 1850 - errorId := "operation-error" 1851 - 1852 - err = tangled.RepoRemoveSecret( 1853 - r.Context(), 1854 - spindleClient, 1855 - &tangled.RepoRemoveSecret_Input{ 1856 - Repo: f.RepoAt().String(), 1857 - Key: key, 1858 - }, 1859 - ) 1860 - if err != nil { 1861 - l.Error("Failed to delete secret.", "err", err) 1862 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1863 - return 1864 - } 1865 - } 1866 - 1867 - rp.pages.HxRefresh(w) 1868 - } 1869 - 1870 - type tab = map[string]any 1871 - 1872 - var ( 1873 - // would be great to have ordered maps right about now 1874 - settingsTabs []tab = []tab{ 1875 - {"Name": "general", "Icon": "sliders-horizontal"}, 1876 - {"Name": "access", "Icon": "users"}, 1877 - {"Name": "pipelines", "Icon": "layers-2"}, 1878 - } 1879 - ) 1880 - 1881 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1882 - tabVal := r.URL.Query().Get("tab") 1883 - if tabVal == "" { 1884 - tabVal = "general" 1885 - } 1886 - 1887 - switch tabVal { 1888 - case "general": 1889 - rp.generalSettings(w, r) 1890 - 1891 - case "access": 1892 - rp.accessSettings(w, r) 1893 - 1894 - case "pipelines": 1895 - rp.pipelineSettings(w, r) 1896 - } 1897 - } 1898 - 1899 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1900 - f, err := rp.repoResolver.Resolve(r) 1901 - user := rp.oauth.GetUser(r) 1902 - 1903 - scheme := "http" 1904 - if !rp.config.Core.Dev { 1905 - scheme = "https" 1906 - } 1907 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1908 - xrpcc := &indigoxrpc.Client{ 1909 - Host: host, 1910 - } 1911 - 1912 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1913 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1914 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1915 - log.Println("failed to call XRPC repo.branches", xrpcerr) 1916 - rp.pages.Error503(w) 1917 - return 1918 - } 1919 - 1920 - var result types.RepoBranchesResponse 1921 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1922 - log.Println("failed to decode XRPC response", err) 1923 - rp.pages.Error503(w) 1924 - return 1925 - } 1926 - 1927 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1928 - if err != nil { 1929 - log.Println("failed to fetch labels", err) 1930 - rp.pages.Error503(w) 1931 - return 1932 - } 1933 - 1934 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1935 - if err != nil { 1936 - log.Println("failed to fetch labels", err) 1937 - rp.pages.Error503(w) 1938 - return 1939 - } 1940 - // remove default labels from the labels list, if present 1941 - defaultLabelMap := make(map[string]bool) 1942 - for _, dl := range defaultLabels { 1943 - defaultLabelMap[dl.AtUri().String()] = true 1944 - } 1945 - n := 0 1946 - for _, l := range labels { 1947 - if !defaultLabelMap[l.AtUri().String()] { 1948 - labels[n] = l 1949 - n++ 1950 - } 1951 - } 1952 - labels = labels[:n] 1953 - 1954 - subscribedLabels := make(map[string]struct{}) 1955 - for _, l := range f.Repo.Labels { 1956 - subscribedLabels[l] = struct{}{} 1957 - } 1958 - 1959 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1960 - // if all default labels are subbed, show the "unsubscribe all" button 1961 - shouldSubscribeAll := false 1962 - for _, dl := range defaultLabels { 1963 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1964 - // one of the default labels is not subscribed to 1965 - shouldSubscribeAll = true 1966 - break 1967 - } 1968 - } 1969 - 1970 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1971 - LoggedInUser: user, 1972 - RepoInfo: f.RepoInfo(user), 1973 - Branches: result.Branches, 1974 - Labels: labels, 1975 - DefaultLabels: defaultLabels, 1976 - SubscribedLabels: subscribedLabels, 1977 - ShouldSubscribeAll: shouldSubscribeAll, 1978 - Tabs: settingsTabs, 1979 - Tab: "general", 1980 - }) 1981 - } 1982 - 1983 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1984 - f, err := rp.repoResolver.Resolve(r) 1985 - user := rp.oauth.GetUser(r) 1986 - 1987 - repoCollaborators, err := f.Collaborators(r.Context()) 1988 - if err != nil { 1989 - log.Println("failed to get collaborators", err) 1990 - } 1991 - 1992 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1993 - LoggedInUser: user, 1994 - RepoInfo: f.RepoInfo(user), 1995 - Tabs: settingsTabs, 1996 - Tab: "access", 1997 - Collaborators: repoCollaborators, 1998 - }) 1999 - } 2000 - 2001 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2002 - f, err := rp.repoResolver.Resolve(r) 2003 - user := rp.oauth.GetUser(r) 2004 - 2005 - // all spindles that the repo owner is a member of 2006 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2007 - if err != nil { 2008 - log.Println("failed to fetch spindles", err) 2009 - return 2010 - } 2011 - 2012 - var secrets []*tangled.RepoListSecrets_Secret 2013 - if f.Spindle != "" { 2014 - if spindleClient, err := rp.oauth.ServiceClient( 2015 - r, 2016 - oauth.WithService(f.Spindle), 2017 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2018 - oauth.WithExp(60), 2019 - oauth.WithDev(rp.config.Core.Dev), 2020 - ); err != nil { 2021 - log.Println("failed to create spindle client", err) 2022 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2023 - log.Println("failed to fetch secrets", err) 2024 - } else { 2025 - secrets = resp.Secrets 2026 - } 2027 - } 2028 - 2029 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2030 - return strings.Compare(a.Key, b.Key) 2031 - }) 2032 - 2033 - var dids []string 2034 - for _, s := range secrets { 2035 - dids = append(dids, s.CreatedBy) 2036 - } 2037 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2038 - 2039 - // convert to a more manageable form 2040 - var niceSecret []map[string]any 2041 - for id, s := range secrets { 2042 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2043 - niceSecret = append(niceSecret, map[string]any{ 2044 - "Id": id, 2045 - "Key": s.Key, 2046 - "CreatedAt": when, 2047 - "CreatedBy": resolvedIdents[id].Handle.String(), 2048 - }) 2049 - } 2050 - 2051 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2052 - LoggedInUser: user, 2053 - RepoInfo: f.RepoInfo(user), 2054 - Tabs: settingsTabs, 2055 - Tab: "pipelines", 2056 - Spindles: spindles, 2057 - CurrentSpindle: f.Spindle, 2058 - Secrets: niceSecret, 2059 - }) 2060 - } 2061 - 2062 - func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2063 941 ref := chi.URLParam(r, "ref") 2064 942 ref, _ = url.PathUnescape(ref) 2065 943 2066 944 user := rp.oauth.GetUser(r) 2067 945 f, err := rp.repoResolver.Resolve(r) 2068 946 if err != nil { 2069 - log.Printf("failed to resolve source repo: %v", err) 947 + l.Error("failed to resolve source repo", "err", err) 2070 948 return 2071 949 } 2072 950 ··· 2110 988 } 2111 989 2112 990 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 991 + l := rp.logger.With("handler", "ForkRepo") 992 + 2113 993 user := rp.oauth.GetUser(r) 2114 994 f, err := rp.repoResolver.Resolve(r) 2115 995 if err != nil { 2116 - log.Printf("failed to resolve source repo: %v", err) 996 + l.Error("failed to resolve source repo", "err", err) 2117 997 return 2118 998 } 2119 999 ··· 2149 1029 } 2150 1030 2151 1031 // choose a name for a fork 2152 - forkName := f.Name 1032 + forkName := r.FormValue("repo_name") 1033 + if forkName == "" { 1034 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 1035 + return 1036 + } 1037 + 2153 1038 // this check is *only* to see if the forked repo name already exists 2154 1039 // in the user's account. 2155 1040 existingRepo, err := db.GetRepo( 2156 1041 rp.db, 2157 1042 db.FilterEq("did", user.Did), 2158 - db.FilterEq("name", f.Name), 1043 + db.FilterEq("name", forkName), 2159 1044 ) 2160 1045 if err != nil { 2161 - if errors.Is(err, sql.ErrNoRows) { 2162 - // no existing repo with this name found, we can use the name as is 2163 - } else { 2164 - log.Println("error fetching existing repo from db", "err", err) 1046 + if !errors.Is(err, sql.ErrNoRows) { 1047 + l.Error("error fetching existing repo from db", "err", err) 2165 1048 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2166 1049 return 2167 1050 } 2168 1051 } else if existingRepo != nil { 2169 - // repo with this name already exists, append random string 2170 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1052 + // repo with this name already exists 1053 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 1054 + return 2171 1055 } 2172 1056 l = l.With("forkName", forkName) 2173 1057 ··· 2189 1073 Knot: targetKnot, 2190 1074 Rkey: rkey, 2191 1075 Source: sourceAt, 2192 - Description: existingRepo.Description, 1076 + Description: f.Repo.Description, 2193 1077 Created: time.Now(), 2194 - Labels: models.DefaultLabelDefs(), 1078 + Labels: rp.config.Label.DefaultLabelDefs, 2195 1079 } 2196 1080 record := repo.AsRecord() 2197 1081 2198 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1082 + atpClient, err := rp.oauth.AuthorizedClient(r) 2199 1083 if err != nil { 2200 1084 l.Error("failed to create xrpcclient", "err", err) 2201 1085 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2202 1086 return 2203 1087 } 2204 1088 2205 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1089 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2206 1090 Collection: tangled.RepoNSID, 2207 1091 Repo: user.Did, 2208 1092 Rkey: rkey, ··· 2234 1118 rollback := func() { 2235 1119 err1 := tx.Rollback() 2236 1120 err2 := rp.enforcer.E.LoadPolicy() 2237 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1121 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2238 1122 2239 1123 // ignore txn complete errors, this is okay 2240 1124 if errors.Is(err1, sql.ErrTxDone) { ··· 2275 1159 2276 1160 err = db.AddRepo(tx, repo) 2277 1161 if err != nil { 2278 - log.Println(err) 1162 + l.Error("failed to AddRepo", "err", err) 2279 1163 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2280 1164 return 2281 1165 } ··· 2284 1168 p, _ := securejoin.SecureJoin(user.Did, forkName) 2285 1169 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2286 1170 if err != nil { 2287 - log.Println(err) 1171 + l.Error("failed to add ACLs", "err", err) 2288 1172 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2289 1173 return 2290 1174 } 2291 1175 2292 1176 err = tx.Commit() 2293 1177 if err != nil { 2294 - log.Println("failed to commit changes", err) 1178 + l.Error("failed to commit changes", "err", err) 2295 1179 http.Error(w, err.Error(), http.StatusInternalServerError) 2296 1180 return 2297 1181 } 2298 1182 2299 1183 err = rp.enforcer.E.SavePolicy() 2300 1184 if err != nil { 2301 - log.Println("failed to update ACLs", err) 1185 + l.Error("failed to update ACLs", "err", err) 2302 1186 http.Error(w, err.Error(), http.StatusInternalServerError) 2303 1187 return 2304 1188 } ··· 2307 1191 aturi = "" 2308 1192 2309 1193 rp.notifier.NewRepo(r.Context(), repo) 2310 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1194 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 2311 1195 } 2312 1196 } 2313 1197 2314 1198 // this is used to rollback changes made to the PDS 2315 1199 // 2316 1200 // it is a no-op if the provided ATURI is empty 2317 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1201 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2318 1202 if aturi == "" { 2319 1203 return nil 2320 1204 } ··· 2325 1209 repo := parsed.Authority().String() 2326 1210 rkey := parsed.RecordKey().String() 2327 1211 2328 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1212 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2329 1213 Collection: collection, 2330 1214 Repo: repo, 2331 1215 Rkey: rkey, 2332 1216 }) 2333 1217 return err 2334 1218 } 2335 - 2336 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2337 - user := rp.oauth.GetUser(r) 2338 - f, err := rp.repoResolver.Resolve(r) 2339 - if err != nil { 2340 - log.Println("failed to get repo and knot", err) 2341 - return 2342 - } 2343 - 2344 - scheme := "http" 2345 - if !rp.config.Core.Dev { 2346 - scheme = "https" 2347 - } 2348 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2349 - xrpcc := &indigoxrpc.Client{ 2350 - Host: host, 2351 - } 2352 - 2353 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2354 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2355 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2356 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2357 - rp.pages.Error503(w) 2358 - return 2359 - } 2360 - 2361 - var branchResult types.RepoBranchesResponse 2362 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2363 - log.Println("failed to decode XRPC branches response", err) 2364 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2365 - return 2366 - } 2367 - branches := branchResult.Branches 2368 - 2369 - sortBranches(branches) 2370 - 2371 - var defaultBranch string 2372 - for _, b := range branches { 2373 - if b.IsDefault { 2374 - defaultBranch = b.Name 2375 - } 2376 - } 2377 - 2378 - base := defaultBranch 2379 - head := defaultBranch 2380 - 2381 - params := r.URL.Query() 2382 - queryBase := params.Get("base") 2383 - queryHead := params.Get("head") 2384 - if queryBase != "" { 2385 - base = queryBase 2386 - } 2387 - if queryHead != "" { 2388 - head = queryHead 2389 - } 2390 - 2391 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2392 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2393 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2394 - rp.pages.Error503(w) 2395 - return 2396 - } 2397 - 2398 - var tags types.RepoTagsResponse 2399 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2400 - log.Println("failed to decode XRPC tags response", err) 2401 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2402 - return 2403 - } 2404 - 2405 - repoinfo := f.RepoInfo(user) 2406 - 2407 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2408 - LoggedInUser: user, 2409 - RepoInfo: repoinfo, 2410 - Branches: branches, 2411 - Tags: tags.Tags, 2412 - Base: base, 2413 - Head: head, 2414 - }) 2415 - } 2416 - 2417 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2418 - user := rp.oauth.GetUser(r) 2419 - f, err := rp.repoResolver.Resolve(r) 2420 - if err != nil { 2421 - log.Println("failed to get repo and knot", err) 2422 - return 2423 - } 2424 - 2425 - var diffOpts types.DiffOpts 2426 - if d := r.URL.Query().Get("diff"); d == "split" { 2427 - diffOpts.Split = true 2428 - } 2429 - 2430 - // if user is navigating to one of 2431 - // /compare/{base}/{head} 2432 - // /compare/{base}...{head} 2433 - base := chi.URLParam(r, "base") 2434 - head := chi.URLParam(r, "head") 2435 - if base == "" && head == "" { 2436 - rest := chi.URLParam(r, "*") // master...feature/xyz 2437 - parts := strings.SplitN(rest, "...", 2) 2438 - if len(parts) == 2 { 2439 - base = parts[0] 2440 - head = parts[1] 2441 - } 2442 - } 2443 - 2444 - base, _ = url.PathUnescape(base) 2445 - head, _ = url.PathUnescape(head) 2446 - 2447 - if base == "" || head == "" { 2448 - log.Printf("invalid comparison") 2449 - rp.pages.Error404(w) 2450 - return 2451 - } 2452 - 2453 - scheme := "http" 2454 - if !rp.config.Core.Dev { 2455 - scheme = "https" 2456 - } 2457 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2458 - xrpcc := &indigoxrpc.Client{ 2459 - Host: host, 2460 - } 2461 - 2462 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2463 - 2464 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2465 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2466 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2467 - rp.pages.Error503(w) 2468 - return 2469 - } 2470 - 2471 - var branches types.RepoBranchesResponse 2472 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2473 - log.Println("failed to decode XRPC branches response", err) 2474 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2475 - return 2476 - } 2477 - 2478 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2479 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2480 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2481 - rp.pages.Error503(w) 2482 - return 2483 - } 2484 - 2485 - var tags types.RepoTagsResponse 2486 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2487 - log.Println("failed to decode XRPC tags response", err) 2488 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2489 - return 2490 - } 2491 - 2492 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2493 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2494 - log.Println("failed to call XRPC repo.compare", xrpcerr) 2495 - rp.pages.Error503(w) 2496 - return 2497 - } 2498 - 2499 - var formatPatch types.RepoFormatPatchResponse 2500 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2501 - log.Println("failed to decode XRPC compare response", err) 2502 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2503 - return 2504 - } 2505 - 2506 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2507 - 2508 - repoinfo := f.RepoInfo(user) 2509 - 2510 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2511 - LoggedInUser: user, 2512 - RepoInfo: repoinfo, 2513 - Branches: branches.Branches, 2514 - Tags: tags.Tags, 2515 - Base: base, 2516 - Head: head, 2517 - Diff: &diff, 2518 - DiffOpts: diffOpts, 2519 - }) 2520 - 2521 - }
-35
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "context" 5 4 "crypto/rand" 6 - "fmt" 7 5 "math/big" 8 6 "slices" 9 7 "sort" ··· 90 88 } 91 89 92 90 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 126 91 } 127 92 128 93 func randomString(n int) string {
+16 -19
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.RepoIndex) 13 - r.Get("/feed.atom", rp.RepoAtomFeed) 14 - r.Get("/commits/{ref}", rp.RepoLog) 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) 15 16 r.Route("/tree/{ref}", func(r chi.Router) { 16 - r.Get("/", rp.RepoIndex) 17 - r.Get("/*", rp.RepoTree) 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 18 19 }) 19 - r.Get("/commit/{ref}", rp.RepoCommit) 20 - r.Get("/branches", rp.RepoBranches) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 + r.Delete("/branches", rp.DeleteBranch) 21 23 r.Route("/tags", func(r chi.Router) { 22 - r.Get("/", rp.RepoTags) 24 + r.Get("/", rp.Tags) 23 25 r.Route("/{tag}", func(r chi.Router) { 24 26 r.Get("/download/{file}", rp.DownloadArtifact) 25 27 ··· 35 37 }) 36 38 }) 37 39 }) 38 - r.Get("/blob/{ref}/*", rp.RepoBlob) 40 + r.Get("/blob/{ref}/*", rp.Blob) 39 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 40 42 41 43 // intentionally doesn't use /* as this isn't ··· 52 54 }) 53 55 54 56 r.Route("/compare", func(r chi.Router) { 55 - r.Get("/", rp.RepoCompareNew) // start an new comparison 57 + r.Get("/", rp.CompareNew) // start an new comparison 56 58 57 59 // we have to wildcard here since we want to support GitHub's compare syntax 58 60 // /compare/{ref1}...{ref2} 59 61 // for example: 60 62 // /compare/master...some/feature 61 63 // /compare/master...example.com:another/feature <- this is a fork 62 - r.Get("/{base}/{head}", rp.RepoCompare) 63 - r.Get("/*", rp.RepoCompare) 64 + r.Get("/{base}/{head}", rp.Compare) 65 + r.Get("/*", rp.Compare) 64 66 }) 65 67 66 68 // label panel in issues/pulls/discussions/tasks ··· 72 74 // settings routes, needs auth 73 75 r.Group(func(r chi.Router) { 74 76 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 - }) 81 77 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 82 - r.Get("/", rp.RepoSettings) 78 + r.Get("/", rp.Settings) 79 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 83 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 84 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 85 82 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 + }
+107
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 + IsFile: xrpcFile.Is_file, 57 + IsSubtree: xrpcFile.Is_subtree, 58 + } 59 + // Convert last commit info if present 60 + if xrpcFile.Last_commit != nil { 61 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 62 + file.LastCommit = &types.LastCommitInfo{ 63 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 64 + Message: xrpcFile.Last_commit.Message, 65 + When: commitWhen, 66 + } 67 + } 68 + files[i] = file 69 + } 70 + result := types.RepoTreeResponse{ 71 + Ref: xrpcResp.Ref, 72 + Files: files, 73 + } 74 + if xrpcResp.Parent != nil { 75 + result.Parent = *xrpcResp.Parent 76 + } 77 + if xrpcResp.Dotdot != nil { 78 + result.DotDot = *xrpcResp.Dotdot 79 + } 80 + if xrpcResp.Readme != nil { 81 + result.ReadmeFileName = xrpcResp.Readme.Filename 82 + result.Readme = xrpcResp.Readme.Contents 83 + } 84 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 85 + // so we can safely redirect to the "parent" (which is the same file). 86 + if len(result.Files) == 0 && result.Parent == treePath { 87 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 88 + http.Redirect(w, r, redirectTo, http.StatusFound) 89 + return 90 + } 91 + user := rp.oauth.GetUser(r) 92 + var breadcrumbs [][]string 93 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 94 + if treePath != "" { 95 + for idx, elem := range strings.Split(treePath, "/") { 96 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 97 + } 98 + } 99 + sortFiles(result.Files) 100 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 101 + LoggedInUser: user, 102 + BreadCrumbs: breadcrumbs, 103 + TreePath: treePath, 104 + RepoInfo: f.RepoInfo(user), 105 + RepoTreeResponse: result, 106 + }) 107 + }
+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, 191 193 IsStarred: isStarred, 192 194 Knot: knot, 193 195 Spindle: f.Spindle,
+55 -2
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" 25 26 lexutil "github.com/bluesky-social/indigo/lex/util" 26 27 "github.com/gliderlabs/ssh" 27 28 "github.com/google/uuid" ··· 41 42 {"Name": "profile", "Icon": "user"}, 42 43 {"Name": "keys", "Icon": "key"}, 43 44 {"Name": "emails", "Icon": "mail"}, 45 + {"Name": "notifications", "Icon": "bell"}, 44 46 } 45 47 ) 46 48 ··· 66 68 r.Get("/verify", s.emailsVerify) 67 69 r.Post("/verify/resend", s.emailsVerifyResend) 68 70 r.Post("/primary", s.emailsPrimary) 71 + }) 72 + 73 + r.Route("/notifications", func(r chi.Router) { 74 + r.Get("/", s.notificationsSettings) 75 + r.Put("/", s.updateNotificationPreferences) 69 76 }) 70 77 71 78 return r ··· 79 86 Tabs: settingsTabs, 80 87 Tab: "profile", 81 88 }) 89 + } 90 + 91 + func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 92 + user := s.OAuth.GetUser(r) 93 + did := s.OAuth.GetDid(r) 94 + 95 + prefs, err := db.GetNotificationPreference(s.Db, did) 96 + if err != nil { 97 + log.Printf("failed to get notification preferences: %s", err) 98 + s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 99 + return 100 + } 101 + 102 + s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 103 + LoggedInUser: user, 104 + Preferences: prefs, 105 + Tabs: settingsTabs, 106 + Tab: "notifications", 107 + }) 108 + } 109 + 110 + func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) { 111 + did := s.OAuth.GetDid(r) 112 + 113 + prefs := &models.NotificationPreferences{ 114 + UserDid: syntax.DID(did), 115 + RepoStarred: r.FormValue("repo_starred") == "on", 116 + IssueCreated: r.FormValue("issue_created") == "on", 117 + IssueCommented: r.FormValue("issue_commented") == "on", 118 + IssueClosed: r.FormValue("issue_closed") == "on", 119 + PullCreated: r.FormValue("pull_created") == "on", 120 + PullCommented: r.FormValue("pull_commented") == "on", 121 + PullMerged: r.FormValue("pull_merged") == "on", 122 + Followed: r.FormValue("followed") == "on", 123 + UserMentioned: r.FormValue("user_mentioned") == "on", 124 + EmailNotifications: r.FormValue("email_notifications") == "on", 125 + } 126 + 127 + err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 128 + if err != nil { 129 + log.Printf("failed to update notification preferences: %s", err) 130 + s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 131 + return 132 + } 133 + 134 + s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.") 82 135 } 83 136 84 137 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 419 472 } 420 473 421 474 // store in pds too 422 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 475 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 423 476 Collection: tangled.PublicKeyNSID, 424 477 Repo: did, 425 478 Rkey: rkey, ··· 476 529 477 530 if rkey != "" { 478 531 // remove from pds too 479 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 532 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 480 533 Collection: tangled.PublicKeyNSID, 481 534 Repo: did, 482 535 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 + }
+159 -40
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 6 + "encoding/json" 7 + "errors" 5 8 "fmt" 6 9 "log/slog" 7 10 "net/http" 11 + "net/url" 8 12 "os" 9 13 "strings" 10 14 ··· 17 21 "tangled.org/core/appview/models" 18 22 "tangled.org/core/appview/pages" 19 23 "tangled.org/core/appview/state/userutil" 20 - "tangled.org/core/appview/xrpcclient" 21 24 "tangled.org/core/idresolver" 22 25 ) 23 26 ··· 26 29 db *db.DB 27 30 cf *dns.Cloudflare 28 31 posthog posthog.Client 29 - xrpc *xrpcclient.Client 30 32 idResolver *idresolver.Resolver 31 33 pages *pages.Pages 32 34 l *slog.Logger ··· 61 63 disallowed := make(map[string]bool) 62 64 63 65 if filepath == "" { 64 - logger.Debug("no disallowed nicknames file configured") 66 + logger.Warn("no disallowed nicknames file configured") 65 67 return disallowed 66 68 } 67 69 ··· 116 118 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 119 switch r.Method { 118 120 case http.MethodGet: 119 - s.pages.Signup(w) 121 + s.pages.Signup(w, pages.SignupParams{ 122 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 123 + }) 120 124 case http.MethodPost: 121 125 if s.cf == nil { 122 126 http.Error(w, "signup is disabled", http.StatusFailedDependency) 127 + return 123 128 } 124 129 emailId := r.FormValue("email") 130 + cfToken := r.FormValue("cf-turnstile-response") 125 131 126 132 noticeId := "signup-msg" 133 + 134 + if err := s.validateCaptcha(cfToken, r); err != nil { 135 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 136 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 137 + return 138 + } 139 + 127 140 if !email.IsValidEmail(emailId) { 128 141 s.pages.Notice(w, noticeId, "Invalid email address.") 129 142 return ··· 204 217 return 205 218 } 206 219 207 - did, err := s.createAccountRequest(username, password, email, code) 208 - if err != nil { 209 - s.l.Error("failed to create account", "error", err) 210 - s.pages.Notice(w, "signup-error", err.Error()) 211 - return 212 - } 213 - 214 220 if s.cf == nil { 215 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 216 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 217 223 return 218 224 } 219 225 220 - err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 221 - Type: "TXT", 222 - Name: "_atproto." + username, 223 - Content: fmt.Sprintf(`"did=%s"`, did), 224 - TTL: 6400, 225 - Proxied: false, 226 - }) 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 227 228 if err != nil { 228 - s.l.Error("failed to create DNS record", "error", err) 229 - s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + // Error already logged and notice already sent 230 230 return 231 231 } 232 + } 233 + } 232 234 233 - err = db.AddEmail(s.db, models.Email{ 234 - Did: did, 235 - Address: email, 236 - Verified: true, 237 - Primary: true, 238 - }) 239 - if err != nil { 240 - s.l.Error("failed to add email", "error", err) 241 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 242 - return 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 240 + 241 + success := false 242 + defer func() { 243 + if !success { 244 + s.l.Info("rolling back signup transaction", "username", username, "did", did) 245 + 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 + } 253 + } 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 281 + } 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 + } 327 + 328 + type turnstileResponse struct { 329 + Success bool `json:"success"` 330 + ErrorCodes []string `json:"error-codes,omitempty"` 331 + ChallengeTs string `json:"challenge_ts,omitempty"` 332 + Hostname string `json:"hostname,omitempty"` 333 + } 334 + 335 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 336 + if cfToken == "" { 337 + return errors.New("captcha token is empty") 338 + } 339 + 340 + if s.config.Cloudflare.TurnstileSecretKey == "" { 341 + return errors.New("turnstile secret key not configured") 342 + } 343 + 344 + data := url.Values{} 345 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 346 + data.Set("response", cfToken) 347 + 348 + // include the client IP if we have it 349 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 350 + data.Set("remoteip", remoteIP) 351 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 352 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 353 + data.Set("remoteip", strings.TrimSpace(ips[0])) 243 354 } 355 + } else { 356 + data.Set("remoteip", r.RemoteAddr) 357 + } 244 358 245 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 246 - <a class="underline text-black dark:text-white" href="/login">login</a> 247 - with <code>%s.tngl.sh</code>.`, username)) 359 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 360 + if err != nil { 361 + return fmt.Errorf("failed to verify turnstile token: %w", err) 362 + } 363 + defer resp.Body.Close() 248 364 249 - go func() { 250 - err := db.DeleteInflightSignup(s.db, email) 251 - if err != nil { 252 - s.l.Error("failed to delete inflight signup", "error", err) 253 - } 254 - }() 255 - return 365 + var turnstileResp turnstileResponse 366 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 367 + return fmt.Errorf("failed to decode turnstile response: %w", err) 256 368 } 369 + 370 + if !turnstileResp.Success { 371 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 372 + return errors.New("turnstile validation failed") 373 + } 374 + 375 + return nil 257 376 }
+14 -5
appview/spindles/spindles.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 146 147 } 147 148 148 149 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, "/") 149 156 if instance == "" { 150 157 s.Pages.Notice(w, noticeId, "Incomplete form.") 151 158 return ··· 189 196 return 190 197 } 191 198 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 199 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 200 var exCid *string 194 201 if ex != nil { 195 202 exCid = ex.Cid 196 203 } 197 204 198 205 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 206 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 207 Collection: tangled.SpindleNSID, 201 208 Repo: user.Did, 202 209 Rkey: instance, ··· 332 339 return 333 340 } 334 341 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 342 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 343 Collection: tangled.SpindleNSID, 337 344 Repo: user.Did, 338 345 Rkey: instance, ··· 484 491 } 485 492 486 493 member := r.FormValue("member") 494 + member = strings.TrimPrefix(member, "@") 487 495 if member == "" { 488 496 l.Error("empty member") 489 497 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 542 550 return 543 551 } 544 552 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 553 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 554 Collection: tangled.SpindleMemberNSID, 547 555 Repo: user.Did, 548 556 Rkey: rkey, ··· 613 621 } 614 622 615 623 member := r.FormValue("member") 624 + member = strings.TrimPrefix(member, "@") 616 625 if member == "" { 617 626 l.Error("empty member") 618 627 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") ··· 683 692 } 684 693 685 694 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 695 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 696 Collection: tangled.SpindleMemberNSID, 688 697 Repo: user.Did, 689 698 Rkey: members[0].Rkey,
+3 -2
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 29 30 } 30 31 31 32 if currentUser.Did == subjectIdent.DID.String() { ··· 43 44 case http.MethodPost: 44 45 createdAt := time.Now().Format(time.RFC3339) 45 46 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 48 Collection: tangled.GraphFollowNSID, 48 49 Repo: currentUser.Did, 49 50 Rkey: rkey, ··· 88 89 return 89 90 } 90 91 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 93 Collection: tangled.GraphFollowNSID, 93 94 Repo: currentUser.Did, 94 95 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 + }
+17 -2
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 + 28 31 knots, err := db.GetRegistrations( 29 32 d, 30 33 db.FilterIsNot("registered", "null"), ··· 39 42 srcs[s] = struct{}{} 40 43 } 41 44 42 - logger := log.New("knotstream") 43 45 cache := cache.New(c.Redis.Addr) 44 46 cursorStore := cursor.NewRedisCursorStore(cache) 45 47 ··· 172 174 }) 173 175 } 174 176 175 - return db.InsertRepoLanguages(d, langs) 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() 176 191 } 177 192 178 193 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 + }
+5 -2
appview/state/profile.go
··· 336 336 profile.Did = did 337 337 } 338 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 339 340 UserDid: did, 340 341 FollowStatus: followStatus, 341 342 FollowersCount: followStats.Followers, ··· 537 538 profile.Description = r.FormValue("description") 538 539 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 539 540 profile.Location = r.FormValue("location") 541 + profile.Pronouns = r.FormValue("pronouns") 540 542 541 543 var links [5]string 542 544 for i := range 5 { ··· 633 635 vanityStats = append(vanityStats, string(v.Kind)) 634 636 } 635 637 636 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 638 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 637 639 var cid *string 638 640 if ex != nil { 639 641 cid = ex.Cid 640 642 } 641 643 642 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 644 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 643 645 Collection: tangled.ActorProfileNSID, 644 646 Repo: user.Did, 645 647 Rkey: "self", ··· 651 653 Location: &profile.Location, 652 654 PinnedRepositories: pinnedRepoStrings, 653 655 Stats: vanityStats[:], 656 + Pronouns: &profile.Pronouns, 654 657 }}, 655 658 SwapRecord: cid, 656 659 })
+11 -9
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 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 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 := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &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 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions 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: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 84 85 IsReacted: true, 85 86 }) 86 87 ··· 92 93 return 93 94 } 94 95 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 97 Collection: tangled.FeedReactionNSID, 97 98 Repo: currentUser.Did, 98 99 Rkey: reaction.Rkey, ··· 109 110 // this is not an issue, the firehose event might have already done this 110 111 } 111 112 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 113 114 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 115 116 return 116 117 } 117 118 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 120 ThreadAt: subjectUri, 120 121 Kind: reactionKind, 121 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 122 124 IsReacted: false, 123 125 }) 124 126
+122 -59
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 11 10 "tangled.org/core/appview/labels" 12 11 "tangled.org/core/appview/middleware" 13 - oauthhandler "tangled.org/core/appview/oauth/handler" 12 + "tangled.org/core/appview/notifications" 14 13 "tangled.org/core/appview/pipelines" 15 14 "tangled.org/core/appview/pulls" 16 15 "tangled.org/core/appview/repo" ··· 35 34 36 35 router.Get("/favicon.svg", s.Favicon) 37 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 38 39 39 40 userRouter := s.UserRouter(&middleware) 40 41 standardRouter := s.StandardRouter(&middleware) 41 42 42 43 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 43 44 pat := chi.URLParam(r, "*") 44 - if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 45 - userRouter.ServeHTTP(w, r) 46 - } else { 47 - // Check if the first path element is a valid handle without '@' or a flattened DID 48 - pathParts := strings.SplitN(pat, "/", 2) 49 - if len(pathParts) > 0 { 50 - if userutil.IsHandleNoAt(pathParts[0]) { 51 - // Redirect to the same path but with '@' prefixed to the handle 52 - redirectPath := "@" + pat 53 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 54 - return 55 - } else if userutil.IsFlattenedDid(pathParts[0]) { 56 - // Redirect to the unflattened DID version 57 - unflattenedDid := userutil.UnflattenDid(pathParts[0]) 58 - var redirectPath string 59 - if len(pathParts) > 1 { 60 - redirectPath = unflattenedDid + "/" + pathParts[1] 61 - } else { 62 - redirectPath = unflattenedDid 63 - } 64 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 65 - return 66 - } 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 67 77 } 68 - standardRouter.ServeHTTP(w, r) 78 + 69 79 } 80 + 81 + standardRouter.ServeHTTP(w, r) 70 82 }) 71 83 72 84 return router ··· 79 91 r.Get("/", s.Profile) 80 92 r.Get("/feed.atom", s.AtomFeedPage) 81 93 82 - // redirect /@handle/repo.git -> /@handle/repo 83 - r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 84 - nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 85 - http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 86 - }) 87 - 88 94 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 89 95 r.Use(mw.GoImport()) 90 96 r.Mount("/", s.RepoRouter(mw)) 91 97 r.Mount("/issues", s.IssuesRouter(mw)) 92 98 r.Mount("/pulls", s.PullsRouter(mw)) 93 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 94 - r.Mount("/labels", s.LabelsRouter(mw)) 99 + r.Mount("/pipelines", s.PipelinesRouter()) 100 + r.Mount("/labels", s.LabelsRouter()) 95 101 96 102 // These routes get proxied to the knot 97 103 r.Get("/info/refs", s.InfoRefs) ··· 115 121 116 122 r.Get("/", s.HomeOrTimeline) 117 123 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 124 + r.Get("/upgradeBanner", s.UpgradeBanner) 119 125 120 126 // special-case handler for serving tangled.org/core 121 127 r.Get("/core", s.Core()) 128 + 129 + r.Get("/login", s.Login) 130 + r.Post("/login", s.Login) 131 + r.Post("/logout", s.Logout) 122 132 123 133 r.Route("/repo", func(r chi.Router) { 124 134 r.Route("/new", func(r chi.Router) { ··· 129 139 // r.Post("/import", s.ImportRepo) 130 140 }) 131 141 142 + r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues) 143 + 132 144 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 133 145 r.Post("/", s.Follow) 134 146 r.Delete("/", s.Follow) ··· 156 168 r.Mount("/strings", s.StringsRouter(mw)) 157 169 r.Mount("/knots", s.KnotsRouter()) 158 170 r.Mount("/spindles", s.SpindlesRouter()) 171 + r.Mount("/notifications", s.NotificationsRouter(mw)) 172 + 159 173 r.Mount("/signup", s.SignupRouter()) 160 - r.Mount("/", s.OAuthRouter()) 174 + r.Mount("/", s.oauth.Router()) 161 175 162 176 r.Get("/keys/{user}", s.Keys) 163 177 r.Get("/terms", s.TermsOfService) 164 178 r.Get("/privacy", s.PrivacyPolicy) 179 + r.Get("/brand", s.Brand) 165 180 166 181 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 167 182 s.pages.Error404(w) ··· 175 190 return func(w http.ResponseWriter, r *http.Request) { 176 191 if r.URL.Query().Get("go-get") == "1" { 177 192 w.Header().Set("Content-Type", "text/html") 178 - w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`)) 193 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 179 194 return 180 195 } 181 196 ··· 183 198 } 184 199 } 185 200 186 - func (s *State) OAuthRouter() http.Handler { 187 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 188 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 189 - return oauth.Router() 190 - } 191 - 192 201 func (s *State) SettingsRouter() http.Handler { 193 202 settings := &settings.Settings{ 194 203 Db: s.db, ··· 201 210 } 202 211 203 212 func (s *State) SpindlesRouter() http.Handler { 204 - logger := log.New("spindles") 213 + logger := log.SubLogger(s.logger, "spindles") 205 214 206 215 spindles := &spindles.Spindles{ 207 216 Db: s.db, ··· 217 226 } 218 227 219 228 func (s *State) KnotsRouter() http.Handler { 220 - logger := log.New("knots") 229 + logger := log.SubLogger(s.logger, "knots") 221 230 222 231 knots := &knots.Knots{ 223 232 Db: s.db, ··· 234 243 } 235 244 236 245 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 237 - logger := log.New("strings") 246 + logger := log.SubLogger(s.logger, "strings") 238 247 239 248 strs := &avstrings.Strings{ 240 249 Db: s.db, ··· 249 258 } 250 259 251 260 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 252 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 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 + ) 253 273 return issues.Router(mw) 254 274 } 255 275 256 276 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 257 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 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 + ) 258 290 return pulls.Router(mw) 259 291 } 260 292 261 293 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 262 - logger := log.New("repo") 263 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 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 + ) 264 307 return repo.Router(mw) 265 308 } 266 309 267 - func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 268 - pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 269 - return pipes.Router(mw) 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() 323 + } 324 + 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() 270 335 } 271 336 272 - func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 273 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 274 - return ls.Router(mw) 337 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 338 + notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 339 + return notifs.Router(mw) 275 340 } 276 341 277 342 func (s *State) SignupRouter() http.Handler { 278 - logger := log.New("signup") 279 - 280 - sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 343 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 281 344 return sig.Router() 282 345 }
+3 -1
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 + 25 28 spindles, err := db.GetSpindles( 26 29 d, 27 30 db.FilterIsNot("verified", "null"), ··· 36 39 srcs[src] = struct{}{} 37 40 } 38 41 39 - logger := log.New("spindlestream") 40 42 cache := cache.New(c.Redis.Addr) 41 43 cursorStore := cursor.NewRedisCursorStore(cache) 42 44
+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 := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &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 = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+120 -49
appview/state/state.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "strings" 12 11 "time" 13 12 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" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview" 22 - "tangled.org/core/appview/cache" 23 - "tangled.org/core/appview/cache/session" 24 15 "tangled.org/core/appview/config" 25 16 "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/indexer" 26 18 "tangled.org/core/appview/models" 27 19 "tangled.org/core/appview/notify" 20 + dbnotify "tangled.org/core/appview/notify/db" 21 + phnotify "tangled.org/core/appview/notify/posthog" 28 22 "tangled.org/core/appview/oauth" 29 23 "tangled.org/core/appview/pages" 30 - posthogService "tangled.org/core/appview/posthog" 31 24 "tangled.org/core/appview/reporesolver" 32 25 "tangled.org/core/appview/validator" 33 26 xrpcclient "tangled.org/core/appview/xrpcclient" 34 27 "tangled.org/core/eventconsumer" 35 28 "tangled.org/core/idresolver" 36 29 "tangled.org/core/jetstream" 30 + "tangled.org/core/log" 37 31 tlog "tangled.org/core/log" 38 32 "tangled.org/core/rbac" 39 33 "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" 40 42 ) 41 43 42 44 type State struct { 43 45 db *db.DB 44 46 notifier notify.Notifier 47 + indexer *indexer.Indexer 45 48 oauth *oauth.OAuth 46 49 enforcer *rbac.Enforcer 47 50 pages *pages.Pages 48 - sess *session.SessionStore 49 51 idResolver *idresolver.Resolver 50 52 posthog posthog.Client 51 53 jc *jetstream.JetstreamClient ··· 58 60 } 59 61 60 62 func Make(ctx context.Context, config *config.Config) (*State, error) { 61 - d, err := db.Make(config.Core.DbPath) 63 + logger := tlog.FromContext(ctx) 64 + 65 + d, err := db.Make(ctx, config.Core.DbPath) 62 66 if err != nil { 63 67 return nil, fmt.Errorf("failed to create db: %w", err) 64 68 } 65 69 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 + 66 76 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 67 77 if err != nil { 68 78 return nil, fmt.Errorf("failed to create enforcer: %w", err) 69 79 } 70 80 71 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 81 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 72 82 if err != nil { 73 - log.Printf("failed to create redis resolver: %v", err) 74 - res = idresolver.DefaultResolver() 83 + logger.Error("failed to create redis resolver", "err", err) 84 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 75 85 } 76 86 77 - pgs := pages.NewPages(config, res) 78 - cache := cache.New(config.Redis.Addr) 79 - sess := session.New(cache) 80 - oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 82 - 83 87 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 88 if err != nil { 85 89 return nil, fmt.Errorf("failed to create posthog client: %w", err) 86 90 } 91 + 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) 87 98 88 99 repoResolver := reporesolver.New(config, enforcer, res, d) 89 100 ··· 106 117 tangled.LabelOpNSID, 107 118 }, 108 119 nil, 109 - slog.Default(), 120 + tlog.SubLogger(logger, "jetstream"), 110 121 wrapper, 111 122 false, 112 123 ··· 118 129 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 119 130 } 120 131 121 - if err := db.BackfillDefaultDefs(d, res); err != nil { 132 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 122 133 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 123 134 } 124 135 ··· 127 138 Enforcer: enforcer, 128 139 IdResolver: res, 129 140 Config: config, 130 - Logger: tlog.New("ingester"), 141 + Logger: log.SubLogger(logger, "ingester"), 131 142 Validator: validator, 132 143 } 133 144 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 148 159 spindlestream.Start(ctx) 149 160 150 161 var notifiers []notify.Notifier 162 + 163 + // Always add the database notifier 164 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 165 + 166 + // Add other notifiers in production only 151 167 if !config.Core.Dev { 152 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 168 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 153 169 } 154 - notifier := notify.NewMergedNotifier(notifiers...) 170 + notifiers = append(notifiers, indexer) 171 + notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 155 172 156 173 state := &State{ 157 174 d, 158 175 notifier, 176 + indexer, 159 177 oauth, 160 178 enforcer, 161 - pgs, 162 - sess, 179 + pages, 163 180 res, 164 181 posthog, 165 182 jc, ··· 167 184 repoResolver, 168 185 knotstream, 169 186 spindlestream, 170 - slog.Default(), 187 + logger, 171 188 validator, 172 189 } 173 190 ··· 192 209 s.pages.Favicon(w) 193 210 } 194 211 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 + 195 245 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 196 246 user := s.oauth.GetUser(r) 197 247 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 206 256 }) 207 257 } 208 258 259 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 260 + user := s.oauth.GetUser(r) 261 + s.pages.Brand(w, pages.BrandParams{ 262 + LoggedInUser: user, 263 + }) 264 + } 265 + 209 266 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 210 267 if s.oauth.GetUser(r) != nil { 211 268 s.Timeline(w, r) ··· 217 274 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 218 275 user := s.oauth.GetUser(r) 219 276 277 + // TODO: set this flag based on the UI 278 + filtered := false 279 + 220 280 var userDid string 221 281 if user != nil { 222 282 userDid = user.Did 223 283 } 224 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 284 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 225 285 if err != nil { 226 - log.Println(err) 286 + s.logger.Error("failed to make timeline", "err", err) 227 287 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 228 288 } 229 289 230 290 repos, err := db.GetTopStarredReposLastWeek(s.db) 231 291 if err != nil { 232 - log.Println(err) 292 + s.logger.Error("failed to get top starred repos", "err", err) 233 293 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 234 294 return 235 295 } 236 296 297 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 + if err != nil { 299 + // non-fatal 300 + } 301 + 237 302 s.pages.Timeline(w, pages.TimelineParams{ 238 303 LoggedInUser: user, 239 304 Timeline: timeline, 240 305 Repos: repos, 306 + GfiLabel: gfiLabel, 241 307 }) 242 308 } 243 309 244 310 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 245 311 user := s.oauth.GetUser(r) 312 + if user == nil { 313 + return 314 + } 315 + 246 316 l := s.logger.With("handler", "UpgradeBanner") 247 317 l = l.With("did", user.Did) 248 - l = l.With("handle", user.Handle) 249 318 250 319 regs, err := db.GetRegistrations( 251 320 s.db, ··· 276 345 } 277 346 278 347 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 279 - timeline, err := db.MakeTimeline(s.db, 5, "") 348 + // TODO: set this flag based on the UI 349 + filtered := false 350 + 351 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 280 352 if err != nil { 281 - log.Println(err) 353 + s.logger.Error("failed to make timeline", "err", err) 282 354 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 283 355 return 284 356 } 285 357 286 358 repos, err := db.GetTopStarredReposLastWeek(s.db) 287 359 if err != nil { 288 - log.Println(err) 360 + s.logger.Error("failed to get top starred repos", "err", err) 289 361 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 290 362 return 291 363 } ··· 314 386 315 387 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 316 388 if err != nil { 317 - w.WriteHeader(http.StatusNotFound) 389 + s.logger.Error("failed to get public keys", "err", err) 390 + http.Error(w, "failed to get public keys", http.StatusInternalServerError) 318 391 return 319 392 } 320 393 321 394 if len(pubKeys) == 0 { 322 - w.WriteHeader(http.StatusNotFound) 395 + w.WriteHeader(http.StatusNoContent) 323 396 return 324 397 } 325 398 ··· 385 458 386 459 user := s.oauth.GetUser(r) 387 460 l = l.With("did", user.Did) 388 - l = l.With("handle", user.Handle) 389 461 390 462 // form validation 391 463 domain := r.FormValue("domain") ··· 445 517 Rkey: rkey, 446 518 Description: description, 447 519 Created: time.Now(), 448 - Labels: models.DefaultLabelDefs(), 520 + Labels: s.config.Label.DefaultLabelDefs, 449 521 } 450 522 record := repo.AsRecord() 451 523 452 - xrpcClient, err := s.oauth.AuthorizedClient(r) 524 + atpClient, err := s.oauth.AuthorizedClient(r) 453 525 if err != nil { 454 526 l.Info("PDS write failed", "err", err) 455 527 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 456 528 return 457 529 } 458 530 459 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 531 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 460 532 Collection: tangled.RepoNSID, 461 533 Repo: user.Did, 462 534 Rkey: rkey, ··· 488 560 rollback := func() { 489 561 err1 := tx.Rollback() 490 562 err2 := s.enforcer.E.LoadPolicy() 491 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 563 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 492 564 493 565 // ignore txn complete errors, this is okay 494 566 if errors.Is(err1, sql.ErrTxDone) { ··· 561 633 aturi = "" 562 634 563 635 s.notifier.NewRepo(r.Context(), repo) 564 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 636 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 565 637 } 566 638 } 567 639 568 640 // this is used to rollback changes made to the PDS 569 641 // 570 642 // it is a no-op if the provided ATURI is empty 571 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 643 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 572 644 if aturi == "" { 573 645 return nil 574 646 } ··· 579 651 repo := parsed.Authority().String() 580 652 rkey := parsed.RecordKey().String() 581 653 582 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 654 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 583 655 Collection: collection, 584 656 Repo: repo, 585 657 Rkey: rkey, ··· 587 659 return err 588 660 } 589 661 590 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 591 - defaults := models.DefaultLabelDefs() 662 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 592 663 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 593 664 if err != nil { 594 665 return err ··· 598 669 return nil 599 670 } 600 671 601 - labelDefs, err := models.FetchDefaultDefs(r) 672 + labelDefs, err := models.FetchLabelDefs(r, defaults) 602 673 if err != nil { 603 674 return err 604 675 }
+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 IsHandleNoAt(s string) bool { 13 + func IsHandle(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) 16 21 } 17 22 18 23 func UnflattenDid(s string) string { ··· 45 50 return strings.Replace(s, ":", "-", 2) 46 51 } 47 52 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])?$`)
+9 -7
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 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 29 ) 28 30 29 31 type Strings struct { ··· 254 256 } 255 257 256 258 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 260 if err != nil { 259 261 fail("Failed to updated existing record.", err) 260 262 return 261 263 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 263 265 Collection: tangled.StringNSID, 264 266 Repo: entry.Did.String(), 265 267 Rkey: entry.Rkey, ··· 284 286 s.Notifier.EditString(r.Context(), &entry) 285 287 286 288 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 288 290 } 289 291 290 292 } ··· 336 338 return 337 339 } 338 340 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 340 342 Collection: tangled.StringNSID, 341 343 Repo: user.Did, 342 344 Rkey: string.Rkey, ··· 360 362 s.Notifier.NewString(r.Context(), &string) 361 363 362 364 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 364 366 } 365 367 } 366 368 ··· 403 405 404 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 407 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 407 409 } 408 410 409 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+15 -1
appview/validator/label.go
··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 102 105 if labelOp == nil { 103 106 return fmt.Errorf("label operation is required") 107 + } 108 + 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 104 118 } 105 119 106 120 expectedKey := labelDef.AtUri().String()
+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 + }
+4 -1
appview/validator/validator.go
··· 4 4 "tangled.org/core/appview/db" 5 5 "tangled.org/core/appview/pages/markup" 6 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 7 8 ) 8 9 9 10 type Validator struct { 10 11 db *db.DB 11 12 sanitizer markup.Sanitizer 12 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 13 15 } 14 16 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 16 18 return &Validator{ 17 19 db: db, 18 20 sanitizer: markup.NewSanitizer(), 19 21 resolver: res, 22 + enforcer: enforcer, 20 23 } 21 24 }
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 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 - } 115 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+14 -9
cmd/appview/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 - "log/slog" 7 5 "net/http" 8 6 "os" 9 7 10 8 "tangled.org/core/appview/config" 11 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 12 11 ) 13 12 14 13 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 18 17 19 18 c, err := config.LoadConfig(ctx) 20 19 if err != nil { 21 - log.Println("failed to load config", "error", err) 20 + logger.Error("failed to load config", "error", err) 22 21 return 23 22 } 24 23 25 24 state, err := state.Make(ctx, c) 26 25 defer func() { 27 - log.Println(state.Close()) 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 28 29 }() 29 30 30 31 if err != nil { 31 - log.Fatal(err) 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 32 34 } 33 35 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 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 + } 36 41 }
+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 - }
+6 -3
cmd/knot/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 8 "github.com/urfave/cli/v3" ··· 9 10 "tangled.org/core/hook" 10 11 "tangled.org/core/keyfetch" 11 12 "tangled.org/core/knotserver" 12 - "tangled.org/core/log" 13 + tlog "tangled.org/core/log" 13 14 ) 14 15 15 16 func main() { ··· 24 25 }, 25 26 } 26 27 28 + logger := tlog.New("knot") 29 + slog.SetDefault(logger) 30 + 27 31 ctx := context.Background() 28 - logger := log.New("knot") 29 - ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 32 + ctx = tlog.IntoContext(ctx, logger) 30 33 31 34 if err := cmd.Run(ctx, os.Args); err != nil { 32 35 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 - }
+9 -4
cmd/spindle/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 - "tangled.org/core/log" 8 + tlog "tangled.org/core/log" 8 9 "tangled.org/core/spindle" 9 - _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() { 13 - ctx := log.NewContext(context.Background(), "spindle") 13 + logger := tlog.New("spindle") 14 + slog.SetDefault(logger) 15 + 16 + ctx := context.Background() 17 + ctx = tlog.IntoContext(ctx, logger) 18 + 14 19 err := spindle.Run(ctx) 15 20 if err != nil { 16 - log.FromContext(ctx).Error("error running spindle", "error", err) 21 + logger.Error("error running spindle", "error", err) 17 22 os.Exit(-1) 18 23 } 19 24 }
+16 -6
docs/hacking.md
··· 37 37 38 38 ``` 39 39 # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_JWKS 41 - {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 40 + echo $TANGLED_OAUTH_CLIENT_SECRET 41 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 + 43 + echo $TANGLED_OAUTH_CLIENT_KID 44 + 1761667908 42 45 43 46 # if not, you can set it up yourself: 44 - go build -o genjwks.out ./cmd/genjwks 45 - export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 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..." 46 56 47 57 # run redis in at a new shell to store oauth sessions 48 58 redis-server ··· 158 168 159 169 If for any reason you wish to disable either one of the 160 170 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 - `services.tangled-spindle.enable` (or 162 - `services.tangled-knot.enable`) to `false`. 171 + `services.tangled.spindle.enable` (or 172 + `services.tangled.knot.enable`) to `false`.
+2 -1
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/knot` is a good choice: 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 43 44 44 ``` 45 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 46 47 ``` 47 48 48 49 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;
+20 -2
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`: 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. 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. 23 24 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: 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: 25 26 26 27 ```yaml 27 28 when: ··· 29 30 branch: ["main", "develop"] 30 31 - event: ["pull_request"] 31 32 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"] 32 50 ``` 33 51 34 52 ## 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 + }, 3 19 "flake-compat": { 4 20 "flake": false, 5 21 "locked": { ··· 150 166 }, 151 167 "root": { 152 168 "inputs": { 169 + "actor-typeahead-src": "actor-typeahead-src", 153 170 "flake-compat": "flake-compat", 154 171 "gomod2nix": "gomod2nix", 155 172 "htmx-src": "htmx-src",
+19 -9
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 + }; 36 40 ibm-plex-mono-src = { 37 41 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 38 42 flake = false; ··· 54 58 inter-fonts-src, 55 59 sqlite-lib-src, 56 60 ibm-plex-mono-src, 61 + actor-typeahead-src, 57 62 ... 58 63 }: let 59 64 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 78 83 inherit (pkgs) gcc; 79 84 inherit sqlite-lib-src; 80 85 }; 81 - genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 87 + goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 83 88 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 89 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 85 90 }; 86 91 appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 92 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 90 95 }); 91 96 in { 92 97 overlays.default = final: prev: { 93 - inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 98 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 94 99 }; 95 100 96 101 packages = forAllSystems (system: let ··· 99 104 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 105 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 106 in { 102 - inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 107 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 103 108 104 109 pkgsStatic-appview = staticPackages.appview; 105 110 pkgsStatic-knot = staticPackages.knot; ··· 167 172 mkdir -p appview/pages/static 168 173 # no preserve is needed because watch-tailwind will want to be able to overwrite 169 174 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 - export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 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}')" 171 177 ''; 172 178 env.CGO_ENABLED = 1; 173 179 }; ··· 206 212 watch-knot = { 207 213 type = "app"; 208 214 program = ''${air-watcher "knot" "server"}/bin/run''; 215 + }; 216 + watch-spindle = { 217 + type = "app"; 218 + program = ''${air-watcher "spindle" ""}/bin/run''; 209 219 }; 210 220 watch-tailwind = { 211 221 type = "app"; ··· 262 272 lexgen --build-file lexicon-build-config.json lexicons 263 273 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 274 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 275 + go run ./cmd/cborgen/ 266 276 lexgen --build-file lexicon-build-config.json lexicons 267 277 rm api/tangled/*.bak 268 278 ''; ··· 278 288 }: { 279 289 imports = [./nix/modules/appview.nix]; 280 290 281 - services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 291 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 282 292 }; 283 293 nixosModules.knot = { 284 294 lib, ··· 287 297 }: { 288 298 imports = [./nix/modules/knot.nix]; 289 299 290 - services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 300 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 291 301 }; 292 302 nixosModules.spindle = { 293 303 lib, ··· 296 306 }: { 297 307 imports = [./nix/modules/spindle.nix]; 298 308 299 - services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 309 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 300 310 }; 301 311 }; 302 312 }
+53 -9
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 21 github.com/go-chi/chi/v5 v5.2.0 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 24 25 github.com/google/uuid v1.6.0 25 26 github.com/gorilla/feeds v1.2.0 26 27 github.com/gorilla/sessions v1.4.0 ··· 36 37 github.com/redis/go-redis/v9 v9.7.3 37 38 github.com/resend/resend-go/v2 v2.15.0 38 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 39 42 github.com/stretchr/testify v1.10.0 40 43 github.com/urfave/cli/v3 v3.3.3 41 44 github.com/whyrusleeping/cbor-gen v0.3.1 42 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 45 + github.com/wyatt915/goldmark-treeblood v0.0.1 46 + github.com/yuin/goldmark v1.7.13 44 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 48 golang.org/x/crypto v0.40.0 49 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 46 51 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 52 + golang.org/x/sync v0.17.0 48 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 54 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 55 ) 52 56 53 57 require ( 54 58 dario.cat/mergo v1.0.1 // indirect 55 59 github.com/Microsoft/go-winio v0.6.2 // indirect 56 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 61 + github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 57 62 github.com/alecthomas/repr v0.4.0 // indirect 58 63 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 64 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 59 65 github.com/aymerick/douceur v0.2.0 // indirect 60 66 github.com/beorn7/perks v1.0.1 // indirect 61 - github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 67 + github.com/bits-and-blooms/bitset v1.22.0 // indirect 68 + github.com/blevesearch/bleve/v2 v2.5.3 // indirect 69 + github.com/blevesearch/bleve_index_api v1.2.8 // indirect 70 + github.com/blevesearch/geo v0.2.4 // indirect 71 + github.com/blevesearch/go-faiss v1.0.25 // indirect 72 + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect 73 + github.com/blevesearch/gtreap v0.1.1 // indirect 74 + github.com/blevesearch/mmap-go v1.0.4 // indirect 75 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect 76 + github.com/blevesearch/segment v0.9.1 // indirect 77 + github.com/blevesearch/snowballstem v0.9.0 // indirect 78 + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect 79 + github.com/blevesearch/vellum v1.1.0 // indirect 80 + github.com/blevesearch/zapx/v11 v11.4.2 // indirect 81 + github.com/blevesearch/zapx/v12 v12.4.2 // indirect 82 + github.com/blevesearch/zapx/v13 v13.4.2 // indirect 83 + github.com/blevesearch/zapx/v14 v14.4.2 // indirect 84 + github.com/blevesearch/zapx/v15 v15.4.2 // indirect 85 + github.com/blevesearch/zapx/v16 v16.2.4 // indirect 86 + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect 62 87 github.com/casbin/govaluate v1.3.0 // indirect 63 88 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 64 89 github.com/cespare/xxhash/v2 v2.3.0 // indirect 90 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 91 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 92 + github.com/charmbracelet/log v0.4.2 // 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 65 96 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 66 97 github.com/containerd/errdefs v1.0.0 // indirect 67 98 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 80 111 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 81 112 github.com/go-git/go-billy/v5 v5.6.2 // indirect 82 113 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 114 + github.com/go-logfmt/logfmt v0.6.0 // indirect 83 115 github.com/go-logr/logr v1.4.3 // indirect 84 116 github.com/go-logr/stdr v1.2.2 // indirect 85 117 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 89 121 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 90 122 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 91 123 github.com/golang/mock v1.6.0 // indirect 124 + github.com/golang/protobuf v1.5.4 // indirect 125 + github.com/golang/snappy v0.0.4 // indirect 92 126 github.com/google/go-querystring v1.1.0 // indirect 93 127 github.com/gorilla/css v1.0.1 // indirect 94 128 github.com/gorilla/securecookie v1.1.2 // indirect ··· 114 148 github.com/ipfs/go-log v1.0.5 // indirect 115 149 github.com/ipfs/go-log/v2 v2.6.0 // indirect 116 150 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 151 + github.com/json-iterator/go v1.1.12 // indirect 117 152 github.com/kevinburke/ssh_config v1.2.0 // indirect 118 153 github.com/klauspost/compress v1.18.0 // indirect 119 154 github.com/klauspost/cpuid/v2 v2.3.0 // indirect ··· 122 157 github.com/lestrrat-go/httprc v1.0.6 // indirect 123 158 github.com/lestrrat-go/iter v1.0.2 // indirect 124 159 github.com/lestrrat-go/option v1.0.1 // indirect 160 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 125 161 github.com/mattn/go-isatty v0.0.20 // indirect 162 + github.com/mattn/go-runewidth v0.0.16 // indirect 126 163 github.com/minio/sha256-simd v1.0.1 // indirect 127 164 github.com/mitchellh/mapstructure v1.5.0 // indirect 128 165 github.com/moby/docker-image-spec v1.3.1 // indirect 129 166 github.com/moby/sys/atomicwriter v0.1.0 // indirect 130 167 github.com/moby/term v0.5.2 // indirect 168 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 169 + github.com/modern-go/reflect2 v1.0.2 // indirect 131 170 github.com/morikuni/aec v1.0.0 // indirect 132 171 github.com/mr-tron/base58 v1.2.0 // indirect 172 + github.com/mschoch/smat v0.2.0 // indirect 173 + github.com/muesli/termenv v0.16.0 // indirect 133 174 github.com/multiformats/go-base32 v0.1.0 // indirect 134 175 github.com/multiformats/go-base36 v0.2.0 // indirect 135 176 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 148 189 github.com/prometheus/client_model v0.6.2 // indirect 149 190 github.com/prometheus/common v0.64.0 // indirect 150 191 github.com/prometheus/procfs v0.16.1 // indirect 192 + github.com/rivo/uniseg v0.4.7 // indirect 151 193 github.com/ryanuber/go-glob v1.0.0 // indirect 152 194 github.com/segmentio/asm v1.2.0 // indirect 153 195 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 155 197 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 156 198 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 199 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 - github.com/wyatt915/treeblood v0.1.15 // indirect 200 + github.com/wyatt915/treeblood v0.1.16 // indirect 201 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 202 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 159 203 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 204 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 205 + go.etcd.io/bbolt v1.4.0 // indirect 161 206 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 162 207 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 163 208 go.opentelemetry.io/otel v1.37.0 // indirect ··· 168 213 go.uber.org/atomic v1.11.0 // indirect 169 214 go.uber.org/multierr v1.11.0 // indirect 170 215 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 216 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 217 + golang.org/x/text v0.29.0 // indirect 174 218 golang.org/x/time v0.12.0 // indirect 175 219 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 220 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+109 -16
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= 12 14 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 15 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 16 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 19 21 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 22 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 23 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= 22 26 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 27 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 28 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 29 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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= 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= 28 71 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 72 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 73 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 31 74 github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 32 75 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 76 + github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 77 + github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 33 78 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 34 79 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 35 80 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 48 93 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 94 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 95 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 96 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 97 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 98 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 99 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 100 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 101 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 102 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 103 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 104 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 105 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 106 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 107 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 51 108 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 109 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 110 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 120 177 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 178 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 179 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 180 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 181 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 123 182 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 183 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 184 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 136 195 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 196 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 197 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 198 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 199 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 139 200 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 201 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 202 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 152 213 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 153 214 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 154 215 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 216 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 217 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 218 + github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 219 + github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 155 220 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 156 221 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 157 222 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 163 228 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 164 229 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 165 230 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 231 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 166 232 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 167 233 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 168 234 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 243 309 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 310 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 311 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 246 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 247 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 312 + github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 313 + github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 248 314 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 315 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 316 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 276 342 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 343 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 344 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 345 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 346 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 279 347 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 348 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 349 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 350 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 351 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 352 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 283 353 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 354 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 355 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 296 366 github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 297 367 github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 298 368 github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 369 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 370 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 371 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 372 + github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 373 + github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 299 374 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 300 375 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 376 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 377 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 378 + github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= 379 + github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 380 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 381 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 303 382 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 383 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 384 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 456 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 457 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 458 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 459 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 460 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 461 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 380 462 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 463 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 464 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 399 481 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 482 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 483 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 484 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 485 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 486 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 487 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 402 488 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 489 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 490 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 426 512 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 427 513 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 428 514 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 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= 515 + github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs= 516 + github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208= 517 + github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y= 518 + github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 519 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 520 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 433 521 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 522 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 523 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 524 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 525 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 526 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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= 527 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 528 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 529 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 530 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 531 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 532 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 443 533 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 534 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 535 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 446 536 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 537 + go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 538 + go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 447 539 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 448 540 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 449 541 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= ··· 489 581 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 582 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 583 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 584 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 585 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 492 586 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 587 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 588 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 622 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 623 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 624 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 531 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 625 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 626 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 533 627 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 628 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 629 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 677 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 678 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 679 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 586 - golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 - golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 680 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 681 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 588 682 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 683 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 684 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 645 739 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 646 740 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 647 741 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 742 + gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 648 743 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 649 744 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 650 745 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= ··· 652 747 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 748 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 749 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= 657 750 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 751 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+36 -61
guard/guard.go
··· 12 12 "os/exec" 13 13 "strings" 14 14 15 - "github.com/bluesky-social/indigo/atproto/identity" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/urfave/cli/v3" 18 - "tangled.org/core/idresolver" 19 17 "tangled.org/core/log" 20 18 ) 21 19 ··· 93 91 "command", sshCommand, 94 92 "client", clientIP) 95 93 94 + // TODO: greet user with their resolved handle instead of did 96 95 if sshCommand == "" { 97 96 l.Info("access denied: no interactive shells", "user", incomingUser) 98 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 107 106 } 108 107 109 108 gitCommand := cmdParts[0] 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) 109 + repoPath := cmdParts[1] 129 110 130 111 validCommands := map[string]bool{ 131 112 "git-receive-pack": true, ··· 138 119 return fmt.Errorf("access denied: invalid git command") 139 120 } 140 121 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 - } 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) 149 128 } 150 129 151 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 130 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 152 131 153 132 l.Info("processing command", 154 133 "user", incomingUser, 155 134 "command", gitCommand, 156 - "repo", repoName, 135 + "repo", repoPath, 157 136 "fullPath", fullPath, 158 137 "client", clientIP) 159 138 ··· 177 156 gitCmd.Stdin = os.Stdin 178 157 gitCmd.Env = append(os.Environ(), 179 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 180 - fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 181 159 ) 182 160 183 161 if err := gitCmd.Run(); err != nil { ··· 189 167 l.Info("command completed", 190 168 "user", incomingUser, 191 169 "command", gitCommand, 192 - "repo", repoName, 170 + "repo", repoPath, 193 171 "success", true) 194 172 195 173 return nil 196 174 } 197 175 198 - func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 - resolver := idresolver.DefaultResolver() 200 - ident, err := resolver.ResolveIdent(ctx, didOrHandle) 176 + // runs guardAndQualifyRepo logic 177 + func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 + u, _ := url.Parse(endpoint + "/guard") 179 + q := u.Query() 180 + q.Add("user", incomingUser) 181 + q.Add("repo", repo) 182 + q.Add("gitCmd", gitCommand) 183 + u.RawQuery = q.Encode() 184 + 185 + resp, err := http.Get(u.String()) 201 186 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) 187 + return "", err 210 188 } 211 - return ident 212 - } 189 + defer resp.Body.Close() 213 190 214 - func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 215 - u, _ := url.Parse(endpoint + "/push-allowed") 216 - q := u.Query() 217 - q.Add("user", user) 218 - q.Add("repo", qualifiedRepoName) 219 - u.RawQuery = q.Encode() 191 + l.Info("Running guard", "url", u.String(), "status", resp.Status) 220 192 221 - req, err := http.Get(u.String()) 193 + body, err := io.ReadAll(resp.Body) 222 194 if err != nil { 223 - l.Error("Error verifying permissions", "error", err) 224 - fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 225 - os.Exit(1) 195 + return "", err 226 196 } 227 - 228 - l.Info("Checking push permission", 229 - "url", u.String(), 230 - "status", req.Status) 197 + text := string(body) 231 198 232 - return req.StatusCode == http.StatusNoContent 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 + } 233 208 }
+17 -8
idresolver/resolver.go
··· 17 17 directory identity.Directory 18 18 } 19 19 20 - func BaseDirectory() identity.Directory { 20 + func BaseDirectory(plcUrl string) identity.Directory { 21 21 base := identity.BaseDirectory{ 22 - PLCURL: identity.DefaultPLCURL, 22 + PLCURL: plcUrl, 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 string) (identity.Directory, error) { 45 + func RedisDirectory(url, plcUrl 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(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 49 + return redisdir.NewRedisDirectory( 50 + BaseDirectory(plcUrl), 51 + url, 52 + hitTTL, 53 + errTTL, 54 + invalidHandleTTL, 55 + 10000, 56 + ) 50 57 } 51 58 52 - func DefaultResolver() *Resolver { 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) 53 62 return &Resolver{ 54 - directory: identity.DefaultDirectory(), 63 + directory: &cached, 55 64 } 56 65 } 57 66 58 - func RedisResolver(redisUrl string) (*Resolver, error) { 59 - directory, err := RedisDirectory(redisUrl) 67 + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 + directory, err := RedisDirectory(redisUrl, plcUrl) 60 69 if err != nil { 61 70 return nil, err 62 71 }
+109 -12
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; 161 + @apply no-underline; 162 + } 163 + 164 + .prose a.mention { 165 + @apply no-underline hover:underline; 162 166 } 163 167 164 168 .prose li { 165 - @apply my-0 py-0; 169 + @apply my-0 py-0; 166 170 } 167 171 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 172 + .prose ul, 173 + .prose ol { 174 + @apply my-1 py-0; 170 175 } 171 176 172 177 .prose img { ··· 176 181 } 177 182 178 183 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 184 + @apply inline-block my-0 mb-1 mx-1; 180 185 } 181 186 182 187 .prose input[type="checkbox"] { 183 188 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 189 } 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 + 185 249 } 186 250 @layer utilities { 187 251 .error { ··· 228 292 } 229 293 /* LineHighlight */ 230 294 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 295 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 296 } 233 297 234 298 /* LineNumbersTable */ ··· 865 929 text-decoration: underline; 866 930 } 867 931 } 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, log.New("jetstream"), sched) 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 118 if err != nil { 119 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 120 }
+2 -1
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"` 22 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 24 Owner string `env:"OWNER, required"` 24 25 LogDids bool `env:"LOG_DIDS, default=true"` ··· 41 42 Repo Repo `env:",prefix=KNOT_REPO_"` 42 43 Server Server `env:",prefix=KNOT_SERVER_"` 43 44 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 46 } 46 47 47 48 func Load(ctx context.Context) (*Config, error) {
+2 -3
knotserver/events.go
··· 8 8 "time" 9 9 10 10 "github.com/gorilla/websocket" 11 + "tangled.org/core/log" 11 12 ) 12 13 13 14 var upgrader = websocket.Upgrader{ ··· 16 17 } 17 18 18 19 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 - l := h.l.With("handler", "OpLog") 20 + l := log.SubLogger(h.l, "eventstream") 20 21 l.Debug("received new connection") 21 22 22 23 conn, err := upgrader.Upgrade(w, r, nil) ··· 75 76 } 76 77 case <-time.After(30 * time.Second): 77 78 // 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) 93 92 94 93 for _, event := range events { 95 94 // 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 + }
+11 -103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 29 30 - type TagList struct { 31 - refs []*TagReference 32 - r *git.Repository 33 - } 34 - 35 - // TagReference is used to list both tag and non-annotated tags. 36 - // Non-annotated tags should only contains a reference. 37 - // Annotated tags should contain its reference and its tag information. 38 - type TagReference struct { 39 - ref *plumbing.Reference 40 - tag *object.Tag 41 - } 42 - 43 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 50 37 isDir bool 51 38 } 52 39 53 - func (self *TagList) Len() int { 54 - return len(self.refs) 55 - } 56 - 57 - func (self *TagList) Swap(i, j int) { 58 - self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 59 - } 60 - 61 - // sorting tags in reverse chronological order 62 - func (self *TagList) Less(i, j int) bool { 63 - var dateI time.Time 64 - var dateJ time.Time 65 - 66 - if self.refs[i].tag != nil { 67 - dateI = self.refs[i].tag.Tagger.When 68 - } else { 69 - c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 70 - if err != nil { 71 - dateI = time.Now() 72 - } else { 73 - dateI = c.Committer.When 74 - } 75 - } 76 - 77 - if self.refs[j].tag != nil { 78 - dateJ = self.refs[j].tag.Tagger.When 79 - } else { 80 - c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 81 - if err != nil { 82 - dateJ = time.Now() 83 - } else { 84 - dateJ = c.Committer.When 85 - } 86 - } 87 - 88 - return dateI.After(dateJ) 89 - } 90 - 91 40 func Open(path string, ref string) (*GitRepo, error) { 92 41 var err error 93 42 g := GitRepo{path: path} ··· 122 71 return &g, nil 123 72 } 124 73 74 + // re-open a repository and update references 75 + func (g *GitRepo) Refresh() error { 76 + refreshed, err := PlainOpen(g.path) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + *g = *refreshed 82 + return nil 83 + } 84 + 125 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 126 86 commits := []*object.Commit{} 127 87 ··· 171 131 return g.r.CommitObject(h) 172 132 } 173 133 174 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 175 - c, err := g.r.CommitObject(g.h) 176 - if err != nil { 177 - return nil, fmt.Errorf("last commit: %w", err) 178 - } 179 - return c, nil 180 - } 181 - 182 134 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 135 c, err := g.r.CommitObject(g.h) 184 136 if err != nil { ··· 211 163 } 212 164 213 165 return buf.Bytes(), nil 214 - } 215 - 216 - func (g *GitRepo) FileContent(path string) (string, error) { 217 - c, err := g.r.CommitObject(g.h) 218 - if err != nil { 219 - return "", fmt.Errorf("commit object: %w", err) 220 - } 221 - 222 - tree, err := c.Tree() 223 - if err != nil { 224 - return "", fmt.Errorf("file tree: %w", err) 225 - } 226 - 227 - file, err := tree.File(path) 228 - if err != nil { 229 - return "", err 230 - } 231 - 232 - isbin, _ := file.IsBinary() 233 - 234 - if !isbin { 235 - return file.Contents() 236 - } else { 237 - return "", ErrBinaryFile 238 - } 239 166 } 240 167 241 168 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 337 func (i *infoWrapper) Sys() any { 411 338 return nil 412 339 } 413 - 414 - func (t *TagReference) Name() string { 415 - return t.ref.Name().Short() 416 - } 417 - 418 - func (t *TagReference) Message() string { 419 - if t.tag != nil { 420 - return t.tag.Message 421 - } 422 - return "" 423 - } 424 - 425 - func (t *TagReference) TagObject() *object.Tag { 426 - return t.tag 427 - } 428 - 429 - func (t *TagReference) Hash() plumbing.Hash { 430 - return t.ref.Hash() 431 - }
+21 -2
knotserver/git/last_commit.go
··· 30 30 commitCache = cache 31 31 } 32 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 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) { 34 48 args := []string{} 35 49 args = append(args, "log") 36 50 args = append(args, g.h.String()) ··· 48 62 return nil, err 49 63 } 50 64 51 - return stdout, nil 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 52 70 } 53 71 54 72 type commit struct { ··· 104 122 if err != nil { 105 123 return nil, err 106 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 107 126 108 127 reader := bufio.NewReader(output) 109 128 var current commit
+150 -37
knotserver/git/merge.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "fmt" 7 + "log" 7 8 "os" 8 9 "os/exec" 9 10 "regexp" ··· 12 13 "github.com/dgraph-io/ristretto" 13 14 "github.com/go-git/go-git/v5" 14 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 15 18 ) 16 19 17 20 type MergeCheckCache struct { ··· 32 35 mergeCheckCache = MergeCheckCache{cache} 33 36 } 34 37 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 36 39 sep := byte(':') 37 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 41 return fmt.Sprintf("%x", hash) ··· 49 52 } 50 53 } 51 54 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 53 56 key := m.cacheKey(g, patch, targetBranch) 54 57 val := m.cacheVal(mergeCheck) 55 58 m.cache.Set(key, val, 0) 56 59 } 57 60 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 59 62 key := m.cacheKey(g, patch, targetBranch) 60 63 if val, ok := m.cache.Get(key); ok { 61 64 if val == struct{}{} { ··· 104 107 return fmt.Sprintf("merge failed: %s", e.Message) 105 108 } 106 109 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 108 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 112 if err != nil { 110 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 114 } 112 115 113 - if _, err := tmpFile.Write(patchData); err != nil { 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 114 117 tmpFile.Close() 115 118 os.Remove(tmpFile.Name()) 116 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 165 return nil 163 166 } 164 167 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 166 169 var stderr bytes.Buffer 167 170 var cmd *exec.Cmd 168 171 169 172 // configure default git user before merge 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() 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() 173 176 174 177 // if patch is a format-patch, apply using 'git am' 175 178 if opts.FormatPatch { 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 - } 179 + return g.applyMailbox(patchData) 180 + } 184 181 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 - } 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 + } 189 188 190 - commitArgs := []string{"-C", tmpDir, "commit"} 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 + } 191 193 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 194 + commitArgs := []string{"-C", g.path, "commit"} 195 195 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 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 200 199 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 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 202 204 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 206 207 - cmd = exec.Command("git", commitArgs...) 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 208 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 209 212 210 213 cmd.Stderr = &stderr 211 214 ··· 216 219 return nil 217 220 } 218 221 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 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 { 220 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 329 return val 222 330 } ··· 244 352 return result 245 353 } 246 354 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 248 356 patchFile, err := g.createTempFileWithPatch(patchData) 249 357 if err != nil { 250 358 return &ErrMerge{ ··· 263 371 } 264 372 defer os.RemoveAll(tmpDir) 265 373 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 267 380 return err 268 381 } 269 382
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
+18 -18
knotserver/git.go
··· 13 13 "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (h *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 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 22 + h.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(d.c.Repo.ScanPath, repoName) 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 27 if err != nil { 28 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 29 + h.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 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 50 return 51 51 } 52 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 53 + h.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 (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (h *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(d.c.Repo.ScanPath, filepath.Join(did, name)) 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 63 if err != nil { 64 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 65 + h.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 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 80 + h.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 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 91 + h.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 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 104 return 105 105 } 106 106 } 107 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (h *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(d.c.Repo.ScanPath, filepath.Join(did, name)) 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 112 if err != nil { 113 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 115 return 116 116 } 117 117 118 - d.RejectPush(w, r, name) 118 + h.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (h *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 := d.c.Server.Hostname 134 + hostname := h.c.Server.Hostname 135 135 if strings.Contains(hostname, ":") { 136 136 hostname = strings.Split(hostname, ":")[0] 137 137 }
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+3 -7
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" 20 19 "tangled.org/core/knotserver/db" 21 20 "tangled.org/core/knotserver/git" 22 21 "tangled.org/core/log" ··· 120 119 } 121 120 122 121 // resolve this aturi to extract the repo record 123 - resolver := idresolver.DefaultResolver() 124 - ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 123 if err != nil || ident.Handle.IsInvalidHandle() { 126 124 return fmt.Errorf("failed to resolve handle: %w", err) 127 125 } ··· 233 231 return err 234 232 } 235 233 236 - resolver := idresolver.DefaultResolver() 237 - 238 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 234 + subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 239 235 if err != nil || subjectId.Handle.IsInvalidHandle() { 240 236 return err 241 237 } 242 238 243 239 // TODO: fix this for good, we need to fetch the record here unfortunately 244 240 // resolve this aturi to extract the repo record 245 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 241 + owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 242 if err != nil || owner.Handle.IsInvalidHandle() { 247 243 return fmt.Errorf("failed to resolve handle: %w", err) 248 244 }
+153 -7
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" 16 17 "tangled.org/core/api/tangled" 17 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 18 20 "tangled.org/core/knotserver/config" 19 21 "tangled.org/core/knotserver/db" 20 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 21 24 "tangled.org/core/notifier" 22 25 "tangled.org/core/rbac" 23 26 "tangled.org/core/workflow" 24 27 ) 25 28 26 29 type InternalHandle struct { 27 - db *db.DB 28 - c *config.Config 29 - e *rbac.Enforcer 30 - l *slog.Logger 31 - n *notifier.Notifier 30 + db *db.DB 31 + c *config.Config 32 + e *rbac.Enforcer 33 + l *slog.Logger 34 + n *notifier.Notifier 35 + res *idresolver.Resolver 32 36 } 33 37 34 38 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 64 68 writeJSON(w, data) 65 69 } 66 70 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 + 67 131 type PushOptions struct { 68 132 skipCi bool 69 133 verboseCi bool ··· 115 179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 116 180 if err != nil { 117 181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 182 + // non-fatal 183 + } 184 + 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) 118 188 // non-fatal 119 189 } 120 190 ··· 173 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 174 244 } 175 245 176 - func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 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 { 177 254 if pushOptions.skipCi { 178 255 return nil 179 256 } ··· 268 345 return h.db.InsertEvent(event, h.n) 269 346 } 270 347 271 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 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 { 272 413 r := chi.NewRouter() 414 + l := log.FromContext(ctx) 415 + l = log.SubLogger(l, "internal") 416 + res := idresolver.DefaultResolver(c.Server.PlcUrl) 273 417 274 418 h := InternalHandle{ 275 419 db, ··· 277 421 e, 278 422 l, 279 423 n, 424 + res, 280 425 } 281 426 282 427 r.Get("/push-allowed", h.PushAllowed) 283 428 r.Get("/keys", h.InternalKeys) 429 + r.Get("/guard", h.Guard) 284 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 285 431 r.Mount("/debug", middleware.Profiler()) 286 432
+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 + }
+18 -10
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 - tlog "tangled.org/core/log" 15 + "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, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 - r := chi.NewRouter() 33 - 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 34 32 h := Knot{ 35 33 c: c, 36 34 db: db, 37 35 e: e, 38 - l: l, 36 + l: log.FromContext(ctx), 39 37 jc: jc, 40 38 n: n, 41 - resolver: idresolver.DefaultResolver(), 39 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 42 40 } 43 41 44 42 err := e.AddKnot(rbac.ThisServer) ··· 67 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 66 } 69 67 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 + 70 77 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 78 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 79 }) ··· 86 93 // Socket that streams git oplogs 87 94 r.Get("/events", h.Events) 88 95 89 - return r, nil 96 + return r 90 97 } 91 98 92 99 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 94 - 95 100 serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 101 + 102 + l := log.SubLogger(h.l, "xrpc") 96 103 97 104 xrpc := &xrpc.Xrpc{ 98 105 Config: h.c, 99 106 Db: h.db, 100 107 Ingester: h.jc, 101 108 Enforcer: h.e, 102 - Logger: logger, 109 + Logger: l, 103 110 Notifier: h.n, 104 111 Resolver: h.resolver, 105 112 ServiceAuth: serviceAuth, 106 113 } 114 + 107 115 return xrpc.Router() 108 116 } 109 117
+5 -4
knotserver/server.go
··· 43 43 44 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 47 48 48 49 c, err := config.Load(ctx) 49 50 if err != nil { ··· 80 81 tangled.KnotMemberNSID, 81 82 tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil { 85 86 logger.Error("failed to setup jetstream", "error", err) 86 87 } 87 88 88 89 notifier := notifier.New() 89 90 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 91 92 if err != nil { 92 93 return fmt.Errorf("failed to setup server: %w", err) 93 94 } 94 95 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 + imux := Internal(ctx, c, db, e, &notifier) 96 97 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 99 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([]byte(data.Patch), data.Branch, mo) 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 89 if err != nil { 90 90 var mergeErr *git.ErrMerge 91 91 if errors.As(err, &mergeErr) {
+3 -1
knotserver/xrpc/merge_check.go
··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 54 + err = gr.MergeCheck(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) 83 85 84 86 w.Header().Set("Content-Type", "application/json") 85 87 w.WriteHeader(http.StatusOK)
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 8 "tangled.org/core/knotserver/git" 8 9 "tangled.org/core/types" 9 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 72 return 72 73 } 73 74 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 + 74 88 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 79 95 } 80 96 81 97 writeJson(w, response)
+24
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 9 11 "tangled.org/core/knotserver/git" 10 12 xrpcerr "tangled.org/core/xrpc/errors" 11 13 ) ··· 43 45 return 44 46 } 45 47 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 46 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 68 for i, file := range files { ··· 83 103 Parent: parentPtr, 84 104 Dotdot: dotdotPtr, 85 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 86 110 } 87 111 88 112 writeJson(w, response)
+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) 41 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
-158
legal/privacy.md
··· 1 - # Privacy Policy 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - This Privacy Policy describes how Tangled ("we," "us," or "our") 6 - collects, uses, and shares your personal information when you use our 7 - platform and services (the "Service"). 8 - 9 - ## 1. Information We Collect 10 - 11 - ### Account Information 12 - 13 - When you create an account, we collect: 14 - 15 - - Your chosen username 16 - - Email address 17 - - Profile information you choose to provide 18 - - Authentication data 19 - 20 - ### Content and Activity 21 - 22 - We store: 23 - 24 - - Code repositories and associated metadata 25 - - Issues, pull requests, and comments 26 - - Activity logs and usage patterns 27 - - Public keys for authentication 28 - 29 - ## 2. Data Location and Hosting 30 - 31 - ### EU Data Hosting 32 - 33 - **All Tangled service data is hosted within the European Union.** 34 - Specifically: 35 - 36 - - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 - (*.tngl.sh) are located in Finland 38 - - **Application Data:** All other service data is stored on EU-based 39 - servers 40 - - **Data Processing:** All data processing occurs within EU 41 - jurisdiction 42 - 43 - ### External PDS Notice 44 - 45 - **Important:** If your account is hosted on Bluesky's PDS or other 46 - self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 - that data. The data protection, storage location, and privacy 48 - practices for such accounts are governed by the respective PDS 49 - provider's policies, not this Privacy Policy. We only control data 50 - processing within our own services and infrastructure. 51 - 52 - ## 3. Third-Party Data Processors 53 - 54 - We only share your data with the following third-party processors: 55 - 56 - ### Resend (Email Services) 57 - 58 - - **Purpose:** Sending transactional emails (account verification, 59 - notifications) 60 - - **Data Shared:** Email address and necessary message content 61 - 62 - ### Cloudflare (Image Caching) 63 - 64 - - **Purpose:** Caching and optimizing image delivery 65 - - **Data Shared:** Public images and associated metadata for caching 66 - purposes 67 - 68 - ### Posthog (Usage Metrics Tracking) 69 - 70 - - **Purpose:** Tracking usage and platform metrics 71 - - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 - information 73 - 74 - ## 4. How We Use Your Information 75 - 76 - We use your information to: 77 - 78 - - Provide and maintain the Service 79 - - Process your transactions and requests 80 - - Send you technical notices and support messages 81 - - Improve and develop new features 82 - - Ensure security and prevent fraud 83 - - Comply with legal obligations 84 - 85 - ## 5. Data Sharing and Disclosure 86 - 87 - We do not sell, trade, or rent your personal information. We may share 88 - your information only in the following circumstances: 89 - 90 - - With the third-party processors listed above 91 - - When required by law or legal process 92 - - To protect our rights, property, or safety, or that of our users 93 - - In connection with a merger, acquisition, or sale of assets (with 94 - appropriate protections) 95 - 96 - ## 6. Data Security 97 - 98 - We implement appropriate technical and organizational measures to 99 - protect your personal information against unauthorized access, 100 - alteration, disclosure, or destruction. However, no method of 101 - transmission over the Internet is 100% secure. 102 - 103 - ## 7. Data Retention 104 - 105 - We retain your personal information for as long as necessary to provide 106 - the Service and fulfill the purposes outlined in this Privacy Policy, 107 - unless a longer retention period is required by law. 108 - 109 - ## 8. Your Rights 110 - 111 - Under applicable data protection laws, you have the right to: 112 - 113 - - Access your personal information 114 - - Correct inaccurate information 115 - - Request deletion of your information 116 - - Object to processing of your information 117 - - Data portability 118 - - Withdraw consent (where applicable) 119 - 120 - ## 9. Cookies and Tracking 121 - 122 - We use cookies and similar technologies to: 123 - 124 - - Maintain your login session 125 - - Remember your preferences 126 - - Analyze usage patterns to improve the Service 127 - 128 - You can control cookie settings through your browser preferences. 129 - 130 - ## 10. Children's Privacy 131 - 132 - The Service is not intended for children under 16 years of age. We do 133 - not knowingly collect personal information from children under 16. If 134 - we become aware that we have collected such information, we will take 135 - steps to delete it. 136 - 137 - ## 11. International Data Transfers 138 - 139 - While all our primary data processing occurs within the EU, some of our 140 - third-party processors may process data outside the EU. When this 141 - occurs, we ensure appropriate safeguards are in place, such as Standard 142 - Contractual Clauses or adequacy decisions. 143 - 144 - ## 12. Changes to This Privacy Policy 145 - 146 - We may update this Privacy Policy from time to time. We will notify you 147 - of any changes by posting the new Privacy Policy on this page and 148 - updating the "Last updated" date. 149 - 150 - ## 13. Contact Information 151 - 152 - If you have any questions about this Privacy Policy or wish to exercise 153 - your rights, please contact us through our platform or via email. 154 - 155 - --- 156 - 157 - This Privacy Policy complies with the EU General Data Protection 158 - Regulation (GDPR) and other applicable data protection laws.
-109
legal/terms.md
··· 1 - # Terms of Service 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 - to and use of the Tangled platform and services (the "Service") 7 - operated by us ("Tangled," "we," "us," or "our"). 8 - 9 - ## 1. Acceptance of Terms 10 - 11 - By accessing or using our Service, you agree to be bound by these Terms. 12 - If you disagree with any part of these terms, then you may not access 13 - the Service. 14 - 15 - ## 2. Account Registration 16 - 17 - To use certain features of the Service, you must register for an 18 - account. You agree to provide accurate, current, and complete 19 - information during the registration process and to update such 20 - information to keep it accurate, current, and complete. 21 - 22 - ## 3. Account Termination 23 - 24 - > **Important Notice** 25 - > 26 - > **We reserve the right to terminate, suspend, or restrict access to 27 - > your account at any time, for any reason, or for no reason at all, at 28 - > our sole discretion.** This includes, but is not limited to, 29 - > termination for violation of these Terms, inappropriate conduct, spam, 30 - > abuse, or any other behavior we deem harmful to the Service or other 31 - > users. 32 - > 33 - > Account termination may result in the loss of access to your 34 - > repositories, data, and other content associated with your account. We 35 - > are not obligated to provide advance notice of termination, though we 36 - > may do so in our discretion. 37 - 38 - ## 4. Acceptable Use 39 - 40 - You agree not to use the Service to: 41 - 42 - - Violate any applicable laws or regulations 43 - - Infringe upon the rights of others 44 - - Upload, store, or share content that is illegal, harmful, threatening, 45 - abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 - objectionable 47 - - Engage in spam, phishing, or other deceptive practices 48 - - Attempt to gain unauthorized access to the Service or other users' 49 - accounts 50 - - Interfere with or disrupt the Service or servers connected to the 51 - Service 52 - 53 - ## 5. Content and Intellectual Property 54 - 55 - You retain ownership of the content you upload to the Service. By 56 - uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 - license to use, reproduce, modify, and distribute your content as 58 - necessary to provide the Service. 59 - 60 - ## 6. Privacy 61 - 62 - Your privacy is important to us. Please review our [Privacy 63 - Policy](/privacy), which also governs your use of the Service. 64 - 65 - ## 7. Disclaimers 66 - 67 - The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 - no warranties, expressed or implied, and hereby disclaim and negate all 69 - other warranties including without limitation, implied warranties or 70 - conditions of merchantability, fitness for a particular purpose, or 71 - non-infringement of intellectual property or other violation of rights. 72 - 73 - ## 8. Limitation of Liability 74 - 75 - In no event shall Tangled, nor its directors, employees, partners, 76 - agents, suppliers, or affiliates, be liable for any indirect, 77 - incidental, special, consequential, or punitive damages, including 78 - without limitation, loss of profits, data, use, goodwill, or other 79 - intangible losses, resulting from your use of the Service. 80 - 81 - ## 9. Indemnification 82 - 83 - You agree to defend, indemnify, and hold harmless Tangled and its 84 - affiliates, officers, directors, employees, and agents from and against 85 - any and all claims, damages, obligations, losses, liabilities, costs, 86 - or debt, and expenses (including attorney's fees). 87 - 88 - ## 10. Governing Law 89 - 90 - These Terms shall be interpreted and governed by the laws of Finland, 91 - without regard to its conflict of law provisions. 92 - 93 - ## 11. Changes to Terms 94 - 95 - We reserve the right to modify or replace these Terms at any time. If a 96 - revision is material, we will try to provide at least 30 days notice 97 - prior to any new terms taking effect. 98 - 99 - ## 12. Contact Information 100 - 101 - If you have any questions about these Terms of Service, please contact 102 - us through our platform or via email. 103 - 104 - --- 105 - 106 - These terms are effective as of the last updated date shown above and 107 - will remain in effect except with respect to any changes in their 108 - provisions in the future, which will be in effect immediately after 109 - being posted on this page.
+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 67 72 } 68 73 } 69 74 }
+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 + }, 35 50 "source": { 36 51 "type": "string", 37 52 "format": "uri",
+19
lexicons/repo/tree.json
··· 41 41 "type": "string", 42 42 "description": "Parent directory path" 43 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 44 49 "files": { 45 50 "type": "array", 46 51 "items": { ··· 69 74 "description": "Invalid request parameters" 70 75 } 71 76 ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 72 91 }, 73 92 "treeEntry": { 74 93 "type": "object",
+23 -9
log/log.go
··· 4 4 "context" 5 5 "log/slog" 6 6 "os" 7 + 8 + "github.com/charmbracelet/log" 7 9 ) 8 10 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 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 - Level: slog.LevelDebug, 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 14 16 }) 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 20 17 } 21 18 22 19 func New(name string) *slog.Logger { ··· 49 46 50 47 return slog.Default() 51 48 } 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 + }
+149 -17
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=" 16 19 [mod."github.com/alecthomas/assert/v2"] 17 20 version = "v2.11.0" 18 21 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 29 32 [mod."github.com/avast/retry-go/v4"] 30 33 version = "v4.6.1" 31 34 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 35 + [mod."github.com/aymanbagabas/go-osc52/v2"] 36 + version = "v2.0.1" 37 + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 32 38 [mod."github.com/aymerick/douceur"] 33 39 version = "v0.2.0" 34 40 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" 35 41 [mod."github.com/beorn7/perks"] 36 42 version = "v1.0.1" 37 43 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=" 38 101 [mod."github.com/bluekeyes/go-gitdiff"] 39 102 version = "v0.8.2" 40 103 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 104 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 105 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 106 + version = "v0.0.0-20251003000214-3259b215110e" 107 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 108 [mod."github.com/bluesky-social/jetstream"] 46 109 version = "v0.0.0-20241210005130-ea96859b93d1" 47 110 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 48 111 [mod."github.com/bmatcuk/doublestar/v4"] 49 - version = "v4.7.1" 50 - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 112 + version = "v4.9.1" 113 + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 51 114 [mod."github.com/carlmjohnson/versioninfo"] 52 115 version = "v0.22.5" 53 116 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 63 126 [mod."github.com/cespare/xxhash/v2"] 64 127 version = "v2.3.0" 65 128 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=" 66 147 [mod."github.com/cloudflare/circl"] 67 148 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 149 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 145 226 [mod."github.com/go-jose/go-jose/v3"] 146 227 version = "v3.0.4" 147 228 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 229 + [mod."github.com/go-logfmt/logfmt"] 230 + version = "v0.6.0" 231 + hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 148 232 [mod."github.com/go-logr/logr"] 149 233 version = "v1.4.3" 150 234 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 163 247 [mod."github.com/gogo/protobuf"] 164 248 version = "v1.3.2" 165 249 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 250 + [mod."github.com/goki/freetype"] 251 + version = "v1.0.5" 252 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 166 253 [mod."github.com/golang-jwt/jwt/v5"] 167 254 version = "v5.2.3" 168 255 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 172 259 [mod."github.com/golang/mock"] 173 260 version = "v1.6.0" 174 261 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=" 175 268 [mod."github.com/google/go-querystring"] 176 269 version = "v1.1.0" 177 270 hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" ··· 268 361 [mod."github.com/ipfs/go-metrics-interface"] 269 362 version = "v0.3.0" 270 363 hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 364 + [mod."github.com/json-iterator/go"] 365 + version = "v1.1.12" 366 + hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" 271 367 [mod."github.com/kevinburke/ssh_config"] 272 368 version = "v1.2.0" 273 369 hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" ··· 295 391 [mod."github.com/lestrrat-go/option"] 296 392 version = "v1.0.1" 297 393 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 394 + [mod."github.com/lucasb-eyer/go-colorful"] 395 + version = "v1.2.0" 396 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 298 397 [mod."github.com/mattn/go-isatty"] 299 398 version = "v0.0.20" 300 399 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 400 + [mod."github.com/mattn/go-runewidth"] 401 + version = "v0.0.16" 402 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 301 403 [mod."github.com/mattn/go-sqlite3"] 302 404 version = "v1.14.24" 303 405 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 319 421 [mod."github.com/moby/term"] 320 422 version = "v0.5.2" 321 423 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=" 322 430 [mod."github.com/morikuni/aec"] 323 431 version = "v1.0.0" 324 432 hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 325 433 [mod."github.com/mr-tron/base58"] 326 434 version = "v1.2.0" 327 435 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=" 328 442 [mod."github.com/multiformats/go-base32"] 329 443 version = "v0.1.0" 330 444 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 391 505 [mod."github.com/resend/resend-go/v2"] 392 506 version = "v2.15.0" 393 507 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 508 + [mod."github.com/rivo/uniseg"] 509 + version = "v0.4.7" 510 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 394 511 [mod."github.com/ryanuber/go-glob"] 395 512 version = "v1.0.0" 396 513 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 407 524 [mod."github.com/spaolacci/murmur3"] 408 525 version = "v1.1.0" 409 526 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=" 410 533 [mod."github.com/stretchr/testify"] 411 534 version = "v1.10.0" 412 535 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 426 549 version = "v0.3.1" 427 550 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 551 [mod."github.com/wyatt915/goldmark-treeblood"] 429 - version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 - hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 552 + version = "v0.0.1" 553 + hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g=" 431 554 [mod."github.com/wyatt915/treeblood"] 432 - version = "v0.1.15" 433 - hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 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=" 434 560 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 561 + version = "v1.7.13" 562 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 437 563 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 564 version = "v2.0.0-20230729083705-37449abec8cc" 439 565 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 566 + [mod."gitlab.com/staticnoise/goldmark-callout"] 567 + version = "v0.0.0-20240609120641-6366b799e4ab" 568 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 440 569 [mod."gitlab.com/yawning/secp256k1-voi"] 441 570 version = "v0.0.0-20230925100816-f2616030848b" 442 571 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 443 572 [mod."gitlab.com/yawning/tuplehash"] 444 573 version = "v0.0.0-20230713102510-df83abbf9a02" 445 574 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 575 + [mod."go.etcd.io/bbolt"] 576 + version = "v1.4.0" 577 + hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps=" 446 578 [mod."go.opentelemetry.io/auto/sdk"] 447 579 version = "v1.1.0" 448 580 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" ··· 479 611 [mod."golang.org/x/exp"] 480 612 version = "v0.0.0-20250620022241-b7579e27df2b" 481 613 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 614 + [mod."golang.org/x/image"] 615 + version = "v0.31.0" 616 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 482 617 [mod."golang.org/x/net"] 483 618 version = "v0.42.0" 484 619 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 620 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 621 + version = "v0.17.0" 622 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 488 623 [mod."golang.org/x/sys"] 489 624 version = "v0.34.0" 490 625 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 626 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 627 + version = "v0.29.0" 628 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 494 629 [mod."golang.org/x/time"] 495 630 version = "v0.12.0" 496 631 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 662 [mod."lukechampine.com/blake3"] 528 663 version = "v1.4.1" 529 664 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="
+285 -18
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 + 16 17 package = mkOption { 17 18 type = types.package; 18 19 description = "Package to use for the appview"; 19 20 }; 21 + 22 + # core configuration 20 23 port = mkOption { 21 - type = types.int; 24 + type = types.port; 22 25 default = 3000; 23 26 description = "Port to run the appview on"; 24 27 }; 25 - cookie_secret = mkOption { 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 { 26 36 type = types.str; 27 - default = "00000000000000000000000000000000"; 28 - description = "Cookie secret"; 37 + default = "/var/lib/appview/appview.db"; 38 + description = "Path to the SQLite database file"; 39 + }; 40 + 41 + appviewHost = mkOption { 42 + 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 + }; 154 + }; 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 + }; 29 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 + 30 228 environmentFile = mkOption { 31 229 type = with types; nullOr path; 32 230 default = null; 33 - example = "/etc/tangled-appview.env"; 231 + example = "/etc/appview.env"; 34 232 description = '' 35 233 Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 234 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 - 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. 41 245 ''; 42 246 }; 43 247 }; 44 248 }; 45 249 46 250 config = mkIf cfg.enable { 47 - systemd.services.tangled-appview = { 251 + services.redis.servers.appview = { 252 + enable = true; 253 + port = 6379; 254 + }; 255 + 256 + systemd.services.appview = { 48 257 description = "tangled appview service"; 49 258 wantedBy = ["multi-user.target"]; 259 + after = ["redis-appview.service" "network-online.target"]; 260 + requires = ["redis-appview.service"]; 261 + wants = ["network-online.target"]; 50 262 51 263 serviceConfig = { 52 - ListenStream = "0.0.0.0:${toString cfg.port}"; 264 + Type = "simple"; 53 265 ExecStart = "${cfg.package}/bin/appview"; 54 266 Restart = "always"; 55 - EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 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"]; 56 280 }; 57 281 58 - environment = { 59 - TANGLED_DB_PATH = "appview.db"; 60 - TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 - }; 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 + }; 62 329 }; 63 330 }; 64 331 }
+76 -6
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.sh"; 25 + default = "https://tangled.org"; 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 + 54 75 mainBranch = mkOption { 55 76 type = types.str; 56 77 default = "main"; 57 78 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"; 58 93 }; 59 94 }; 60 95 ··· 107 142 108 143 hostname = mkOption { 109 144 type = types.str; 110 - example = "knot.tangled.sh"; 145 + example = "my.knot.com"; 111 146 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"; 112 165 }; 113 166 114 167 dev = mkOption { ··· 178 231 mkdir -p "${cfg.stateDir}/.config/git" 179 232 cat > "${cfg.stateDir}/.config/git/config" << EOF 180 233 [user] 181 - name = Git User 182 - email = git@example.com 234 + name = ${cfg.git.userName} 235 + email = ${cfg.git.userEmail} 183 236 [receive] 184 237 advertisePushOptions = true 238 + [uploadpack] 239 + allowFilter = true 185 240 EOF 186 241 ${setMotd} 187 242 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 193 248 WorkingDirectory = cfg.stateDir; 194 249 Environment = [ 195 250 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 251 + "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 196 252 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 253 + "KNOT_GIT_USER_NAME=${cfg.git.userName}" 254 + "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 197 255 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 198 256 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 199 257 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 258 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 259 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 260 + "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 261 + "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 202 262 "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 + }" 203 273 ]; 204 274 ExecStart = "${cfg.package}/bin/knot server"; 205 275 Restart = "always";
+12 -5
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 = "spindle.tangled.sh"; 36 + example = "my.spindle.com"; 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 + 40 46 jetstreamEndpoint = mkOption { 41 47 type = types.str; 42 48 default = "wss://jetstream1.us-west.bsky.network/subscribe"; ··· 92 98 pipelines = { 93 99 nixery = mkOption { 94 100 type = types.str; 95 - default = "nixery.tangled.sh"; 101 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 102 description = "Nixery instance to use"; 97 103 }; 98 104 ··· 119 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 120 126 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 121 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 122 - "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 128 + "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 123 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 132 "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, 8 9 sqlite-lib, 9 10 tailwindcss, 10 11 src, ··· 22 23 cp -rf ${lucide-src}/*.svg icons/ 23 24 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 25 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 26 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 25 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 + cp -f ${actor-typeahead-src}/actor-typeahead.js . 26 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 30 # for whatever reason (produces broken css), so we are doing this instead 28 31 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.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+21 -8
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"; 13 22 in 14 23 nixpkgs.lib.nixosSystem { 15 24 inherit system; ··· 73 82 time.timeZone = "Europe/London"; 74 83 services.getty.autologinUser = "root"; 75 84 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 - services.tangled-knot = { 85 + services.tangled.knot = { 77 86 enable = true; 78 87 motd = "Welcome to the development knot!\n"; 79 88 server = { 80 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 - hostname = "localhost:6000"; 90 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000"; 91 + plcUrl = plcUrl; 92 + jetstreamEndpoint = jetstream; 82 93 listenAddr = "0.0.0.0:6000"; 83 94 }; 84 95 }; 85 - services.tangled-spindle = { 96 + services.tangled.spindle = { 86 97 enable = true; 87 98 server = { 88 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 - hostname = "localhost:6555"; 100 + hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 + plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 90 103 listenAddr = "0.0.0.0:6555"; 91 104 dev = true; 92 105 queueSize = 100; ··· 99 112 users = { 100 113 # So we don't have to deal with permission clashing between 101 114 # blank disk VMs and existing state 102 - users.${config.services.tangled-knot.gitUser}.uid = 666; 103 - groups.${config.services.tangled-knot.gitUser}.gid = 666; 115 + users.${config.services.tangled.knot.gitUser}.uid = 666; 116 + groups.${config.services.tangled.knot.gitUser}.gid = 666; 104 117 105 118 # TODO: separate spindle user 106 119 }; ··· 120 133 serviceConfig.PermissionsStartOnly = true; 121 134 }; 122 135 in { 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); 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); 125 138 }; 126 139 }) 127 140 ];
+18 -7
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "os" ··· 42 43 // IsPatchValid checks if the given patch string is valid. 43 44 // It performs very basic sniffing for either git-diff or git-format-patch 44 45 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 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 { 46 53 if len(patch) == 0 { 47 - return false 54 + return EmptyPatchError 48 55 } 49 56 50 57 lines := strings.Split(patch, "\n") 51 58 if len(lines) < 2 { 52 - return false 59 + return EmptyPatchError 53 60 } 54 61 55 62 firstLine := strings.TrimSpace(lines[0]) ··· 60 67 strings.HasPrefix(firstLine, "Index: ") || 61 68 strings.HasPrefix(firstLine, "+++ ") || 62 69 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 70 + return nil 64 71 } 65 72 66 73 // check if it's format-patch ··· 70 77 // it's safe to say it's broken. 71 78 patches, err := ExtractPatches(patch) 72 79 if err != nil { 73 - return false 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 74 81 } 75 - return len(patches) > 0 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 76 87 } 77 88 78 - return false 89 + return GenericPatchError 79 90 } 80 91 81 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "reflect" 5 6 "testing" 6 7 ) ··· 9 10 tests := []struct { 10 11 name string 11 12 patch string 12 - expected bool 13 + expected error 13 14 }{ 14 15 { 15 16 name: `empty patch`, 16 17 patch: ``, 17 - expected: false, 18 + expected: EmptyPatchError, 18 19 }, 19 20 { 20 21 name: `single line patch`, 21 22 patch: `single line`, 22 - expected: false, 23 + expected: EmptyPatchError, 23 24 }, 24 25 { 25 26 name: `valid diff patch`, ··· 31 32 -old line 32 33 +new line 33 34 context`, 34 - expected: true, 35 + expected: nil, 35 36 }, 36 37 { 37 38 name: `valid patch starting with ---`, ··· 41 42 -old line 42 43 +new line 43 44 context`, 44 - expected: true, 45 + expected: nil, 45 46 }, 46 47 { 47 48 name: `valid patch starting with Index`, ··· 53 54 -old line 54 55 +new line 55 56 context`, 56 - expected: true, 57 + expected: nil, 57 58 }, 58 59 { 59 60 name: `valid patch starting with +++`, ··· 63 64 -old line 64 65 +new line 65 66 context`, 66 - expected: true, 67 + expected: nil, 67 68 }, 68 69 { 69 70 name: `valid patch starting with @@`, ··· 72 73 +new line 73 74 context 74 75 `, 75 - expected: true, 76 + expected: nil, 76 77 }, 77 78 { 78 79 name: `valid format patch`, ··· 90 91 +new content 91 92 -- 92 93 2.48.1`, 93 - expected: true, 94 + expected: nil, 94 95 }, 95 96 { 96 97 name: `invalid format patch`, 97 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 99 From: Author <author@example.com> 99 100 This is not a valid patch format`, 100 - expected: false, 101 + expected: FormatPatchError, 101 102 }, 102 103 { 103 104 name: `not a patch at all`, ··· 105 106 just some 106 107 random text 107 108 that isn't a patch`, 108 - expected: false, 109 + expected: GenericPatchError, 109 110 }, 110 111 } 111 112 112 113 for _, tt := range tests { 113 114 t.Run(tt.name, func(t *testing.T) { 114 115 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 + if !errors.Is(result, tt.expected) { 116 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 118 } 118 119 })
-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"` 16 17 Dev bool `env:"DEV, default=false"` 17 18 Owner string `env:"OWNER, required"` 18 19 Secrets Secrets `env:",prefix=SECRETS_"`
+13 -3
spindle/engine/engine.go
··· 79 79 defer cancel() 80 80 81 81 for stepIdx, step := range w.Steps { 82 + // log start of step 82 83 if wfLogger != nil { 83 - ctl := wfLogger.ControlWriter(stepIdx, step) 84 - ctl.Write([]byte(step.Name())) 84 + wfLogger. 85 + ControlWriter(stepIdx, step, models.StepStatusStart). 86 + Write([]byte{0}) 85 87 } 86 88 87 89 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 + 88 98 if err != nil { 89 99 if errors.Is(err, ErrTimedOut) { 90 100 dbErr := db.StatusTimeout(wid, n) ··· 115 125 if err := eg.Wait(); err != nil { 116 126 l.Error("failed to run one or more workflows", "err", err) 117 127 } else { 118 - l.Error("successfully ran full pipeline") 128 + l.Info("successfully ran full pipeline") 119 129 } 120 130 }
+3 -3
spindle/engines/nixery/engine.go
··· 222 222 }, 223 223 ReadonlyRootfs: false, 224 224 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 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("stdout"), 385 - wfLogger.DataWriter("stderr"), 384 + wfLogger.DataWriter(stepIdx, "stdout"), 385 + wfLogger.DataWriter(stepIdx, "stderr"), 386 386 logs.Reader, 387 387 ) 388 388 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3 -7
spindle/ingester.go
··· 9 9 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/eventconsumer" 12 - "tangled.org/core/idresolver" 13 12 "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/db" 15 14 ··· 142 141 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 142 var err error 144 143 did := e.Did 145 - resolver := idresolver.DefaultResolver() 146 144 147 145 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 146 ··· 190 188 } 191 189 192 190 // add collaborators to rbac 193 - owner, err := resolver.ResolveIdent(ctx, did) 191 + owner, err := s.res.ResolveIdent(ctx, did) 194 192 if err != nil || owner.Handle.IsInvalidHandle() { 195 193 return err 196 194 } ··· 225 223 return err 226 224 } 227 225 228 - resolver := idresolver.DefaultResolver() 229 - 230 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 231 227 if err != nil || subjectId.Handle.IsInvalidHandle() { 232 228 return err 233 229 } ··· 240 236 241 237 // TODO: get rid of this entirely 242 238 // resolve this aturi to extract the repo record 243 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 244 240 if err != nil || owner.Handle.IsInvalidHandle() { 245 241 return fmt.Errorf("failed to resolve handle: %w", err) 246 242 }
+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 + }
+14 -11
spindle/models/logger.go
··· 37 37 return l.file.Close() 38 38 } 39 39 40 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 - // TODO: emit stream 40 + func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 42 41 return &dataWriter{ 43 42 logger: l, 43 + idx: idx, 44 44 stream: stream, 45 45 } 46 46 } 47 47 48 - func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 49 49 return &controlWriter{ 50 - logger: l, 51 - idx: idx, 52 - step: step, 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + stepStatus: stepStatus, 53 54 } 54 55 } 55 56 56 57 type dataWriter struct { 57 58 logger *WorkflowLogger 59 + idx int 58 60 stream string 59 61 } 60 62 61 63 func (w *dataWriter) Write(p []byte) (int, error) { 62 64 line := strings.TrimRight(string(p), "\r\n") 63 - entry := NewDataLogLine(line, w.stream) 65 + entry := NewDataLogLine(w.idx, line, w.stream) 64 66 if err := w.logger.encoder.Encode(entry); err != nil { 65 67 return 0, err 66 68 } ··· 68 70 } 69 71 70 72 type controlWriter struct { 71 - logger *WorkflowLogger 72 - idx int 73 - step Step 73 + logger *WorkflowLogger 74 + idx int 75 + step Step 76 + stepStatus StepStatus 74 77 } 75 78 76 79 func (w *controlWriter) Write(_ []byte) (int, error) { 77 - entry := NewControlLogLine(w.idx, w.step) 80 + entry := NewControlLogLine(w.idx, w.step, w.stepStatus) 78 81 if err := w.logger.encoder.Encode(entry); err != nil { 79 82 return 0, err 80 83 }
+23 -8
spindle/models/models.go
··· 4 4 "fmt" 5 5 "regexp" 6 6 "slices" 7 + "time" 7 8 8 9 "tangled.org/core/api/tangled" 9 10 ··· 76 77 var ( 77 78 // step log data 78 79 LogKindData LogKind = "data" 79 - // indicates start/end of a step 80 + // indicates status of a step 80 81 LogKindControl LogKind = "control" 81 82 ) 82 83 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 + 83 92 type LogLine struct { 84 - Kind LogKind `json:"kind"` 85 - Content string `json:"content"` 93 + Kind LogKind `json:"kind"` 94 + Content string `json:"content"` 95 + Time time.Time `json:"time"` 96 + StepId int `json:"step_id"` 86 97 87 98 // fields if kind is "data" 88 99 Stream string `json:"stream,omitempty"` 89 100 90 101 // fields if kind is "control" 91 - StepId int `json:"step_id,omitempty"` 92 - StepKind StepKind `json:"step_kind,omitempty"` 93 - StepCommand string `json:"step_command,omitempty"` 102 + StepStatus StepStatus `json:"step_status,omitempty"` 103 + StepKind StepKind `json:"step_kind,omitempty"` 104 + StepCommand string `json:"step_command,omitempty"` 94 105 } 95 106 96 - func NewDataLogLine(content, stream string) LogLine { 107 + func NewDataLogLine(idx int, content, stream string) LogLine { 97 108 return LogLine{ 98 109 Kind: LogKindData, 110 + Time: time.Now(), 99 111 Content: content, 112 + StepId: idx, 100 113 Stream: stream, 101 114 } 102 115 } 103 116 104 - func NewControlLogLine(idx int, step Step) LogLine { 117 + func NewControlLogLine(idx int, step Step, status StepStatus) LogLine { 105 118 return LogLine{ 106 119 Kind: LogKindControl, 120 + Time: time.Now(), 107 121 Content: step.Name(), 108 122 StepId: idx, 123 + StepStatus: status, 109 124 StepKind: step.Kind(), 110 125 StepCommand: step.Command(), 111 126 }
+92 -47
spindle/server.go
··· 49 49 vault secrets.Manager 50 50 } 51 51 52 - func Run(ctx context.Context) error { 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) { 53 54 logger := log.FromContext(ctx) 54 55 55 - cfg, err := config.Load(ctx) 56 - if err != nil { 57 - return fmt.Errorf("failed to load config: %w", err) 58 - } 59 - 60 56 d, err := db.Make(cfg.Server.DBPath) 61 57 if err != nil { 62 - return fmt.Errorf("failed to setup db: %w", err) 58 + return nil, fmt.Errorf("failed to setup db: %w", err) 63 59 } 64 60 65 61 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 66 62 if err != nil { 67 - return fmt.Errorf("failed to setup rbac enforcer: %w", err) 63 + return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 64 } 69 65 e.E.EnableAutoSave(true) 70 66 ··· 74 70 switch cfg.Server.Secrets.Provider { 75 71 case "openbao": 76 72 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 - return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 73 + return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 74 } 79 75 vault, err = secrets.NewOpenBaoManager( 80 76 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 82 78 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 79 ) 84 80 if err != nil { 85 - return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 81 + return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 82 } 87 83 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 84 case "sqlite", "": 89 85 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 86 if err != nil { 91 - return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 87 + return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 88 } 93 89 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 90 default: 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 91 + return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 92 } 102 93 103 94 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 108 99 tangled.RepoNSID, 109 100 tangled.RepoCollaboratorNSID, 110 101 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 102 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 103 if err != nil { 113 - return fmt.Errorf("failed to setup jetstream client: %w", err) 104 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 114 105 } 115 106 jc.AddDid(cfg.Server.Owner) 116 107 117 108 // Check if the spindle knows about any Dids; 118 109 dids, err := d.GetAllDids() 119 110 if err != nil { 120 - return fmt.Errorf("failed to get all dids: %w", err) 111 + return nil, fmt.Errorf("failed to get all dids: %w", err) 121 112 } 122 113 for _, d := range dids { 123 114 jc.AddDid(d) 124 115 } 125 116 126 - resolver := idresolver.DefaultResolver() 117 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 127 118 128 - spindle := Spindle{ 119 + spindle := &Spindle{ 129 120 jc: jc, 130 121 e: e, 131 122 db: d, 132 123 l: logger, 133 124 n: &n, 134 - engs: map[string]models.Engine{"nixery": nixeryEng}, 125 + engs: engines, 135 126 jq: jq, 136 127 cfg: cfg, 137 128 res: resolver, ··· 140 131 141 132 err = e.AddSpindle(rbacDomain) 142 133 if err != nil { 143 - return fmt.Errorf("failed to set rbac domain: %w", err) 134 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 144 135 } 145 136 err = spindle.configureOwner() 146 137 if err != nil { 147 - return err 138 + return nil, err 148 139 } 149 140 logger.Info("owner set", "did", cfg.Server.Owner) 150 141 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 - 160 142 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 161 143 if err != nil { 162 - return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 144 + return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 163 145 } 164 146 165 147 err = jc.StartJetstream(ctx, spindle.ingest()) 166 148 if err != nil { 167 - return fmt.Errorf("failed to start jetstream consumer: %w", err) 149 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 168 150 } 169 151 170 152 // for each incoming sh.tangled.pipeline, we execute 171 153 // spindle.processPipeline, which in turn enqueues the pipeline 172 154 // job in the above registered queue. 173 155 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 156 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 157 ccfg.Dev = cfg.Server.Dev 176 158 ccfg.ProcessFunc = spindle.processPipeline 177 159 ccfg.CursorStore = cursorStore 178 160 knownKnots, err := d.Knots() 179 161 if err != nil { 180 - return err 162 + return nil, err 181 163 } 182 164 for _, knot := range knownKnots { 183 165 logger.Info("adding source start", "knot", knot) ··· 185 167 } 186 168 spindle.ks = eventconsumer.NewConsumer(*ccfg) 187 169 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 + 188 214 go func() { 189 - logger.Info("starting knot event consumer") 190 - spindle.ks.Start(ctx) 215 + s.l.Info("starting knot event consumer") 216 + s.ks.Start(ctx) 191 217 }() 192 218 193 - logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 219 + s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 220 + return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 221 + } 222 + 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 + } 195 240 196 - return nil 241 + return s.Start(ctx) 197 242 } 198 243 199 244 func (s *Spindle) Router() http.Handler { ··· 210 255 } 211 256 212 257 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 258 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 259 260 + l := log.SubLogger(s.l, "xrpc") 261 + 217 262 x := xrpc.Xrpc{ 218 - Logger: logger, 263 + Logger: l, 219 264 Db: s.db, 220 265 Enforcer: s.e, 221 266 Engines: s.engs, ··· 305 350 306 351 ok := s.jq.Enqueue(queue.Job{ 307 352 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 353 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 354 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 355 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 356 Workflows: workflows,
+8 -3
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 + "tangled.org/core/log" 13 14 "tangled.org/core/spindle/models" 14 15 15 16 "github.com/go-chi/chi/v5" ··· 23 24 } 24 25 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 + l := log.SubLogger(s.l, "eventstream") 28 + 27 29 l.Debug("received new connection") 28 30 29 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 84 } 83 85 case <-time.After(30 * time.Second): 84 86 // send a keep-alive 85 - l.Debug("sent keepalive") 86 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 88 l.Error("failed to write control", "err", err) 88 89 } ··· 212 213 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 213 214 return fmt.Errorf("failed to write to websocket: %w", err) 214 215 } 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 + } 215 221 } 216 222 } 217 223 } ··· 222 228 s.l.Debug("err", "err", err) 223 229 return err 224 230 } 225 - s.l.Debug("ops", "ops", events) 226 231 227 232 for _, event := range events { 228 233 // first extract the inner json into a map
+14 -10
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 4 5 "github.com/go-git/go-git/v5/plumbing/object" 5 6 ) 6 7 ··· 33 34 } 34 35 35 36 type RepoFormatPatchResponse struct { 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"` 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"` 41 43 } 42 44 43 45 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 46 + Ref string `json:"ref,omitempty"` 47 + Parent string `json:"parent,omitempty"` 48 + Description string `json:"description,omitempty"` 49 + DotDot string `json:"dotdot,omitempty"` 50 + Files []NiceTree `json:"files,omitempty"` 51 + ReadmeFileName string `json:"readme_filename,omitempty"` 52 + Readme string `json:"readme_contents,omitempty"` 49 53 } 50 54 51 55 type TagReference struct {
+9 -1
workflow/compile.go
··· 113 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 114 cw := &tangled.Pipeline_Workflow{} 115 115 116 - if !w.Match(compiler.Trigger) { 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 { 117 125 compiler.Diagnostics.AddWarning( 118 126 w.Name, 119 127 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 + }
+61 -19
workflow/def.go
··· 8 8 9 9 "tangled.org/core/api/tangled" 10 10 11 + "github.com/bmatcuk/doublestar/v4" 11 12 "github.com/go-git/go-git/v5/plumbing" 12 13 "gopkg.in/yaml.v3" 13 14 ) ··· 33 34 34 35 Constraint struct { 35 36 Event StringList `yaml:"event"` 36 - Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 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 37 39 } 38 40 39 41 CloneOpts struct { ··· 59 61 return strings.ReplaceAll(string(t), "_", " ") 60 62 } 61 63 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 + 62 81 func FromFile(name string, contents []byte) (Workflow, error) { 63 82 var wf Workflow 64 83 ··· 74 93 } 75 94 76 95 // if any of the constraints on a workflow is true, return true 77 - func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 96 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 78 97 // manual triggers always run the workflow 79 98 if trigger.Manual != nil { 80 - return true 99 + return true, nil 81 100 } 82 101 83 102 // if not manual, run through the constraint list and see if any one matches 84 103 for _, c := range w.When { 85 - if c.Match(trigger) { 86 - return true 104 + matched, err := c.Match(trigger) 105 + if err != nil { 106 + return false, err 107 + } 108 + if matched { 109 + return true, nil 87 110 } 88 111 } 89 112 90 113 // no constraints, always run this workflow 91 114 if len(w.When) == 0 { 92 - return true 115 + return true, nil 93 116 } 94 117 95 - return false 118 + return false, nil 96 119 } 97 120 98 - func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 121 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 99 122 match := true 100 123 101 124 // manual triggers always pass this constraint 102 125 if trigger.Manual != nil { 103 - return true 126 + return true, nil 104 127 } 105 128 106 129 // apply event constraints ··· 108 131 109 132 // apply branch constraints for PRs 110 133 if trigger.PullRequest != nil { 111 - match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 134 + matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) 135 + if err != nil { 136 + return false, err 137 + } 138 + match = match && matched 112 139 } 113 140 114 141 // apply ref constraints for pushes 115 142 if trigger.Push != nil { 116 - match = match && c.MatchRef(trigger.Push.Ref) 143 + matched, err := c.MatchRef(trigger.Push.Ref) 144 + if err != nil { 145 + return false, err 146 + } 147 + match = match && matched 117 148 } 118 149 119 - return match 120 - } 121 - 122 - func (c *Constraint) MatchBranch(branch string) bool { 123 - return slices.Contains(c.Branch, branch) 150 + return match, nil 124 151 } 125 152 126 - func (c *Constraint) MatchRef(ref string) bool { 153 + func (c *Constraint) MatchRef(ref string) (bool, error) { 127 154 refName := plumbing.ReferenceName(ref) 155 + shortName := refName.Short() 156 + 128 157 if refName.IsBranch() { 129 - return slices.Contains(c.Branch, refName.Short()) 158 + return c.MatchBranch(shortName) 130 159 } 131 - return false 160 + 161 + if refName.IsTag() { 162 + return c.MatchTag(shortName) 163 + } 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) 132 174 } 133 175 134 176 func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
··· 6 6 "github.com/stretchr/testify/assert" 7 7 ) 8 8 9 - func TestUnmarshalWorkflow(t *testing.T) { 9 + func TestUnmarshalWorkflowWithBranch(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 + }
+5 -4
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" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 22 23 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 25 return &ServiceAuth{ 25 - logger: logger, 26 + logger: log.SubLogger(logger, "serviceauth"), 26 27 resolver: resolver, 27 28 audienceDid: audienceDid, 28 29 } ··· 30 31 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 34 token := r.Header.Get("Authorization") 36 35 token = strings.TrimPrefix(token, "Bearer ") 37 36 ··· 42 41 43 42 did, err := s.Validate(r.Context(), token, nil) 44 43 if err != nil { 45 - l.Error("signature verification failed", "err", err) 44 + sa.logger.Error("signature verification failed", "err", err) 46 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 46 return 48 47 } 48 + 49 + sa.logger.Debug("valid signature", ActorDid, did) 49 50 50 51 r = r.WithContext( 51 52 context.WithValue(r.Context(), ActorDid, did),