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

Compare changes

Choose any two refs to compare.

Changed files
+8585 -3171
.tangled
api
appview
config
db
dns
issues
knots
labels
middleware
models
notifications
notify
oauth
ogcard
pages
pagination
pipelines
posthog
pulls
repo
settings
signup
spindles
state
strings
validator
xrpcclient
cmd
appview
cborgen
genjwks
knot
punchcardPopulate
spindle
docs
jetstream
knotserver
legal
lexicons
log
nix
patchutil
spindle
types
xrpc
serviceauth
+1 -1
.tangled/workflows/build.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+1 -1
.tangled/workflows/test.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+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 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 // ref: The git reference used 35 Ref string `json:"ref" cborgen:"ref"` 36 } 37 38 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 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"` 36 // ref: The git reference used 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"` 46 } 47 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
-1
appview/db/artifact.go
··· 67 ) 68 69 rows, err := e.Query(query, args...) 70 - 71 if err != nil { 72 return nil, err 73 }
··· 67 ) 68 69 rows, err := e.Query(query, args...) 70 if err != nil { 71 return nil, err 72 }
+53
appview/db/collaborators.go
··· 3 import ( 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) ··· 59 60 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 61 }
··· 3 import ( 4 "fmt" 5 "strings" 6 + "time" 7 8 "tangled.org/core/appview/models" 9 ) ··· 60 61 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 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 + }
+214 -34
appview/db/db.go
··· 4 "context" 5 "database/sql" 6 "fmt" 7 - "log" 8 "reflect" 9 "strings" 10 11 _ "github.com/mattn/go-sqlite3" 12 ) 13 14 type DB struct { 15 *sql.DB 16 } 17 18 type Execer interface { ··· 26 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 } 28 29 - func Make(dbPath string) (*DB, error) { 30 // https://github.com/mattn/go-sqlite3#connection-string 31 opts := []string{ 32 "_foreign_keys=1", ··· 35 "_auto_vacuum=incremental", 36 } 37 38 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 if err != nil { 40 return nil, err 41 } 42 - 43 - ctx := context.Background() 44 45 conn, err := db.Conn(ctx) 46 if err != nil { ··· 530 unique (repo_at, label_at) 531 ); 532 533 create table if not exists migrations ( 534 id integer primary key autoincrement, 535 name text unique 536 ); 537 538 - -- indexes for better star query performance 539 create index if not exists idx_stars_created on stars(created); 540 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 541 `) ··· 544 } 545 546 // run migrations 547 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 548 tx.Exec(` 549 alter table repos add column description text check (length(description) <= 200); 550 `) 551 return nil 552 }) 553 554 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 555 // add unconstrained column 556 _, err := tx.Exec(` 557 alter table public_keys ··· 574 return nil 575 }) 576 577 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 578 _, err := tx.Exec(` 579 alter table comments drop column comment_at; 580 alter table comments add column rkey text; ··· 582 return err 583 }) 584 585 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 586 _, err := tx.Exec(` 587 alter table comments add column deleted text; -- timestamp 588 alter table comments add column edited text; -- timestamp ··· 590 return err 591 }) 592 593 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 594 _, err := tx.Exec(` 595 alter table pulls add column source_branch text; 596 alter table pulls add column source_repo_at text; ··· 599 return err 600 }) 601 602 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 603 _, err := tx.Exec(` 604 alter table repos add column source text; 605 `) ··· 611 // 612 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 613 conn.ExecContext(ctx, "pragma foreign_keys = off;") 614 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 615 _, err := tx.Exec(` 616 create table pulls_new ( 617 -- identifiers ··· 668 }) 669 conn.ExecContext(ctx, "pragma foreign_keys = on;") 670 671 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 672 tx.Exec(` 673 alter table repos add column spindle text; 674 `) ··· 678 // drop all knot secrets, add unique constraint to knots 679 // 680 // knots will henceforth use service auth for signed requests 681 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 682 _, err := tx.Exec(` 683 create table registrations_new ( 684 id integer primary key autoincrement, ··· 701 }) 702 703 // recreate and add rkey + created columns with default constraint 704 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 705 // create new table 706 // - repo_at instead of repo integer 707 // - rkey field ··· 755 return err 756 }) 757 758 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 759 _, err := tx.Exec(` 760 alter table issues add column rkey text not null default ''; 761 ··· 767 }) 768 769 // repurpose the read-only column to "needs-upgrade" 770 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 771 _, err := tx.Exec(` 772 alter table registrations rename column read_only to needs_upgrade; 773 `) ··· 775 }) 776 777 // require all knots to upgrade after the release of total xrpc 778 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 779 _, err := tx.Exec(` 780 update registrations set needs_upgrade = 1; 781 `) ··· 783 }) 784 785 // require all knots to upgrade after the release of total xrpc 786 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 787 _, err := tx.Exec(` 788 alter table spindles add column needs_upgrade integer not null default 0; 789 - `) 790 - if err != nil { 791 - return err 792 - } 793 - 794 - _, err = tx.Exec(` 795 - update spindles set needs_upgrade = 1; 796 `) 797 return err 798 }) ··· 808 // 809 // disable foreign-keys for the next migration 810 conn.ExecContext(ctx, "pragma foreign_keys = off;") 811 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 812 _, err := tx.Exec(` 813 create table if not exists issues_new ( 814 -- identifiers ··· 878 // - new columns 879 // * column "reply_to" which can be any other comment 880 // * column "at-uri" which is a generated column 881 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 882 _, err := tx.Exec(` 883 create table if not exists issue_comments ( 884 -- identifiers ··· 931 return err 932 }) 933 934 - return &DB{db}, nil 935 } 936 937 type migrationFn = func(*sql.Tx) error 938 939 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 940 tx, err := c.BeginTx(context.Background(), nil) 941 if err != nil { 942 return err ··· 953 // run migration 954 err = migrationFn(tx) 955 if err != nil { 956 - log.Printf("Failed to run migration %s: %v", name, err) 957 return err 958 } 959 960 // mark migration as complete 961 _, err = tx.Exec("insert into migrations (name) values (?)", name) 962 if err != nil { 963 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 964 return err 965 } 966 ··· 969 return err 970 } 971 972 - log.Printf("migration %s applied successfully", name) 973 } else { 974 - log.Printf("skipped migration %s, already applied", name) 975 } 976 977 return nil
··· 4 "context" 5 "database/sql" 6 "fmt" 7 + "log/slog" 8 "reflect" 9 "strings" 10 11 _ "github.com/mattn/go-sqlite3" 12 + "tangled.org/core/log" 13 ) 14 15 type DB struct { 16 *sql.DB 17 + logger *slog.Logger 18 } 19 20 type Execer interface { ··· 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 29 } 30 31 + func Make(ctx context.Context, dbPath string) (*DB, error) { 32 // https://github.com/mattn/go-sqlite3#connection-string 33 opts := []string{ 34 "_foreign_keys=1", ··· 37 "_auto_vacuum=incremental", 38 } 39 40 + logger := log.FromContext(ctx) 41 + logger = log.SubLogger(logger, "db") 42 + 43 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 44 if err != nil { 45 return nil, err 46 } 47 48 conn, err := db.Conn(ctx) 49 if err != nil { ··· 533 unique (repo_at, label_at) 534 ); 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 + 564 create table if not exists migrations ( 565 id integer primary key autoincrement, 566 name text unique 567 ); 568 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); 572 create index if not exists idx_stars_created on stars(created); 573 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 574 `) ··· 577 } 578 579 // run migrations 580 + runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 581 tx.Exec(` 582 alter table repos add column description text check (length(description) <= 200); 583 `) 584 return nil 585 }) 586 587 + runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 588 // add unconstrained column 589 _, err := tx.Exec(` 590 alter table public_keys ··· 607 return nil 608 }) 609 610 + runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 611 _, err := tx.Exec(` 612 alter table comments drop column comment_at; 613 alter table comments add column rkey text; ··· 615 return err 616 }) 617 618 + runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 619 _, err := tx.Exec(` 620 alter table comments add column deleted text; -- timestamp 621 alter table comments add column edited text; -- timestamp ··· 623 return err 624 }) 625 626 + runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 627 _, err := tx.Exec(` 628 alter table pulls add column source_branch text; 629 alter table pulls add column source_repo_at text; ··· 632 return err 633 }) 634 635 + runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 636 _, err := tx.Exec(` 637 alter table repos add column source text; 638 `) ··· 644 // 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 647 + runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 648 _, err := tx.Exec(` 649 create table pulls_new ( 650 -- identifiers ··· 701 }) 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 703 704 + runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 705 tx.Exec(` 706 alter table repos add column spindle text; 707 `) ··· 711 // drop all knot secrets, add unique constraint to knots 712 // 713 // knots will henceforth use service auth for signed requests 714 + runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 715 _, err := tx.Exec(` 716 create table registrations_new ( 717 id integer primary key autoincrement, ··· 734 }) 735 736 // recreate and add rkey + created columns with default constraint 737 + runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 738 // create new table 739 // - repo_at instead of repo integer 740 // - rkey field ··· 788 return err 789 }) 790 791 + runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 792 _, err := tx.Exec(` 793 alter table issues add column rkey text not null default ''; 794 ··· 800 }) 801 802 // repurpose the read-only column to "needs-upgrade" 803 + runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 804 _, err := tx.Exec(` 805 alter table registrations rename column read_only to needs_upgrade; 806 `) ··· 808 }) 809 810 // require all knots to upgrade after the release of total xrpc 811 + runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 812 _, err := tx.Exec(` 813 update registrations set needs_upgrade = 1; 814 `) ··· 816 }) 817 818 // require all knots to upgrade after the release of total xrpc 819 + runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 820 _, err := tx.Exec(` 821 alter table spindles add column needs_upgrade integer not null default 0; 822 `) 823 return err 824 }) ··· 834 // 835 // disable foreign-keys for the next migration 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 837 + runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 838 _, err := tx.Exec(` 839 create table if not exists issues_new ( 840 -- identifiers ··· 904 // - new columns 905 // * column "reply_to" which can be any other comment 906 // * column "at-uri" which is a generated column 907 + runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 908 _, err := tx.Exec(` 909 create table if not exists issue_comments ( 910 -- identifiers ··· 957 return err 958 }) 959 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 + return &DB{ 1110 + db, 1111 + logger, 1112 + }, nil 1113 } 1114 1115 type migrationFn = func(*sql.Tx) error 1116 1117 + func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1118 + logger = logger.With("migration", name) 1119 + 1120 tx, err := c.BeginTx(context.Background(), nil) 1121 if err != nil { 1122 return err ··· 1133 // run migration 1134 err = migrationFn(tx) 1135 if err != nil { 1136 + logger.Error("failed to run migration", "err", err) 1137 return err 1138 } 1139 1140 // mark migration as complete 1141 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1142 if err != nil { 1143 + logger.Error("failed to mark migration as complete", "err", err) 1144 return err 1145 } 1146 ··· 1149 return err 1150 } 1151 1152 + logger.Info("migration applied successfully") 1153 } else { 1154 + logger.Warn("skipped migration, already applied") 1155 } 1156 1157 return nil
+13 -9
appview/db/email.go
··· 71 return did, nil 72 } 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 80 if isVerifiedFilter { 81 verifiedFilter = 1 82 } 83 84 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 88 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 92 } 93 94 query := ` ··· 104 return nil, err 105 } 106 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 110 for rows.Next() { 111 var email, did string
··· 71 return did, nil 72 } 73 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 80 if isVerifiedFilter { 81 verifiedFilter = 1 82 } 83 + 84 + assoc := make(map[string]string) 85 86 // Create placeholders for the IN clause 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 89 90 args[0] = verifiedFilter 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 98 } 99 100 query := ` ··· 110 return nil, err 111 } 112 defer rows.Close() 113 114 for rows.Next() { 115 var email, did string
-20
appview/db/issues.go
··· 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 } 249 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) 253 - 254 - var issue models.Issue 255 - var createdAt string 256 - err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 - if err != nil { 258 - return nil, err 259 - } 260 - 261 - createdTime, err := time.Parse(time.RFC3339, createdAt) 262 - if err != nil { 263 - return nil, err 264 - } 265 - issue.Created = createdTime 266 - 267 - return &issue, nil 268 - } 269 - 270 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 result, err := e.Exec( 272 `insert into issue_comments (
··· 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 } 249 250 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 251 result, err := e.Exec( 252 `insert into issue_comments (
+34
appview/db/language.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) 9 ··· 82 83 return nil 84 }
··· 1 package db 2 3 import ( 4 + "database/sql" 5 "fmt" 6 "strings" 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/appview/models" 10 ) 11 ··· 84 85 return nil 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 + }
+486
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 + 64 + query := fmt.Sprintf(` 65 + select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 66 + from notifications 67 + %s 68 + order by created desc 69 + limit ? offset ? 70 + `, whereClause) 71 + 72 + args = append(args, page.Limit, page.Offset) 73 + 74 + rows, err := e.QueryContext(context.Background(), query, args...) 75 + if err != nil { 76 + return nil, fmt.Errorf("failed to query notifications: %w", err) 77 + } 78 + defer rows.Close() 79 + 80 + var notifications []*models.Notification 81 + for rows.Next() { 82 + var n models.Notification 83 + var typeStr string 84 + var createdStr string 85 + err := rows.Scan( 86 + &n.ID, 87 + &n.RecipientDid, 88 + &n.ActorDid, 89 + &typeStr, 90 + &n.EntityType, 91 + &n.EntityId, 92 + &n.Read, 93 + &createdStr, 94 + &n.RepoId, 95 + &n.IssueId, 96 + &n.PullId, 97 + ) 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to scan notification: %w", err) 100 + } 101 + n.Type = models.NotificationType(typeStr) 102 + n.Created, err = time.Parse(time.RFC3339, createdStr) 103 + if err != nil { 104 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 105 + } 106 + notifications = append(notifications, &n) 107 + } 108 + 109 + return notifications, nil 110 + } 111 + 112 + // GetNotificationsWithEntities retrieves notifications with their related entities 113 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 114 + var conditions []string 115 + var args []any 116 + 117 + for _, filter := range filters { 118 + conditions = append(conditions, filter.Condition()) 119 + args = append(args, filter.Arg()...) 120 + } 121 + 122 + whereClause := "" 123 + if len(conditions) > 0 { 124 + whereClause = "WHERE " + conditions[0] 125 + for _, condition := range conditions[1:] { 126 + whereClause += " AND " + condition 127 + } 128 + } 129 + 130 + query := fmt.Sprintf(` 131 + select 132 + n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 133 + n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 134 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 135 + 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, 136 + 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 137 + from notifications n 138 + left join repos r on n.repo_id = r.id 139 + left join issues i on n.issue_id = i.id 140 + left join pulls p on n.pull_id = p.id 141 + %s 142 + order by n.created desc 143 + limit ? offset ? 144 + `, whereClause) 145 + 146 + args = append(args, page.Limit, page.Offset) 147 + 148 + rows, err := e.QueryContext(context.Background(), query, args...) 149 + if err != nil { 150 + return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 151 + } 152 + defer rows.Close() 153 + 154 + var notifications []*models.NotificationWithEntity 155 + for rows.Next() { 156 + var n models.Notification 157 + var typeStr string 158 + var createdStr string 159 + var repo models.Repo 160 + var issue models.Issue 161 + var pull models.Pull 162 + var rId, iId, pId sql.NullInt64 163 + var rDid, rName, rDescription sql.NullString 164 + var iDid sql.NullString 165 + var iIssueId sql.NullInt64 166 + var iTitle sql.NullString 167 + var iOpen sql.NullBool 168 + var pOwnerDid sql.NullString 169 + var pPullId sql.NullInt64 170 + var pTitle sql.NullString 171 + var pState sql.NullInt64 172 + 173 + err := rows.Scan( 174 + &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 175 + &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 176 + &rId, &rDid, &rName, &rDescription, 177 + &iId, &iDid, &iIssueId, &iTitle, &iOpen, 178 + &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 179 + ) 180 + if err != nil { 181 + return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 182 + } 183 + 184 + n.Type = models.NotificationType(typeStr) 185 + n.Created, err = time.Parse(time.RFC3339, createdStr) 186 + if err != nil { 187 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 188 + } 189 + 190 + nwe := &models.NotificationWithEntity{Notification: &n} 191 + 192 + // populate repo if present 193 + if rId.Valid { 194 + repo.Id = rId.Int64 195 + if rDid.Valid { 196 + repo.Did = rDid.String 197 + } 198 + if rName.Valid { 199 + repo.Name = rName.String 200 + } 201 + if rDescription.Valid { 202 + repo.Description = rDescription.String 203 + } 204 + nwe.Repo = &repo 205 + } 206 + 207 + // populate issue if present 208 + if iId.Valid { 209 + issue.Id = iId.Int64 210 + if iDid.Valid { 211 + issue.Did = iDid.String 212 + } 213 + if iIssueId.Valid { 214 + issue.IssueId = int(iIssueId.Int64) 215 + } 216 + if iTitle.Valid { 217 + issue.Title = iTitle.String 218 + } 219 + if iOpen.Valid { 220 + issue.Open = iOpen.Bool 221 + } 222 + nwe.Issue = &issue 223 + } 224 + 225 + // populate pull if present 226 + if pId.Valid { 227 + pull.ID = int(pId.Int64) 228 + if pOwnerDid.Valid { 229 + pull.OwnerDid = pOwnerDid.String 230 + } 231 + if pPullId.Valid { 232 + pull.PullId = int(pPullId.Int64) 233 + } 234 + if pTitle.Valid { 235 + pull.Title = pTitle.String 236 + } 237 + if pState.Valid { 238 + pull.State = models.PullState(pState.Int64) 239 + } 240 + nwe.Pull = &pull 241 + } 242 + 243 + notifications = append(notifications, nwe) 244 + } 245 + 246 + return notifications, nil 247 + } 248 + 249 + // GetNotifications retrieves notifications with filters 250 + func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 251 + return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 252 + } 253 + 254 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 255 + var conditions []string 256 + var args []any 257 + for _, filter := range filters { 258 + conditions = append(conditions, filter.Condition()) 259 + args = append(args, filter.Arg()...) 260 + } 261 + 262 + whereClause := "" 263 + if conditions != nil { 264 + whereClause = " where " + strings.Join(conditions, " and ") 265 + } 266 + 267 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 268 + var count int64 269 + err := e.QueryRow(query, args...).Scan(&count) 270 + 271 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 272 + return 0, err 273 + } 274 + 275 + return count, nil 276 + } 277 + 278 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 279 + idFilter := FilterEq("id", notificationID) 280 + recipientFilter := FilterEq("recipient_did", userDID) 281 + 282 + query := fmt.Sprintf(` 283 + UPDATE notifications 284 + SET read = 1 285 + WHERE %s AND %s 286 + `, idFilter.Condition(), recipientFilter.Condition()) 287 + 288 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 289 + 290 + result, err := e.Exec(query, args...) 291 + if err != nil { 292 + return fmt.Errorf("failed to mark notification as read: %w", err) 293 + } 294 + 295 + rowsAffected, err := result.RowsAffected() 296 + if err != nil { 297 + return fmt.Errorf("failed to get rows affected: %w", err) 298 + } 299 + 300 + if rowsAffected == 0 { 301 + return fmt.Errorf("notification not found or access denied") 302 + } 303 + 304 + return nil 305 + } 306 + 307 + func MarkAllNotificationsRead(e Execer, userDID string) error { 308 + recipientFilter := FilterEq("recipient_did", userDID) 309 + readFilter := FilterEq("read", 0) 310 + 311 + query := fmt.Sprintf(` 312 + UPDATE notifications 313 + SET read = 1 314 + WHERE %s AND %s 315 + `, recipientFilter.Condition(), readFilter.Condition()) 316 + 317 + args := append(recipientFilter.Arg(), readFilter.Arg()...) 318 + 319 + _, err := e.Exec(query, args...) 320 + if err != nil { 321 + return fmt.Errorf("failed to mark all notifications as read: %w", err) 322 + } 323 + 324 + return nil 325 + } 326 + 327 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 328 + idFilter := FilterEq("id", notificationID) 329 + recipientFilter := FilterEq("recipient_did", userDID) 330 + 331 + query := fmt.Sprintf(` 332 + DELETE FROM notifications 333 + WHERE %s AND %s 334 + `, idFilter.Condition(), recipientFilter.Condition()) 335 + 336 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 337 + 338 + result, err := e.Exec(query, args...) 339 + if err != nil { 340 + return fmt.Errorf("failed to delete notification: %w", err) 341 + } 342 + 343 + rowsAffected, err := result.RowsAffected() 344 + if err != nil { 345 + return fmt.Errorf("failed to get rows affected: %w", err) 346 + } 347 + 348 + if rowsAffected == 0 { 349 + return fmt.Errorf("notification not found or access denied") 350 + } 351 + 352 + return nil 353 + } 354 + 355 + func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 356 + prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 357 + if err != nil { 358 + return nil, err 359 + } 360 + 361 + p, ok := prefs[syntax.DID(userDid)] 362 + if !ok { 363 + return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 364 + } 365 + 366 + return p, nil 367 + } 368 + 369 + func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 370 + prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 371 + 372 + var conditions []string 373 + var args []any 374 + for _, filter := range filters { 375 + conditions = append(conditions, filter.Condition()) 376 + args = append(args, filter.Arg()...) 377 + } 378 + 379 + whereClause := "" 380 + if conditions != nil { 381 + whereClause = " where " + strings.Join(conditions, " and ") 382 + } 383 + 384 + query := fmt.Sprintf(` 385 + select 386 + id, 387 + user_did, 388 + repo_starred, 389 + issue_created, 390 + issue_commented, 391 + pull_created, 392 + pull_commented, 393 + followed, 394 + pull_merged, 395 + issue_closed, 396 + email_notifications 397 + from 398 + notification_preferences 399 + %s 400 + `, whereClause) 401 + 402 + rows, err := e.Query(query, args...) 403 + if err != nil { 404 + return nil, err 405 + } 406 + defer rows.Close() 407 + 408 + for rows.Next() { 409 + var prefs models.NotificationPreferences 410 + if err := rows.Scan( 411 + &prefs.ID, 412 + &prefs.UserDid, 413 + &prefs.RepoStarred, 414 + &prefs.IssueCreated, 415 + &prefs.IssueCommented, 416 + &prefs.PullCreated, 417 + &prefs.PullCommented, 418 + &prefs.Followed, 419 + &prefs.PullMerged, 420 + &prefs.IssueClosed, 421 + &prefs.EmailNotifications, 422 + ); err != nil { 423 + return nil, err 424 + } 425 + 426 + prefsMap[prefs.UserDid] = &prefs 427 + } 428 + 429 + if err := rows.Err(); err != nil { 430 + return nil, err 431 + } 432 + 433 + return prefsMap, nil 434 + } 435 + 436 + func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 437 + query := ` 438 + INSERT OR REPLACE INTO notification_preferences 439 + (user_did, repo_starred, issue_created, issue_commented, pull_created, 440 + pull_commented, followed, pull_merged, issue_closed, email_notifications) 441 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 442 + ` 443 + 444 + result, err := d.DB.ExecContext(ctx, query, 445 + prefs.UserDid, 446 + prefs.RepoStarred, 447 + prefs.IssueCreated, 448 + prefs.IssueCommented, 449 + prefs.PullCreated, 450 + prefs.PullCommented, 451 + prefs.Followed, 452 + prefs.PullMerged, 453 + prefs.IssueClosed, 454 + prefs.EmailNotifications, 455 + ) 456 + if err != nil { 457 + return fmt.Errorf("failed to update notification preferences: %w", err) 458 + } 459 + 460 + if prefs.ID == 0 { 461 + id, err := result.LastInsertId() 462 + if err != nil { 463 + return fmt.Errorf("failed to get preferences ID: %w", err) 464 + } 465 + prefs.ID = id 466 + } 467 + 468 + return nil 469 + } 470 + 471 + func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 472 + cutoff := time.Now().Add(-olderThan) 473 + createdFilter := FilterLte("created", cutoff) 474 + 475 + query := fmt.Sprintf(` 476 + DELETE FROM notifications 477 + WHERE %s 478 + `, createdFilter.Condition()) 479 + 480 + _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 481 + if err != nil { 482 + return fmt.Errorf("failed to cleanup old notifications: %w", err) 483 + } 484 + 485 + return nil 486 + }
+155 -225
appview/db/pulls.go
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 - "log" 7 "sort" 8 "strings" 9 "time" ··· 55 parentChangeId = &pull.ParentChangeId 56 } 57 58 - _, err = tx.Exec( 59 ` 60 insert into pulls ( 61 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 79 return err 80 } 81 82 _, err = tx.Exec(` 83 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 84 values (?, ?, ?, ?, ?) 85 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 86 return err 87 } 88 ··· 101 } 102 103 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 104 - pulls := make(map[int]*models.Pull) 105 106 var conditions []string 107 var args []any ··· 121 122 query := fmt.Sprintf(` 123 select 124 owner_did, 125 repo_at, 126 pull_id, ··· 154 var createdAt string 155 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 156 err := rows.Scan( 157 &pull.OwnerDid, 158 &pull.RepoAt, 159 &pull.PullId, ··· 202 pull.ParentChangeId = parentChangeId.String 203 } 204 205 - pulls[pull.PullId] = &pull 206 } 207 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 221 - for _, p := range pulls { 222 - args[idx] = p.RepoAt 223 - idx += 1 224 - } 225 for _, p := range pulls { 226 - args[idx] = p.PullId 227 - idx += 1 228 } 229 - submissionsRows, err := e.Query(submissionsQuery, args...) 230 if err != nil { 231 - return nil, err 232 } 233 - defer submissionsRows.Close() 234 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 249 } 250 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 - } 265 - } 266 - if err := rows.Err(); err != nil { 267 - return nil, err 268 } 269 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{} 284 for _, p := range pulls { 285 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 286 } 287 - commentsRows, err := e.Query(commentsQuery, args...) 288 - if err != nil { 289 - return nil, err 290 } 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 - } 305 } 306 - if err := rows.Err(); err != nil { 307 - return nil, err 308 } 309 310 orderedByPullId := []*models.Pull{} ··· 323 } 324 325 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 326 - query := ` 327 - 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 342 - from 343 - 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, 367 - ) 368 if err != nil { 369 return nil, err 370 } 371 - 372 - createdTime, err := time.Parse(time.RFC3339, createdAt) 373 - if err != nil { 374 - return nil, err 375 } 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 - } 390 - } 391 392 - if stackId.Valid { 393 - pull.StackId = stackId.String 394 - } 395 - if changeId.Valid { 396 - pull.ChangeId = changeId.String 397 } 398 - if parentChangeId.Valid { 399 - pull.ParentChangeId = parentChangeId.String 400 } 401 402 - submissionsQuery := ` 403 select 404 - id, pull_id, repo_at, round_number, patch, created, source_rev 405 from 406 pull_submissions 407 - where 408 - repo_at = ? and pull_id = ? 409 - ` 410 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 411 if err != nil { 412 return nil, err 413 } 414 - defer submissionsRows.Close() 415 416 - submissionsMap := make(map[int]*models.PullSubmission) 417 418 - for submissionsRows.Next() { 419 var submission models.PullSubmission 420 var submissionCreatedStr string 421 - var submissionSourceRev sql.NullString 422 - err := submissionsRows.Scan( 423 &submission.ID, 424 - &submission.PullId, 425 - &submission.RepoAt, 426 &submission.RoundNumber, 427 &submission.Patch, 428 &submissionCreatedStr, 429 &submissionSourceRev, 430 ) ··· 432 return nil, err 433 } 434 435 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 436 - if err != nil { 437 - return nil, err 438 } 439 - submission.Created = submissionCreatedTime 440 441 if submissionSourceRev.Valid { 442 submission.SourceRev = submissionSourceRev.String 443 } 444 445 - submissionsMap[submission.ID] = &submission 446 } 447 - if err = submissionsRows.Close(); err != nil { 448 return nil, err 449 } 450 - if len(submissionsMap) == 0 { 451 - return &pull, nil 452 } 453 454 var args []any 455 - for k := range submissionsMap { 456 - args = append(args, k) 457 } 458 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 459 - commentsQuery := fmt.Sprintf(` 460 select 461 id, 462 pull_id, ··· 468 created 469 from 470 pull_comments 471 - where 472 - submission_id IN (%s) 473 order by 474 created asc 475 - `, inClause) 476 - commentsRows, err := e.Query(commentsQuery, args...) 477 if err != nil { 478 return nil, err 479 } 480 - defer commentsRows.Close() 481 482 - for commentsRows.Next() { 483 var comment models.PullComment 484 - var commentCreatedStr string 485 - err := commentsRows.Scan( 486 &comment.ID, 487 &comment.PullId, 488 &comment.SubmissionId, ··· 490 &comment.OwnerDid, 491 &comment.CommentAt, 492 &comment.Body, 493 - &commentCreatedStr, 494 ) 495 if err != nil { 496 return nil, err 497 } 498 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) 508 - } 509 - 510 - } 511 - if err = commentsRows.Err(); err != nil { 512 - return nil, err 513 - } 514 - 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 533 } 534 535 // timeframe here is directly passed into the sql query filter, and any ··· 663 return err 664 } 665 666 - func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 667 - newRoundNumber := len(pull.Submissions) 668 _, err := e.Exec(` 669 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 670 values (?, ?, ?, ?, ?) 671 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 672 673 return err 674 }
··· 1 package db 2 3 import ( 4 + "cmp" 5 "database/sql" 6 + "errors" 7 "fmt" 8 + "maps" 9 + "slices" 10 "sort" 11 "strings" 12 "time" ··· 58 parentChangeId = &pull.ParentChangeId 59 } 60 61 + result, err := tx.Exec( 62 ` 63 insert into pulls ( 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 ··· 82 return err 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) 91 + 92 _, err = tx.Exec(` 93 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 values (?, ?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 111 } 112 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 115 116 var conditions []string 117 var args []any ··· 131 132 query := fmt.Sprintf(` 133 select 134 + id, 135 owner_did, 136 repo_at, 137 pull_id, ··· 165 var createdAt string 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 167 err := rows.Scan( 168 + &pull.ID, 169 &pull.OwnerDid, 170 &pull.RepoAt, 171 &pull.PullId, ··· 214 pull.ParentChangeId = parentChangeId.String 215 } 216 217 + pulls[pull.PullAt()] = &pull 218 } 219 220 + var pullAts []syntax.ATURI 221 for _, p := range pulls { 222 + pullAts = append(pullAts, p.PullAt()) 223 } 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 227 } 228 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 232 } 233 + } 234 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) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 243 } 244 } 245 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 252 } 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) 256 } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 } 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 + } 267 } 268 269 orderedByPullId := []*models.Pull{} ··· 282 } 283 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 286 if err != nil { 287 return nil, err 288 } 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 291 } 292 293 + return pulls[0], nil 294 + } 295 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 303 } 304 + 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 308 } 309 310 + query := fmt.Sprintf(` 311 select 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + combined, 317 + created, 318 + source_rev 319 from 320 pull_submissions 321 + %s 322 + order by 323 + round_number asc 324 + `, whereClause) 325 + 326 + rows, err := e.Query(query, args...) 327 if err != nil { 328 return nil, err 329 } 330 + defer rows.Close() 331 332 + submissionMap := make(map[int]*models.PullSubmission) 333 334 + for rows.Next() { 335 var submission models.PullSubmission 336 var submissionCreatedStr string 337 + var submissionSourceRev, submissionCombined sql.NullString 338 + err := rows.Scan( 339 &submission.ID, 340 + &submission.PullAt, 341 &submission.RoundNumber, 342 &submission.Patch, 343 + &submissionCombined, 344 &submissionCreatedStr, 345 &submissionSourceRev, 346 ) ··· 348 return nil, err 349 } 350 351 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 352 + submission.Created = t 353 } 354 355 if submissionSourceRev.Valid { 356 submission.SourceRev = submissionSourceRev.String 357 } 358 359 + if submissionCombined.Valid { 360 + submission.Combined = submissionCombined.String 361 + } 362 + 363 + submissionMap[submission.ID] = &submission 364 } 365 + 366 + if err := rows.Err(); err != nil { 367 return nil, err 368 } 369 + 370 + // Get comments for all submissions using GetPullComments 371 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 372 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 373 + if err != nil { 374 + return nil, err 375 + } 376 + for _, comment := range comments { 377 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 378 + submission.Comments = append(submission.Comments, comment) 379 + } 380 } 381 382 + // group the submissions by pull_at 383 + m := make(map[syntax.ATURI][]*models.PullSubmission) 384 + for _, s := range submissionMap { 385 + m[s.PullAt] = append(m[s.PullAt], s) 386 + } 387 + 388 + // sort each one by round number 389 + for _, s := range m { 390 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 391 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 392 + }) 393 + } 394 + 395 + return m, nil 396 + } 397 + 398 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 399 + var conditions []string 400 var args []any 401 + for _, filter := range filters { 402 + conditions = append(conditions, filter.Condition()) 403 + args = append(args, filter.Arg()...) 404 + } 405 + 406 + whereClause := "" 407 + if conditions != nil { 408 + whereClause = " where " + strings.Join(conditions, " and ") 409 } 410 + 411 + query := fmt.Sprintf(` 412 select 413 id, 414 pull_id, ··· 420 created 421 from 422 pull_comments 423 + %s 424 order by 425 created asc 426 + `, whereClause) 427 + 428 + rows, err := e.Query(query, args...) 429 if err != nil { 430 return nil, err 431 } 432 + defer rows.Close() 433 434 + var comments []models.PullComment 435 + for rows.Next() { 436 var comment models.PullComment 437 + var createdAt string 438 + err := rows.Scan( 439 &comment.ID, 440 &comment.PullId, 441 &comment.SubmissionId, ··· 443 &comment.OwnerDid, 444 &comment.CommentAt, 445 &comment.Body, 446 + &createdAt, 447 ) 448 if err != nil { 449 return nil, err 450 } 451 452 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 453 + comment.Created = t 454 } 455 456 + comments = append(comments, comment) 457 } 458 459 + if err := rows.Err(); err != nil { 460 + return nil, err 461 } 462 463 + return comments, nil 464 } 465 466 // timeframe here is directly passed into the sql query filter, and any ··· 594 return err 595 } 596 597 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 598 _, err := e.Exec(` 599 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 600 values (?, ?, ?, ?, ?) 601 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 602 603 return err 604 }
+34 -7
appview/db/reaction.go
··· 62 return count, nil 63 } 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 67 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 71 } 72 - countMap[kind] = count 73 } 74 - return countMap, nil 75 } 76 77 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
··· 62 return count, nil 63 } 64 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 91 } 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 99 } 100 + 101 + return reactionMap, rows.Err() 102 } 103 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+36 -6
appview/db/repos.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 ) 15 16 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 repoMap := make(map[syntax.ATURI]*models.Repo) 18 ··· 35 36 repoQuery := fmt.Sprintf( 37 `select 38 did, 39 name, 40 knot, ··· 63 var description, source, spindle sql.NullString 64 65 err := rows.Scan( 66 &repo.Did, 67 &repo.Name, 68 &repo.Knot, ··· 327 var repo models.Repo 328 var nullableDescription sql.NullString 329 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 331 332 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 334 return nil, err 335 } 336 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 386 var repos []models.Repo 387 388 rows, err := e.Query( 389 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 390 from repos r 391 left join collaborators c on r.at_uri = c.repo_at 392 where (r.did = ? or c.subject_did = ?) ··· 406 var nullableDescription sql.NullString 407 var nullableSource sql.NullString 408 409 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 410 if err != nil { 411 return nil, err 412 } ··· 443 var nullableSource sql.NullString 444 445 row := e.QueryRow( 446 - `select did, name, knot, rkey, description, created, source 447 from repos 448 where did = ? and name = ? and source is not null and source != ''`, 449 did, name, 450 ) 451 452 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 453 if err != nil { 454 return nil, err 455 }
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/models" 16 ) 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 + 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 repoMap := make(map[syntax.ATURI]*models.Repo) 46 ··· 63 64 repoQuery := fmt.Sprintf( 65 `select 66 + id, 67 did, 68 name, 69 knot, ··· 92 var description, source, spindle sql.NullString 93 94 err := rows.Scan( 95 + &repo.Id, 96 &repo.Did, 97 &repo.Name, 98 &repo.Knot, ··· 357 var repo models.Repo 358 var nullableDescription sql.NullString 359 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 361 362 var createdAt string 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 364 return nil, err 365 } 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 416 var repos []models.Repo 417 418 rows, err := e.Query( 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 420 from repos r 421 left join collaborators c on r.at_uri = c.repo_at 422 where (r.did = ? or c.subject_did = ?) ··· 436 var nullableDescription sql.NullString 437 var nullableSource sql.NullString 438 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 440 if err != nil { 441 return nil, err 442 } ··· 473 var nullableSource sql.NullString 474 475 row := e.QueryRow( 476 + `select id, did, name, knot, rkey, description, created, source 477 from repos 478 where did = ? and name = ? and source is not null and source != ''`, 479 did, name, 480 ) 481 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 483 if err != nil { 484 return nil, err 485 }
+38 -10
appview/db/timeline.go
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 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) { 13 var events []models.TimelineEvent 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 16 if err != nil { 17 return nil, err 18 } 19 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 21 if err != nil { 22 return nil, err 23 } 24 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 26 if err != nil { 27 return nil, err 28 } ··· 70 return isStarred, starCount 71 } 72 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 75 if err != nil { 76 return nil, err 77 } ··· 125 return events, nil 126 } 127 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 130 if err != nil { 131 return nil, err 132 } ··· 166 return events, nil 167 } 168 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 171 if err != nil { 172 return nil, err 173 }
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 var events []models.TimelineEvent 14 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 39 if err != nil { 40 return nil, err 41 } ··· 83 return isStarred, starCount 84 } 85 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...) 93 if err != nil { 94 return nil, err 95 } ··· 143 return events, nil 144 } 145 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...) 153 if err != nil { 154 return nil, err 155 } ··· 189 return events, nil 190 } 191 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...) 199 if err != nil { 200 return nil, err 201 }
+4 -4
appview/dns/cloudflare.go
··· 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 } 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 Type: record.Type, 36 Name: record.Name, 37 Content: record.Content, ··· 39 Proxied: &record.Proxied, 40 }) 41 if err != nil { 42 - return fmt.Errorf("failed to create DNS record: %w", err) 43 } 44 - return nil 45 } 46 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
··· 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 } 32 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 Type: record.Type, 36 Name: record.Name, 37 Content: record.Content, ··· 39 Proxied: &record.Proxied, 40 }) 41 if err != nil { 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 } 44 + return result.ID, nil 45 } 46 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+2 -2
appview/ingester.go
··· 89 } 90 91 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 93 } 94 95 return nil ··· 1008 if !ok { 1009 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 } 1011 - if err := i.Validator.ValidateLabelOp(def, &o); err != nil { 1012 return fmt.Errorf("failed to validate labelop: %w", err) 1013 } 1014 }
··· 89 } 90 91 if err != nil { 92 + l.Warn("refused to ingest record", "err", err) 93 } 94 95 return nil ··· 1008 if !ok { 1009 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 } 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 return fmt.Errorf("failed to validate labelop: %w", err) 1013 } 1014 }
+54 -40
appview/issues/issues.go
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 - "log" 9 "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 "tangled.org/core/idresolver" 31 - tlog "tangled.org/core/log" 32 "tangled.org/core/tid" 33 ) 34 ··· 53 config *config.Config, 54 notifier notify.Notifier, 55 validator *validator.Validator, 56 ) *Issues { 57 return &Issues{ 58 oauth: oauth, ··· 62 db: db, 63 config: config, 64 notifier: notifier, 65 - logger: tlog.New("issues"), 66 validator: validator, 67 } 68 } ··· 72 user := rp.oauth.GetUser(r) 73 f, err := rp.repoResolver.Resolve(r) 74 if err != nil { 75 - log.Println("failed to get repo and knot", err) 76 return 77 } 78 ··· 83 return 84 } 85 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 if err != nil { 88 l.Error("failed to get issue reactions", "err", err) 89 } ··· 99 db.FilterContains("scope", tangled.RepoIssueNSID), 100 ) 101 if err != nil { 102 - log.Println("failed to fetch labels", err) 103 rp.pages.Error503(w) 104 return 105 } ··· 115 Issue: issue, 116 CommentList: issue.CommentList(), 117 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 119 UserReacted: userReactions, 120 LabelDefs: defs, 121 }) ··· 126 user := rp.oauth.GetUser(r) 127 f, err := rp.repoResolver.Resolve(r) 128 if err != nil { 129 - log.Println("failed to get repo and knot", err) 130 return 131 } 132 ··· 166 return 167 } 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 199 200 err = db.PutIssue(tx, newIssue) 201 if err != nil { 202 - log.Println("failed to edit issue", err) 203 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 return 205 } ··· 237 // delete from PDS 238 client, err := rp.oauth.AuthorizedClient(r) 239 if err != nil { 240 - log.Println("failed to get authorized client", err) 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 282 283 collaborators, err := f.Collaborators(r.Context()) 284 if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 286 } 287 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 return user.Did == collab.Did ··· 296 db.FilterEq("id", issue.Id), 297 ) 298 if err != nil { 299 - log.Println("failed to close issue", err) 300 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 return 302 } 303 304 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 305 return 306 } else { 307 - log.Println("user is not permitted to close issue") 308 http.Error(w, "for biden", http.StatusUnauthorized) 309 return 310 } ··· 315 user := rp.oauth.GetUser(r) 316 f, err := rp.repoResolver.Resolve(r) 317 if err != nil { 318 - log.Println("failed to get repo and knot", err) 319 return 320 } 321 ··· 328 329 collaborators, err := f.Collaborators(r.Context()) 330 if err != nil { 331 - log.Println("failed to fetch repo collaborators: %w", err) 332 } 333 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 334 return user.Did == collab.Did ··· 341 db.FilterEq("id", issue.Id), 342 ) 343 if err != nil { 344 - log.Println("failed to reopen issue", err) 345 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 346 return 347 } 348 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 349 return 350 } else { 351 - log.Println("user is not the owner of the repo") 352 http.Error(w, "forbidden", http.StatusUnauthorized) 353 return 354 } ··· 405 } 406 407 // create a record first 408 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 409 Collection: tangled.RepoIssueCommentNSID, 410 Repo: comment.Did, 411 Rkey: comment.Rkey, ··· 434 435 // reset atUri to make rollback a no-op 436 atUri = "" 437 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 438 } 439 ··· 530 newBody := r.FormValue("body") 531 client, err := rp.oauth.AuthorizedClient(r) 532 if err != nil { 533 - log.Println("failed to get authorized client", err) 534 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 535 return 536 } ··· 543 544 _, err = db.AddIssueComment(rp.db, newComment) 545 if err != nil { 546 - log.Println("failed to perferom update-description query", err) 547 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 548 return 549 } ··· 551 // rkey is optional, it was introduced later 552 if newComment.Rkey != "" { 553 // update the record on pds 554 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 555 if err != nil { 556 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 557 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 558 return 559 } 560 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 562 Collection: tangled.RepoIssueCommentNSID, 563 Repo: user.Did, 564 Rkey: newComment.Rkey, ··· 721 if comment.Rkey != "" { 722 client, err := rp.oauth.AuthorizedClient(r) 723 if err != nil { 724 - log.Println("failed to get authorized client", err) 725 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 726 return 727 } 728 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 729 Collection: tangled.RepoIssueCommentNSID, 730 Repo: user.Did, 731 Rkey: comment.Rkey, 732 }) 733 if err != nil { 734 - log.Println(err) 735 } 736 } 737 ··· 749 } 750 751 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 752 params := r.URL.Query() 753 state := params.Get("state") 754 isOpen := true ··· 763 764 page, ok := r.Context().Value("page").(pagination.Page) 765 if !ok { 766 - log.Println("failed to get page") 767 page = pagination.FirstPage() 768 } 769 770 user := rp.oauth.GetUser(r) 771 f, err := rp.repoResolver.Resolve(r) 772 if err != nil { 773 - log.Println("failed to get repo and knot", err) 774 return 775 } 776 ··· 785 db.FilterEq("open", openVal), 786 ) 787 if err != nil { 788 - log.Println("failed to get issues", err) 789 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 790 return 791 } 792 793 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 794 if err != nil { 795 - log.Println("failed to fetch labels", err) 796 rp.pages.Error503(w) 797 return 798 } ··· 836 Body: r.FormValue("body"), 837 Did: user.Did, 838 Created: time.Now(), 839 } 840 841 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 853 rp.pages.Notice(w, "issues", "Failed to create issue.") 854 return 855 } 856 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 857 Collection: tangled.RepoIssueNSID, 858 Repo: user.Did, 859 Rkey: issue.Rkey, ··· 889 890 err = db.PutIssue(tx, issue) 891 if err != nil { 892 - log.Println("failed to create issue", err) 893 rp.pages.Notice(w, "issues", "Failed to create issue.") 894 return 895 } 896 897 if err = tx.Commit(); err != nil { 898 - log.Println("failed to create issue", err) 899 rp.pages.Notice(w, "issues", "Failed to create issue.") 900 return 901 } ··· 911 // this is used to rollback changes made to the PDS 912 // 913 // it is a no-op if the provided ATURI is empty 914 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 915 if aturi == "" { 916 return nil 917 } ··· 922 repo := parsed.Authority().String() 923 rkey := parsed.RecordKey().String() 924 925 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 926 Collection: collection, 927 Repo: repo, 928 Rkey: rkey,
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "slices" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 "tangled.org/core/idresolver" 30 "tangled.org/core/tid" 31 ) 32 ··· 51 config *config.Config, 52 notifier notify.Notifier, 53 validator *validator.Validator, 54 + logger *slog.Logger, 55 ) *Issues { 56 return &Issues{ 57 oauth: oauth, ··· 61 db: db, 62 config: config, 63 notifier: notifier, 64 + logger: logger, 65 validator: validator, 66 } 67 } ··· 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { 74 + l.Error("failed to get repo and knot", "err", err) 75 return 76 } 77 ··· 82 return 83 } 84 85 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 86 if err != nil { 87 l.Error("failed to get issue reactions", "err", err) 88 } ··· 98 db.FilterContains("scope", tangled.RepoIssueNSID), 99 ) 100 if err != nil { 101 + l.Error("failed to fetch labels", "err", err) 102 rp.pages.Error503(w) 103 return 104 } ··· 114 Issue: issue, 115 CommentList: issue.CommentList(), 116 OrderedReactionKinds: models.OrderedReactionKinds, 117 + Reactions: reactionMap, 118 UserReacted: userReactions, 119 LabelDefs: defs, 120 }) ··· 125 user := rp.oauth.GetUser(r) 126 f, err := rp.repoResolver.Resolve(r) 127 if err != nil { 128 + l.Error("failed to get repo and knot", "err", err) 129 return 130 } 131 ··· 165 return 166 } 167 168 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 169 if err != nil { 170 l.Error("failed to get record", "err", err) 171 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 172 return 173 } 174 175 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 176 Collection: tangled.RepoIssueNSID, 177 Repo: user.Did, 178 Rkey: newIssue.Rkey, ··· 198 199 err = db.PutIssue(tx, newIssue) 200 if err != nil { 201 + l.Error("failed to edit issue", "err", err) 202 rp.pages.Notice(w, "issues", "Failed to edit issue.") 203 return 204 } ··· 236 // delete from PDS 237 client, err := rp.oauth.AuthorizedClient(r) 238 if err != nil { 239 + l.Error("failed to get authorized client", "err", err) 240 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 241 return 242 } 243 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 244 Collection: tangled.RepoIssueNSID, 245 Repo: issue.Did, 246 Rkey: issue.Rkey, ··· 281 282 collaborators, err := f.Collaborators(r.Context()) 283 if err != nil { 284 + l.Error("failed to fetch repo collaborators", "err", err) 285 } 286 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 287 return user.Did == collab.Did ··· 295 db.FilterEq("id", issue.Id), 296 ) 297 if err != nil { 298 + l.Error("failed to close issue", "err", err) 299 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 300 return 301 } 302 303 + // notify about the issue closure 304 + rp.notifier.NewIssueClosed(r.Context(), issue) 305 + 306 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 307 return 308 } else { 309 + l.Error("user is not permitted to close issue") 310 http.Error(w, "for biden", http.StatusUnauthorized) 311 return 312 } ··· 317 user := rp.oauth.GetUser(r) 318 f, err := rp.repoResolver.Resolve(r) 319 if err != nil { 320 + l.Error("failed to get repo and knot", "err", err) 321 return 322 } 323 ··· 330 331 collaborators, err := f.Collaborators(r.Context()) 332 if err != nil { 333 + l.Error("failed to fetch repo collaborators", "err", err) 334 } 335 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 336 return user.Did == collab.Did ··· 343 db.FilterEq("id", issue.Id), 344 ) 345 if err != nil { 346 + l.Error("failed to reopen issue", "err", err) 347 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 348 return 349 } 350 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 351 return 352 } else { 353 + l.Error("user is not the owner of the repo") 354 http.Error(w, "forbidden", http.StatusUnauthorized) 355 return 356 } ··· 407 } 408 409 // create a record first 410 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 411 Collection: tangled.RepoIssueCommentNSID, 412 Repo: comment.Did, 413 Rkey: comment.Rkey, ··· 436 437 // reset atUri to make rollback a no-op 438 atUri = "" 439 + 440 + // notify about the new comment 441 + comment.Id = commentId 442 + rp.notifier.NewIssueComment(r.Context(), &comment) 443 + 444 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 445 } 446 ··· 537 newBody := r.FormValue("body") 538 client, err := rp.oauth.AuthorizedClient(r) 539 if err != nil { 540 + l.Error("failed to get authorized client", "err", err) 541 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 542 return 543 } ··· 550 551 _, err = db.AddIssueComment(rp.db, newComment) 552 if err != nil { 553 + l.Error("failed to perferom update-description query", "err", err) 554 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 555 return 556 } ··· 558 // rkey is optional, it was introduced later 559 if newComment.Rkey != "" { 560 // update the record on pds 561 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 562 if err != nil { 563 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 564 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 565 return 566 } 567 568 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 569 Collection: tangled.RepoIssueCommentNSID, 570 Repo: user.Did, 571 Rkey: newComment.Rkey, ··· 728 if comment.Rkey != "" { 729 client, err := rp.oauth.AuthorizedClient(r) 730 if err != nil { 731 + l.Error("failed to get authorized client", "err", err) 732 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 733 return 734 } 735 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 736 Collection: tangled.RepoIssueCommentNSID, 737 Repo: user.Did, 738 Rkey: comment.Rkey, 739 }) 740 if err != nil { 741 + l.Error("failed to delete from PDS", "err", err) 742 } 743 } 744 ··· 756 } 757 758 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 759 + l := rp.logger.With("handler", "RepoIssues") 760 + 761 params := r.URL.Query() 762 state := params.Get("state") 763 isOpen := true ··· 772 773 page, ok := r.Context().Value("page").(pagination.Page) 774 if !ok { 775 + l.Error("failed to get page") 776 page = pagination.FirstPage() 777 } 778 779 user := rp.oauth.GetUser(r) 780 f, err := rp.repoResolver.Resolve(r) 781 if err != nil { 782 + l.Error("failed to get repo and knot", "err", err) 783 return 784 } 785 ··· 794 db.FilterEq("open", openVal), 795 ) 796 if err != nil { 797 + l.Error("failed to get issues", "err", err) 798 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 799 return 800 } 801 802 + labelDefs, err := db.GetLabelDefinitions( 803 + rp.db, 804 + db.FilterIn("at_uri", f.Repo.Labels), 805 + db.FilterContains("scope", tangled.RepoIssueNSID), 806 + ) 807 if err != nil { 808 + l.Error("failed to fetch labels", "err", err) 809 rp.pages.Error503(w) 810 return 811 } ··· 849 Body: r.FormValue("body"), 850 Did: user.Did, 851 Created: time.Now(), 852 + Repo: &f.Repo, 853 } 854 855 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 867 rp.pages.Notice(w, "issues", "Failed to create issue.") 868 return 869 } 870 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 871 Collection: tangled.RepoIssueNSID, 872 Repo: user.Did, 873 Rkey: issue.Rkey, ··· 903 904 err = db.PutIssue(tx, issue) 905 if err != nil { 906 + l.Error("failed to create issue", "err", err) 907 rp.pages.Notice(w, "issues", "Failed to create issue.") 908 return 909 } 910 911 if err = tx.Commit(); err != nil { 912 + l.Error("failed to create issue", "err", err) 913 rp.pages.Notice(w, "issues", "Failed to create issue.") 914 return 915 } ··· 925 // this is used to rollback changes made to the PDS 926 // 927 // it is a no-op if the provided ATURI is empty 928 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 929 if aturi == "" { 930 return nil 931 } ··· 936 repo := parsed.Authority().String() 937 rkey := parsed.RecordKey().String() 938 939 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 940 Collection: collection, 941 Repo: repo, 942 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 = "static/icons/circle-dot.svg" 147 + statusText = "open" 148 + statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 + } else { 150 + statusIcon = "static/icons/circle-dot.svg" 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.DrawSVGIcon(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.DrawSVGIcon("static/icons/message-square.svg", 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", 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 r.Route("/{issue}", func(r chi.Router) { 17 r.Use(mw.ResolveIssue) 18 r.Get("/", i.RepoSingleIssue) 19 20 // authenticated routes 21 r.Group(func(r chi.Router) {
··· 16 r.Route("/{issue}", func(r chi.Router) { 17 r.Use(mw.ResolveIssue) 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 20 21 // authenticated routes 22 r.Group(func(r chi.Router) {
+6 -6
appview/knots/knots.go
··· 185 return 186 } 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
··· 185 return 186 } 187 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
+24 -16
appview/labels/labels.go
··· 9 "net/http" 10 "time" 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 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/middleware" ··· 21 "tangled.org/core/appview/oauth" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 - "tangled.org/core/log" 26 "tangled.org/core/tid" 27 ) 28 29 type Labels struct { ··· 32 db *db.DB 33 logger *slog.Logger 34 validator *validator.Validator 35 } 36 37 func New( ··· 39 pages *pages.Pages, 40 db *db.DB, 41 validator *validator.Validator, 42 ) *Labels { 43 - logger := log.New("labels") 44 - 45 return &Labels{ 46 oauth: oauth, 47 pages: pages, 48 db: db, 49 logger: logger, 50 validator: validator, 51 } 52 } 53 ··· 86 repoAt := r.Form.Get("repo") 87 subjectUri := r.Form.Get("subject") 88 89 // find all the labels that this repo subscribes to 90 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 91 if err != nil { ··· 152 } 153 } 154 155 - // reduce the opset 156 - labelOps = models.ReduceLabelOps(labelOps) 157 - 158 for i := range labelOps { 159 def := actx.Defs[labelOps[i].OperandKey] 160 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 161 fail(fmt.Sprintf("Invalid form data: %s", err), err) 162 return 163 } 164 } 165 166 // next, apply all ops introduced in this request and filter out ones that are no-ops 167 validLabelOps := labelOps[:0] ··· 186 return 187 } 188 189 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 190 Collection: tangled.LabelOpNSID, 191 Repo: did, 192 Rkey: rkey, ··· 242 // this is used to rollback changes made to the PDS 243 // 244 // it is a no-op if the provided ATURI is empty 245 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 246 if aturi == "" { 247 return nil 248 } ··· 253 repo := parsed.Authority().String() 254 rkey := parsed.RecordKey().String() 255 256 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 257 Collection: collection, 258 Repo: repo, 259 Rkey: rkey,
··· 9 "net/http" 10 "time" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/middleware" ··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 + "tangled.org/core/rbac" 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 ) 28 29 type Labels struct { ··· 32 db *db.DB 33 logger *slog.Logger 34 validator *validator.Validator 35 + enforcer *rbac.Enforcer 36 } 37 38 func New( ··· 40 pages *pages.Pages, 41 db *db.DB, 42 validator *validator.Validator, 43 + enforcer *rbac.Enforcer, 44 + logger *slog.Logger, 45 ) *Labels { 46 return &Labels{ 47 oauth: oauth, 48 pages: pages, 49 db: db, 50 logger: logger, 51 validator: validator, 52 + enforcer: enforcer, 53 } 54 } 55 ··· 88 repoAt := r.Form.Get("repo") 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 + } 96 + 97 // find all the labels that this repo subscribes to 98 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 99 if err != nil { ··· 160 } 161 } 162 163 for i := range labelOps { 164 def := actx.Defs[labelOps[i].OperandKey] 165 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 166 fail(fmt.Sprintf("Invalid form data: %s", err), err) 167 return 168 } 169 } 170 + 171 + // reduce the opset 172 + labelOps = models.ReduceLabelOps(labelOps) 173 174 // next, apply all ops introduced in this request and filter out ones that are no-ops 175 validLabelOps := labelOps[:0] ··· 194 return 195 } 196 197 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 198 Collection: tangled.LabelOpNSID, 199 Repo: did, 200 Rkey: rkey, ··· 250 // this is used to rollback changes made to the PDS 251 // 252 // it is a no-op if the provided ATURI is empty 253 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 254 if aturi == "" { 255 return nil 256 } ··· 261 repo := parsed.Authority().String() 262 rkey := parsed.RecordKey().String() 263 264 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 265 Collection: collection, 266 Repo: repo, 267 Rkey: rkey,
+5 -5
appview/middleware/middleware.go
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 - _, auth, err := a.GetSession(r) 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 69 redirectFunc(w, r) 70 return 71 } 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 75 redirectFunc(w, r) 76 return 77 }
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 + sess, err := o.ResumeSession(r) 67 if err != nil { 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 redirectFunc(w, r) 70 return 71 } 72 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 redirectFunc(w, r) 76 return 77 }
+24
appview/models/issue.go
··· 54 Replies []*IssueComment 55 } 56 57 func (i *Issue) CommentList() []CommentListItem { 58 // Create a map to quickly find comments by their aturi 59 toplevel := make(map[string]*CommentListItem) ··· 167 168 func (i *IssueComment) IsTopLevel() bool { 169 return i.ReplyTo == nil 170 } 171 172 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
··· 54 Replies []*IssueComment 55 } 56 57 + func (it *CommentListItem) Participants() []syntax.DID { 58 + participantSet := make(map[syntax.DID]struct{}) 59 + participants := []syntax.DID{} 60 + 61 + addParticipant := func(did syntax.DID) { 62 + if _, exists := participantSet[did]; !exists { 63 + participantSet[did] = struct{}{} 64 + participants = append(participants, did) 65 + } 66 + } 67 + 68 + addParticipant(syntax.DID(it.Self.Did)) 69 + 70 + for _, c := range it.Replies { 71 + addParticipant(syntax.DID(c.Did)) 72 + } 73 + 74 + return participants 75 + } 76 + 77 func (i *Issue) CommentList() []CommentListItem { 78 // Create a map to quickly find comments by their aturi 79 toplevel := make(map[string]*CommentListItem) ··· 187 188 func (i *IssueComment) IsTopLevel() bool { 189 return i.ReplyTo == nil 190 + } 191 + 192 + func (i *IssueComment) IsReply() bool { 193 + return i.ReplyTo != nil 194 } 195 196 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+19 -17
appview/models/label.go
··· 232 } 233 234 var ops []LabelOp 235 - for _, o := range record.Add { 236 if o != nil { 237 op := mkOp(o) 238 - op.Operation = LabelOperationAdd 239 ops = append(ops, op) 240 } 241 } 242 - for _, o := range record.Delete { 243 if o != nil { 244 op := mkOp(o) 245 - op.Operation = LabelOperationDel 246 ops = append(ops, op) 247 } 248 } ··· 460 return result 461 } 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) {
··· 232 } 233 234 var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 if o != nil { 238 op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 ops = append(ops, op) 241 } 242 } 243 + for _, o := range record.Add { 244 if o != nil { 245 op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 ops = append(ops, op) 248 } 249 } ··· 461 return result 462 } 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 } 480 } 481 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+124
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 + NotificationTypePullClosed NotificationType = "pull_closed" 21 + ) 22 + 23 + type Notification struct { 24 + ID int64 25 + RecipientDid string 26 + ActorDid string 27 + Type NotificationType 28 + EntityType string 29 + EntityId string 30 + Read bool 31 + Created time.Time 32 + 33 + // foreign key references 34 + RepoId *int64 35 + IssueId *int64 36 + PullId *int64 37 + } 38 + 39 + // lucide icon that represents this notification 40 + func (n *Notification) Icon() string { 41 + switch n.Type { 42 + case NotificationTypeRepoStarred: 43 + return "star" 44 + case NotificationTypeIssueCreated: 45 + return "circle-dot" 46 + case NotificationTypeIssueCommented: 47 + return "message-square" 48 + case NotificationTypeIssueClosed: 49 + return "ban" 50 + case NotificationTypePullCreated: 51 + return "git-pull-request-create" 52 + case NotificationTypePullCommented: 53 + return "message-square" 54 + case NotificationTypePullMerged: 55 + return "git-merge" 56 + case NotificationTypePullClosed: 57 + return "git-pull-request-closed" 58 + case NotificationTypeFollowed: 59 + return "user-plus" 60 + default: 61 + return "" 62 + } 63 + } 64 + 65 + type NotificationWithEntity struct { 66 + *Notification 67 + Repo *Repo 68 + Issue *Issue 69 + Pull *Pull 70 + } 71 + 72 + type NotificationPreferences struct { 73 + ID int64 74 + UserDid syntax.DID 75 + RepoStarred bool 76 + IssueCreated bool 77 + IssueCommented bool 78 + PullCreated bool 79 + PullCommented bool 80 + Followed bool 81 + PullMerged bool 82 + IssueClosed bool 83 + EmailNotifications bool 84 + } 85 + 86 + func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool { 87 + switch t { 88 + case NotificationTypeRepoStarred: 89 + return prefs.RepoStarred 90 + case NotificationTypeIssueCreated: 91 + return prefs.IssueCreated 92 + case NotificationTypeIssueCommented: 93 + return prefs.IssueCommented 94 + case NotificationTypeIssueClosed: 95 + return prefs.IssueClosed 96 + case NotificationTypePullCreated: 97 + return prefs.PullCreated 98 + case NotificationTypePullCommented: 99 + return prefs.PullCommented 100 + case NotificationTypePullMerged: 101 + return prefs.PullMerged 102 + case NotificationTypePullClosed: 103 + return prefs.PullMerged // same pref for now 104 + case NotificationTypeFollowed: 105 + return prefs.Followed 106 + default: 107 + return false 108 + } 109 + } 110 + 111 + func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences { 112 + return &NotificationPreferences{ 113 + UserDid: user, 114 + RepoStarred: true, 115 + IssueCreated: true, 116 + IssueCommented: true, 117 + PullCreated: true, 118 + PullCommented: true, 119 + Followed: true, 120 + PullMerged: true, 121 + IssueClosed: true, 122 + EmailNotifications: false, 123 + } 124 + }
+76 -27
appview/models/pull.go
··· 77 PullSource *PullSource 78 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 81 } 82 83 func (p Pull) AsRecord() tangled.RepoPull { 84 var source *tangled.RepoPull_Source 85 if p.PullSource != nil { 86 - s := p.PullSource.AsRecord() 87 - source = &s 88 source.Sha = p.LatestSha() 89 } 90 91 record := tangled.RepoPull{ ··· 110 Repo *Repo 111 } 112 113 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 114 - var repoAt *string 115 - if p.RepoAt != nil { 116 - s := p.RepoAt.String() 117 - repoAt = &s 118 - } 119 - record := tangled.RepoPull_Source{ 120 - Branch: p.Branch, 121 - Repo: repoAt, 122 - } 123 - return record 124 - } 125 - 126 type PullSubmission struct { 127 // ids 128 - ID int 129 - PullId int 130 131 // at ids 132 - RepoAt syntax.ATURI 133 134 // content 135 RoundNumber int 136 Patch string 137 Comments []PullComment 138 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 ··· 159 Created time.Time 160 } 161 162 func (p *Pull) LatestPatch() string { 163 - latestSubmission := p.Submissions[p.LastRoundNumber()] 164 - return latestSubmission.Patch 165 } 166 167 func (p *Pull) LatestSha() string { 168 - latestSubmission := p.Submissions[p.LastRoundNumber()] 169 - return latestSubmission.SourceRev 170 } 171 172 func (p *Pull) PullAt() syntax.ATURI { 173 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 - } 175 - 176 - func (p *Pull) LastRoundNumber() int { 177 - return len(p.Submissions) - 1 178 } 179 180 func (p *Pull) IsPatchBased() bool { ··· 207 return p.StackId != "" 208 } 209 210 func (s PullSubmission) IsFormatPatch() bool { 211 return patchutil.IsFormatPatch(s.Patch) 212 } ··· 219 } 220 221 return patches 222 } 223 224 type Stack []*Pull ··· 308 309 return mergeable 310 }
··· 77 PullSource *PullSource 78 79 // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 } 83 84 func (p Pull) AsRecord() tangled.RepoPull { 85 var source *tangled.RepoPull_Source 86 if p.PullSource != nil { 87 + source = &tangled.RepoPull_Source{} 88 + source.Branch = p.PullSource.Branch 89 source.Sha = p.LatestSha() 90 + if p.PullSource.RepoAt != nil { 91 + s := p.PullSource.RepoAt.String() 92 + source.Repo = &s 93 + } 94 } 95 96 record := tangled.RepoPull{ ··· 115 Repo *Repo 116 } 117 118 type PullSubmission struct { 119 // ids 120 + ID int 121 122 // at ids 123 + PullAt syntax.ATURI 124 125 // content 126 RoundNumber int 127 Patch string 128 + Combined string 129 Comments []PullComment 130 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 131 ··· 151 Created time.Time 152 } 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 func (p *Pull) LatestPatch() string { 163 + return p.LatestSubmission().Patch 164 } 165 166 func (p *Pull) LatestSha() string { 167 + return p.LatestSubmission().SourceRev 168 } 169 170 func (p *Pull) PullAt() syntax.ATURI { 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 172 } 173 174 func (p *Pull) IsPatchBased() bool { ··· 201 return p.StackId != "" 202 } 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 + 226 func (s PullSubmission) IsFormatPatch() bool { 227 return patchutil.IsFormatPatch(s.Patch) 228 } ··· 235 } 236 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 266 } 267 268 type Stack []*Pull ··· 352 353 return mergeable 354 } 355 + 356 + type BranchDeleteStatus struct { 357 + Repo *Repo 358 + Branch string 359 + }
+5
appview/models/reaction.go
··· 55 Rkey string 56 Kind ReactionKind 57 }
··· 55 Rkey string 56 Kind ReactionKind 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+6
appview/models/repo.go
··· 10 ) 11 12 type Repo struct { 13 Did string 14 Name string 15 Knot string ··· 85 RepoAt syntax.ATURI 86 LabelAt syntax.ATURI 87 }
··· 10 ) 11 12 type Repo struct { 13 + Id int64 14 Did string 15 Name string 16 Knot string ··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+169
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, ok := r.Context().Value("page").(pagination.Page) 53 + if !ok { 54 + l.Error("failed to get page") 55 + page = pagination.FirstPage() 56 + } 57 + 58 + total, err := db.CountNotifications( 59 + n.db, 60 + db.FilterEq("recipient_did", user.Did), 61 + ) 62 + if err != nil { 63 + l.Error("failed to get total notifications", "err", err) 64 + n.pages.Error500(w) 65 + return 66 + } 67 + 68 + notifications, err := db.GetNotificationsWithEntities( 69 + n.db, 70 + page, 71 + db.FilterEq("recipient_did", user.Did), 72 + ) 73 + if err != nil { 74 + l.Error("failed to get notifications", "err", err) 75 + n.pages.Error500(w) 76 + return 77 + } 78 + 79 + err = db.MarkAllNotificationsRead(n.db, user.Did) 80 + if err != nil { 81 + l.Error("failed to mark notifications as read", "err", err) 82 + } 83 + 84 + unreadCount := 0 85 + 86 + n.pages.Notifications(w, pages.NotificationsParams{ 87 + LoggedInUser: user, 88 + Notifications: notifications, 89 + UnreadCount: unreadCount, 90 + Page: page, 91 + Total: total, 92 + }) 93 + } 94 + 95 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 96 + user := n.oauth.GetUser(r) 97 + if user == nil { 98 + return 99 + } 100 + 101 + count, err := db.CountNotifications( 102 + n.db, 103 + db.FilterEq("recipient_did", user.Did), 104 + db.FilterEq("read", 0), 105 + ) 106 + if err != nil { 107 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 108 + return 109 + } 110 + 111 + params := pages.NotificationCountParams{ 112 + Count: count, 113 + } 114 + err = n.pages.NotificationCount(w, params) 115 + if err != nil { 116 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 117 + return 118 + } 119 + } 120 + 121 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 122 + userDid := n.oauth.GetDid(r) 123 + 124 + idStr := chi.URLParam(r, "id") 125 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 126 + if err != nil { 127 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 128 + return 129 + } 130 + 131 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 132 + if err != nil { 133 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 134 + return 135 + } 136 + 137 + w.WriteHeader(http.StatusNoContent) 138 + } 139 + 140 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 141 + userDid := n.oauth.GetDid(r) 142 + 143 + err := db.MarkAllNotificationsRead(n.db, userDid) 144 + if err != nil { 145 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 146 + return 147 + } 148 + 149 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 150 + } 151 + 152 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 153 + userDid := n.oauth.GetDid(r) 154 + 155 + idStr := chi.URLParam(r, "id") 156 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 157 + if err != nil { 158 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 159 + return 160 + } 161 + 162 + err = db.DeleteNotification(n.db, notificationID, userDid) 163 + if err != nil { 164 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 165 + return 166 + } 167 + 168 + w.WriteHeader(http.StatusOK) 169 + }
+481
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 + type databaseNotifier struct { 17 + db *db.DB 18 + res *idresolver.Resolver 19 + } 20 + 21 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 22 + return &databaseNotifier{ 23 + db: database, 24 + res: resolver, 25 + } 26 + } 27 + 28 + var _ notify.Notifier = &databaseNotifier{} 29 + 30 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 31 + // no-op for now 32 + } 33 + 34 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 35 + var err error 36 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 37 + if err != nil { 38 + log.Printf("NewStar: failed to get repos: %v", err) 39 + return 40 + } 41 + 42 + actorDid := syntax.DID(star.StarredByDid) 43 + recipients := []syntax.DID{syntax.DID(repo.Did)} 44 + eventType := models.NotificationTypeRepoStarred 45 + entityType := "repo" 46 + entityId := star.RepoAt.String() 47 + repoId := &repo.Id 48 + var issueId *int64 49 + var pullId *int64 50 + 51 + n.notifyEvent( 52 + actorDid, 53 + recipients, 54 + eventType, 55 + entityType, 56 + entityId, 57 + repoId, 58 + issueId, 59 + pullId, 60 + ) 61 + } 62 + 63 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 64 + // no-op 65 + } 66 + 67 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 68 + 69 + // build the recipients list 70 + // - owner of the repo 71 + // - collaborators in the repo 72 + var recipients []syntax.DID 73 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 74 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 75 + if err != nil { 76 + log.Printf("failed to fetch collaborators: %v", err) 77 + return 78 + } 79 + for _, c := range collaborators { 80 + recipients = append(recipients, c.SubjectDid) 81 + } 82 + 83 + actorDid := syntax.DID(issue.Did) 84 + eventType := models.NotificationTypeIssueCreated 85 + entityType := "issue" 86 + entityId := issue.AtUri().String() 87 + repoId := &issue.Repo.Id 88 + issueId := &issue.Id 89 + var pullId *int64 90 + 91 + n.notifyEvent( 92 + actorDid, 93 + recipients, 94 + eventType, 95 + entityType, 96 + entityId, 97 + repoId, 98 + issueId, 99 + pullId, 100 + ) 101 + } 102 + 103 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 104 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 105 + if err != nil { 106 + log.Printf("NewIssueComment: failed to get issues: %v", err) 107 + return 108 + } 109 + if len(issues) == 0 { 110 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 111 + return 112 + } 113 + issue := issues[0] 114 + 115 + var recipients []syntax.DID 116 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 117 + 118 + if comment.IsReply() { 119 + // if this comment is a reply, then notify everybody in that thread 120 + parentAtUri := *comment.ReplyTo 121 + allThreads := issue.CommentList() 122 + 123 + // find the parent thread, and add all DIDs from here to the recipient list 124 + for _, t := range allThreads { 125 + if t.Self.AtUri().String() == parentAtUri { 126 + recipients = append(recipients, t.Participants()...) 127 + } 128 + } 129 + } else { 130 + // not a reply, notify just the issue author 131 + recipients = append(recipients, syntax.DID(issue.Did)) 132 + } 133 + 134 + actorDid := syntax.DID(comment.Did) 135 + eventType := models.NotificationTypeIssueCommented 136 + entityType := "issue" 137 + entityId := issue.AtUri().String() 138 + repoId := &issue.Repo.Id 139 + issueId := &issue.Id 140 + var pullId *int64 141 + 142 + n.notifyEvent( 143 + actorDid, 144 + recipients, 145 + eventType, 146 + entityType, 147 + entityId, 148 + repoId, 149 + issueId, 150 + pullId, 151 + ) 152 + } 153 + 154 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 155 + actorDid := syntax.DID(follow.UserDid) 156 + recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 157 + eventType := models.NotificationTypeFollowed 158 + entityType := "follow" 159 + entityId := follow.UserDid 160 + var repoId, issueId, pullId *int64 161 + 162 + n.notifyEvent( 163 + actorDid, 164 + recipients, 165 + eventType, 166 + entityType, 167 + entityId, 168 + repoId, 169 + issueId, 170 + pullId, 171 + ) 172 + } 173 + 174 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 175 + // no-op 176 + } 177 + 178 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 179 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 180 + if err != nil { 181 + log.Printf("NewPull: failed to get repos: %v", err) 182 + return 183 + } 184 + 185 + // build the recipients list 186 + // - owner of the repo 187 + // - collaborators in the repo 188 + var recipients []syntax.DID 189 + recipients = append(recipients, syntax.DID(repo.Did)) 190 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 191 + if err != nil { 192 + log.Printf("failed to fetch collaborators: %v", err) 193 + return 194 + } 195 + for _, c := range collaborators { 196 + recipients = append(recipients, c.SubjectDid) 197 + } 198 + 199 + actorDid := syntax.DID(pull.OwnerDid) 200 + eventType := models.NotificationTypePullCreated 201 + entityType := "pull" 202 + entityId := pull.PullAt().String() 203 + repoId := &repo.Id 204 + var issueId *int64 205 + p := int64(pull.ID) 206 + pullId := &p 207 + 208 + n.notifyEvent( 209 + actorDid, 210 + recipients, 211 + eventType, 212 + entityType, 213 + entityId, 214 + repoId, 215 + issueId, 216 + pullId, 217 + ) 218 + } 219 + 220 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 221 + pull, err := db.GetPull(n.db, 222 + syntax.ATURI(comment.RepoAt), 223 + comment.PullId, 224 + ) 225 + if err != nil { 226 + log.Printf("NewPullComment: failed to get pulls: %v", err) 227 + return 228 + } 229 + 230 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 231 + if err != nil { 232 + log.Printf("NewPullComment: failed to get repos: %v", err) 233 + return 234 + } 235 + 236 + // build up the recipients list: 237 + // - repo owner 238 + // - all pull participants 239 + var recipients []syntax.DID 240 + recipients = append(recipients, syntax.DID(repo.Did)) 241 + for _, p := range pull.Participants() { 242 + recipients = append(recipients, syntax.DID(p)) 243 + } 244 + 245 + actorDid := syntax.DID(comment.OwnerDid) 246 + eventType := models.NotificationTypePullCommented 247 + entityType := "pull" 248 + entityId := pull.PullAt().String() 249 + repoId := &repo.Id 250 + var issueId *int64 251 + p := int64(pull.ID) 252 + pullId := &p 253 + 254 + n.notifyEvent( 255 + actorDid, 256 + recipients, 257 + eventType, 258 + entityType, 259 + entityId, 260 + repoId, 261 + issueId, 262 + pullId, 263 + ) 264 + } 265 + 266 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 267 + // no-op 268 + } 269 + 270 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 271 + // no-op 272 + } 273 + 274 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 275 + // no-op 276 + } 277 + 278 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 279 + // no-op 280 + } 281 + 282 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 283 + // build up the recipients list: 284 + // - repo owner 285 + // - repo collaborators 286 + // - all issue participants 287 + var recipients []syntax.DID 288 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 289 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 290 + if err != nil { 291 + log.Printf("failed to fetch collaborators: %v", err) 292 + return 293 + } 294 + for _, c := range collaborators { 295 + recipients = append(recipients, c.SubjectDid) 296 + } 297 + for _, p := range issue.Participants() { 298 + recipients = append(recipients, syntax.DID(p)) 299 + } 300 + 301 + actorDid := syntax.DID(issue.Repo.Did) 302 + eventType := models.NotificationTypeIssueClosed 303 + entityType := "pull" 304 + entityId := issue.AtUri().String() 305 + repoId := &issue.Repo.Id 306 + issueId := &issue.Id 307 + var pullId *int64 308 + 309 + n.notifyEvent( 310 + actorDid, 311 + recipients, 312 + eventType, 313 + entityType, 314 + entityId, 315 + repoId, 316 + issueId, 317 + pullId, 318 + ) 319 + } 320 + 321 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 322 + // Get repo details 323 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 324 + if err != nil { 325 + log.Printf("NewPullMerged: failed to get repos: %v", err) 326 + return 327 + } 328 + 329 + // build up the recipients list: 330 + // - repo owner 331 + // - all pull participants 332 + var recipients []syntax.DID 333 + recipients = append(recipients, syntax.DID(repo.Did)) 334 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 335 + if err != nil { 336 + log.Printf("failed to fetch collaborators: %v", err) 337 + return 338 + } 339 + for _, c := range collaborators { 340 + recipients = append(recipients, c.SubjectDid) 341 + } 342 + for _, p := range pull.Participants() { 343 + recipients = append(recipients, syntax.DID(p)) 344 + } 345 + 346 + actorDid := syntax.DID(repo.Did) 347 + eventType := models.NotificationTypePullMerged 348 + entityType := "pull" 349 + entityId := pull.PullAt().String() 350 + repoId := &repo.Id 351 + var issueId *int64 352 + p := int64(pull.ID) 353 + pullId := &p 354 + 355 + n.notifyEvent( 356 + actorDid, 357 + recipients, 358 + eventType, 359 + entityType, 360 + entityId, 361 + repoId, 362 + issueId, 363 + pullId, 364 + ) 365 + } 366 + 367 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 368 + // Get repo details 369 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 370 + if err != nil { 371 + log.Printf("NewPullMerged: failed to get repos: %v", err) 372 + return 373 + } 374 + 375 + // build up the recipients list: 376 + // - repo owner 377 + // - all pull participants 378 + var recipients []syntax.DID 379 + recipients = append(recipients, syntax.DID(repo.Did)) 380 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 381 + if err != nil { 382 + log.Printf("failed to fetch collaborators: %v", err) 383 + return 384 + } 385 + for _, c := range collaborators { 386 + recipients = append(recipients, c.SubjectDid) 387 + } 388 + for _, p := range pull.Participants() { 389 + recipients = append(recipients, syntax.DID(p)) 390 + } 391 + 392 + actorDid := syntax.DID(repo.Did) 393 + eventType := models.NotificationTypePullClosed 394 + entityType := "pull" 395 + entityId := pull.PullAt().String() 396 + repoId := &repo.Id 397 + var issueId *int64 398 + p := int64(pull.ID) 399 + pullId := &p 400 + 401 + n.notifyEvent( 402 + actorDid, 403 + recipients, 404 + eventType, 405 + entityType, 406 + entityId, 407 + repoId, 408 + issueId, 409 + pullId, 410 + ) 411 + } 412 + 413 + func (n *databaseNotifier) notifyEvent( 414 + actorDid syntax.DID, 415 + recipients []syntax.DID, 416 + eventType models.NotificationType, 417 + entityType string, 418 + entityId string, 419 + repoId *int64, 420 + issueId *int64, 421 + pullId *int64, 422 + ) { 423 + recipientSet := make(map[syntax.DID]struct{}) 424 + for _, did := range recipients { 425 + // everybody except actor themselves 426 + if did != actorDid { 427 + recipientSet[did] = struct{}{} 428 + } 429 + } 430 + 431 + prefMap, err := db.GetNotificationPreferences( 432 + n.db, 433 + db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 434 + ) 435 + if err != nil { 436 + // failed to get prefs for users 437 + return 438 + } 439 + 440 + // create a transaction for bulk notification storage 441 + tx, err := n.db.Begin() 442 + if err != nil { 443 + // failed to start tx 444 + return 445 + } 446 + defer tx.Rollback() 447 + 448 + // filter based on preferences 449 + for recipientDid := range recipientSet { 450 + prefs, ok := prefMap[recipientDid] 451 + if !ok { 452 + prefs = models.DefaultNotificationPreferences(recipientDid) 453 + } 454 + 455 + // skip users who don’t want this type 456 + if !prefs.ShouldNotify(eventType) { 457 + continue 458 + } 459 + 460 + // create notification 461 + notif := &models.Notification{ 462 + RecipientDid: recipientDid.String(), 463 + ActorDid: actorDid.String(), 464 + Type: eventType, 465 + EntityType: entityType, 466 + EntityId: entityId, 467 + RepoId: repoId, 468 + IssueId: issueId, 469 + PullId: pullId, 470 + } 471 + 472 + if err := db.CreateNotification(tx, notif); err != nil { 473 + log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 474 + } 475 + } 476 + 477 + if err := tx.Commit(); err != nil { 478 + // failed to commit 479 + return 480 + } 481 + }
+53 -38
appview/notify/merged_notifier.go
··· 2 3 import ( 4 "context" 5 6 "tangled.org/core/appview/models" 7 ) ··· 16 17 var _ Notifier = &mergedNotifier{} 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 22 } 23 } 24 25 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 29 } 30 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 34 } 35 36 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 40 } 41 42 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 - for _, notifier := range m.notifiers { 44 - notifier.NewFollow(ctx, follow) 45 - } 46 } 47 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 48 - for _, notifier := range m.notifiers { 49 - notifier.DeleteFollow(ctx, follow) 50 - } 51 } 52 53 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewPull(ctx, pull) 56 - } 57 } 58 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 - for _, notifier := range m.notifiers { 60 - notifier.NewPullComment(ctx, comment) 61 - } 62 } 63 64 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 65 - for _, notifier := range m.notifiers { 66 - notifier.UpdateProfile(ctx, profile) 67 - } 68 } 69 70 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 71 - for _, notifier := range m.notifiers { 72 - notifier.NewString(ctx, string) 73 - } 74 } 75 76 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 77 - for _, notifier := range m.notifiers { 78 - notifier.EditString(ctx, string) 79 - } 80 } 81 82 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 - for _, notifier := range m.notifiers { 84 - notifier.DeleteString(ctx, did, rkey) 85 - } 86 }
··· 2 3 import ( 4 "context" 5 + "reflect" 6 + "sync" 7 8 "tangled.org/core/appview/models" 9 ) ··· 18 19 var _ Notifier = &mergedNotifier{} 20 21 + // fanout calls the same method on all notifiers concurrently 22 + func (m *mergedNotifier) fanout(method string, args ...any) { 23 + var wg sync.WaitGroup 24 + for _, n := range m.notifiers { 25 + wg.Add(1) 26 + go func(notifier Notifier) { 27 + defer wg.Done() 28 + v := reflect.ValueOf(notifier).MethodByName(method) 29 + in := make([]reflect.Value, len(args)) 30 + for i, arg := range args { 31 + in[i] = reflect.ValueOf(arg) 32 + } 33 + v.Call(in) 34 + }(n) 35 } 36 + wg.Wait() 37 + } 38 + 39 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 40 + m.fanout("NewRepo", ctx, repo) 41 } 42 43 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 44 + m.fanout("NewStar", ctx, star) 45 } 46 + 47 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 48 + m.fanout("DeleteStar", ctx, star) 49 } 50 51 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 52 + m.fanout("NewIssue", ctx, issue) 53 + } 54 + 55 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 56 + m.fanout("NewIssueComment", ctx, comment) 57 + } 58 + 59 + func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 60 + m.fanout("NewIssueClosed", ctx, issue) 61 } 62 63 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 64 + m.fanout("NewFollow", ctx, follow) 65 } 66 + 67 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 68 + m.fanout("DeleteFollow", ctx, follow) 69 } 70 71 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 72 + m.fanout("NewPull", ctx, pull) 73 } 74 + 75 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 76 + m.fanout("NewPullComment", ctx, comment) 77 + } 78 + 79 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 80 + m.fanout("NewPullMerged", ctx, pull) 81 + } 82 + 83 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 84 + m.fanout("NewPullClosed", ctx, pull) 85 } 86 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 + m.fanout("UpdateProfile", ctx, profile) 89 } 90 91 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 92 + m.fanout("NewString", ctx, s) 93 } 94 95 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 96 + m.fanout("EditString", ctx, s) 97 } 98 99 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 100 + m.fanout("DeleteString", ctx, did, rkey) 101 }
+9 -1
appview/notify/notifier.go
··· 13 DeleteStar(ctx context.Context, star *models.Star) 14 15 NewIssue(ctx context.Context, issue *models.Issue) 16 17 NewFollow(ctx context.Context, follow *models.Follow) 18 DeleteFollow(ctx context.Context, follow *models.Follow) 19 20 NewPull(ctx context.Context, pull *models.Pull) 21 NewPullComment(ctx context.Context, comment *models.PullComment) 22 23 UpdateProfile(ctx context.Context, profile *models.Profile) 24 ··· 37 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 38 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 41 42 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 43 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 45 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 46 func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 47 48 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49
··· 13 DeleteStar(ctx context.Context, star *models.Star) 14 15 NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 18 19 NewFollow(ctx context.Context, follow *models.Follow) 20 DeleteFollow(ctx context.Context, follow *models.Follow) 21 22 NewPull(ctx context.Context, pull *models.Pull) 23 NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 26 27 UpdateProfile(ctx context.Context, profile *models.Profile) 28 ··· 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 47 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 51 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 + func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 55 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57
+219
appview/notify/posthog/notifier.go
···
··· 1 + package posthog 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) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 + err := n.client.Enqueue(posthog.Capture{ 117 + DistinctId: follow.UserDid, 118 + Event: "follow", 119 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 120 + }) 121 + if err != nil { 122 + log.Println("failed to enqueue posthog event:", err) 123 + } 124 + } 125 + 126 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 127 + err := n.client.Enqueue(posthog.Capture{ 128 + DistinctId: follow.UserDid, 129 + Event: "unfollow", 130 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 131 + }) 132 + if err != nil { 133 + log.Println("failed to enqueue posthog event:", err) 134 + } 135 + } 136 + 137 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 138 + err := n.client.Enqueue(posthog.Capture{ 139 + DistinctId: profile.Did, 140 + Event: "edit_profile", 141 + }) 142 + if err != nil { 143 + log.Println("failed to enqueue posthog event:", err) 144 + } 145 + } 146 + 147 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 148 + err := n.client.Enqueue(posthog.Capture{ 149 + DistinctId: did, 150 + Event: "delete_string", 151 + Properties: posthog.Properties{"rkey": rkey}, 152 + }) 153 + if err != nil { 154 + log.Println("failed to enqueue posthog event:", err) 155 + } 156 + } 157 + 158 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 159 + err := n.client.Enqueue(posthog.Capture{ 160 + DistinctId: string.Did.String(), 161 + Event: "edit_string", 162 + Properties: posthog.Properties{"rkey": string.Rkey}, 163 + }) 164 + if err != nil { 165 + log.Println("failed to enqueue posthog event:", err) 166 + } 167 + } 168 + 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 + err := n.client.Enqueue(posthog.Capture{ 171 + DistinctId: string.Did.String(), 172 + Event: "new_string", 173 + Properties: posthog.Properties{"rkey": string.Rkey}, 174 + }) 175 + if err != nil { 176 + log.Println("failed to enqueue posthog event:", err) 177 + } 178 + } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
-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 package oauth 2 3 const ( 4 - SessionName = "appview-session" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionPds = "pds" 8 SessionAccessJwt = "accessJwt" 9 SessionRefreshJwt = "refreshJwt"
··· 1 package oauth 2 3 const ( 4 + SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 + SessionId = "id" 8 SessionPds = "pds" 9 SessionAccessJwt = "accessJwt" 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 - }
···
+275
appview/oauth/handler.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "slices" 10 + "time" 11 + 12 + "github.com/go-chi/chi/v5" 13 + "github.com/lestrrat-go/jwx/v2/jwk" 14 + "github.com/posthog/posthog-go" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/consts" 18 + "tangled.org/core/tid" 19 + ) 20 + 21 + func (o *OAuth) Router() http.Handler { 22 + r := chi.NewRouter() 23 + 24 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 25 + r.Get("/oauth/jwks.json", o.jwks) 26 + r.Get("/oauth/callback", o.callback) 27 + return r 28 + } 29 + 30 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 31 + doc := o.ClientApp.Config.ClientMetadata() 32 + doc.JWKSURI = &o.JwksUri 33 + 34 + w.Header().Set("Content-Type", "application/json") 35 + if err := json.NewEncoder(w).Encode(doc); err != nil { 36 + http.Error(w, err.Error(), http.StatusInternalServerError) 37 + return 38 + } 39 + } 40 + 41 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 42 + jwks := o.Config.OAuth.Jwks 43 + pubKey, err := pubKeyFromJwk(jwks) 44 + if err != nil { 45 + o.Logger.Error("error parsing public key", "err", err) 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + response := map[string]any{ 51 + "keys": []jwk.Key{pubKey}, 52 + } 53 + 54 + w.Header().Set("Content-Type", "application/json") 55 + w.WriteHeader(http.StatusOK) 56 + json.NewEncoder(w).Encode(response) 57 + } 58 + 59 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 60 + ctx := r.Context() 61 + 62 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 63 + if err != nil { 64 + http.Error(w, err.Error(), http.StatusInternalServerError) 65 + return 66 + } 67 + 68 + if err := o.SaveSession(w, r, sessData); err != nil { 69 + http.Error(w, err.Error(), http.StatusInternalServerError) 70 + return 71 + } 72 + 73 + o.Logger.Debug("session saved successfully") 74 + go o.addToDefaultKnot(sessData.AccountDID.String()) 75 + go o.addToDefaultSpindle(sessData.AccountDID.String()) 76 + 77 + if !o.Config.Core.Dev { 78 + err = o.Posthog.Enqueue(posthog.Capture{ 79 + DistinctId: sessData.AccountDID.String(), 80 + Event: "signin", 81 + }) 82 + if err != nil { 83 + o.Logger.Error("failed to enqueue posthog event", "err", err) 84 + } 85 + } 86 + 87 + http.Redirect(w, r, "/", http.StatusFound) 88 + } 89 + 90 + func (o *OAuth) addToDefaultSpindle(did string) { 91 + l := o.Logger.With("subject", did) 92 + 93 + // use the tangled.sh app password to get an accessJwt 94 + // and create an sh.tangled.spindle.member record with that 95 + spindleMembers, err := db.GetSpindleMembers( 96 + o.Db, 97 + db.FilterEq("instance", "spindle.tangled.sh"), 98 + db.FilterEq("subject", did), 99 + ) 100 + if err != nil { 101 + l.Error("failed to get spindle members", "err", err) 102 + return 103 + } 104 + 105 + if len(spindleMembers) != 0 { 106 + l.Warn("already a member of the default spindle") 107 + return 108 + } 109 + 110 + l.Debug("adding to default spindle") 111 + session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 112 + if err != nil { 113 + l.Error("failed to create session", "err", err) 114 + return 115 + } 116 + 117 + record := tangled.SpindleMember{ 118 + LexiconTypeID: "sh.tangled.spindle.member", 119 + Subject: did, 120 + Instance: consts.DefaultSpindle, 121 + CreatedAt: time.Now().Format(time.RFC3339), 122 + } 123 + 124 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 125 + l.Error("failed to add to default spindle", "err", err) 126 + return 127 + } 128 + 129 + l.Debug("successfully added to default spindle", "did", did) 130 + } 131 + 132 + func (o *OAuth) addToDefaultKnot(did string) { 133 + l := o.Logger.With("subject", did) 134 + 135 + // use the tangled.sh app password to get an accessJwt 136 + // and create an sh.tangled.spindle.member record with that 137 + 138 + allKnots, err := o.Enforcer.GetKnotsForUser(did) 139 + if err != nil { 140 + l.Error("failed to get knot members for did", "err", err) 141 + return 142 + } 143 + 144 + if slices.Contains(allKnots, consts.DefaultKnot) { 145 + l.Warn("already a member of the default knot") 146 + return 147 + } 148 + 149 + l.Debug("addings to default knot") 150 + session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 151 + if err != nil { 152 + l.Error("failed to create session", "err", err) 153 + return 154 + } 155 + 156 + record := tangled.KnotMember{ 157 + LexiconTypeID: "sh.tangled.knot.member", 158 + Subject: did, 159 + Domain: consts.DefaultKnot, 160 + CreatedAt: time.Now().Format(time.RFC3339), 161 + } 162 + 163 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 164 + l.Error("failed to add to default knot", "err", err) 165 + return 166 + } 167 + 168 + if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 169 + l.Error("failed to set up enforcer rules", "err", err) 170 + return 171 + } 172 + 173 + l.Debug("successfully addeds to default Knot") 174 + } 175 + 176 + // create a session using apppasswords 177 + type session struct { 178 + AccessJwt string `json:"accessJwt"` 179 + PdsEndpoint string 180 + Did string 181 + } 182 + 183 + func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 184 + if appPassword == "" { 185 + return nil, fmt.Errorf("no app password configured, skipping member addition") 186 + } 187 + 188 + resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 189 + if err != nil { 190 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 191 + } 192 + 193 + pdsEndpoint := resolved.PDSEndpoint() 194 + if pdsEndpoint == "" { 195 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 196 + } 197 + 198 + sessionPayload := map[string]string{ 199 + "identifier": did, 200 + "password": appPassword, 201 + } 202 + sessionBytes, err := json.Marshal(sessionPayload) 203 + if err != nil { 204 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 205 + } 206 + 207 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 208 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 209 + if err != nil { 210 + return nil, fmt.Errorf("failed to create session request: %v", err) 211 + } 212 + sessionReq.Header.Set("Content-Type", "application/json") 213 + 214 + client := &http.Client{Timeout: 30 * time.Second} 215 + sessionResp, err := client.Do(sessionReq) 216 + if err != nil { 217 + return nil, fmt.Errorf("failed to create session: %v", err) 218 + } 219 + defer sessionResp.Body.Close() 220 + 221 + if sessionResp.StatusCode != http.StatusOK { 222 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 223 + } 224 + 225 + var session session 226 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 227 + return nil, fmt.Errorf("failed to decode session response: %v", err) 228 + } 229 + 230 + session.PdsEndpoint = pdsEndpoint 231 + session.Did = did 232 + 233 + return &session, nil 234 + } 235 + 236 + func (s *session) putRecord(record any, collection string) error { 237 + recordBytes, err := json.Marshal(record) 238 + if err != nil { 239 + return fmt.Errorf("failed to marshal knot member record: %w", err) 240 + } 241 + 242 + payload := map[string]any{ 243 + "repo": s.Did, 244 + "collection": collection, 245 + "rkey": tid.TID(), 246 + "record": json.RawMessage(recordBytes), 247 + } 248 + 249 + payloadBytes, err := json.Marshal(payload) 250 + if err != nil { 251 + return fmt.Errorf("failed to marshal request payload: %w", err) 252 + } 253 + 254 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 255 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 256 + if err != nil { 257 + return fmt.Errorf("failed to create HTTP request: %w", err) 258 + } 259 + 260 + req.Header.Set("Content-Type", "application/json") 261 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 262 + 263 + client := &http.Client{Timeout: 30 * time.Second} 264 + resp, err := client.Do(req) 265 + if err != nil { 266 + return fmt.Errorf("failed to add user to default service: %w", err) 267 + } 268 + defer resp.Body.Close() 269 + 270 + if resp.StatusCode != http.StatusOK { 271 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 272 + } 273 + 274 + return nil 275 + }
+121 -201
appview/oauth/oauth.go
··· 1 package oauth 2 3 import ( 4 "fmt" 5 - "log" 6 "net/http" 7 - "net/url" 8 "time" 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 13 "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" 18 ) 19 20 type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 } 25 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, 31 } 32 - } 33 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) 41 if err != nil { 42 return err 43 } 44 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) 50 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 52 } 53 - 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), 65 } 66 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 74 } 75 76 - did := userSession.Values[SessionDid].(string) 77 78 - err = o.sess.DeleteSession(r.Context(), did) 79 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 81 } 82 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 86 } 87 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) 92 - } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 100 } 101 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 103 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 - self := o.ClientMetadata() 113 - 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 119 120 - if err != nil { 121 - return nil, false, err 122 - } 123 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 143 } 144 145 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 149 } 150 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 153 154 - if err != nil || clientSession.IsNew { 155 return nil 156 } 157 158 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 162 } 163 } 164 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 "" 170 } 171 172 - return clientSession.Values[SessionDid].(string) 173 } 174 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 177 if err != nil { 178 return nil, fmt.Errorf("error getting session: %w", err) 179 } 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 208 } 209 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 // this is a higher level abstraction on ServerGetServiceAuth 213 type ServiceClientOpts struct { 214 service string ··· 259 return scheme + s.service 260 } 261 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 opts := ServiceClientOpts{} 264 for _, o := range os { 265 o(&opts) 266 } 267 268 - authorizedClient, err := o.AuthorizedClient(r) 269 if err != nil { 270 return nil, err 271 } ··· 276 opts.exp = sixty 277 } 278 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 if err != nil { 281 return nil, err 282 } 283 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), ··· 291 }, 292 }, nil 293 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
··· 1 package oauth 2 3 import ( 4 + "errors" 5 "fmt" 6 + "log/slog" 7 "net/http" 8 "time" 9 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + xrpc "github.com/bluesky-social/indigo/xrpc" 15 "github.com/gorilla/sessions" 16 + "github.com/lestrrat-go/jwx/v2/jwk" 17 + "github.com/posthog/posthog-go" 18 "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 22 ) 23 24 type OAuth struct { 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + Posthog posthog.Client 30 + Db *db.DB 31 + Enforcer *rbac.Enforcer 32 + IdResolver *idresolver.Resolver 33 + Logger *slog.Logger 34 } 35 36 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 37 + 38 + var oauthConfig oauth.ClientConfig 39 + var clientUri string 40 + 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"}) 50 } 51 + 52 + jwksUri := clientUri + "/oauth/jwks.json" 53 54 + authStore, err := NewRedisStore(config.Redis.ToURL()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 60 + 61 + return &OAuth{ 62 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 63 + Config: config, 64 + SessStore: sessStore, 65 + JwksUri: jwksUri, 66 + Posthog: ph, 67 + Db: db, 68 + Enforcer: enforcer, 69 + IdResolver: res, 70 + Logger: logger, 71 + }, nil 72 } 73 74 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 75 // first we save the did in the user session 76 + userSession, err := o.SessStore.Get(r, SessionName) 77 if err != nil { 78 return err 79 } 80 81 + userSession.Values[SessionDid] = sessData.AccountDID.String() 82 + userSession.Values[SessionPds] = sessData.HostURL 83 + userSession.Values[SessionId] = sessData.SessionID 84 userSession.Values[SessionAuthenticated] = true 85 + return userSession.Save(r, w) 86 + } 87 + 88 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 89 + userSession, err := o.SessStore.Get(r, SessionName) 90 if err != nil { 91 + return nil, fmt.Errorf("error getting user session: %w", err) 92 } 93 + if userSession.IsNew { 94 + return nil, fmt.Errorf("no session available for user") 95 } 96 97 + d := userSession.Values[SessionDid].(string) 98 + sessDid, err := syntax.ParseDID(d) 99 + if err != nil { 100 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 101 } 102 103 + sessId := userSession.Values[SessionId].(string) 104 105 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 106 if err != nil { 107 + return nil, fmt.Errorf("failed to resume session: %w", err) 108 } 109 110 + return clientSess, nil 111 } 112 113 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 114 + userSession, err := o.SessStore.Get(r, SessionName) 115 if err != nil { 116 + return fmt.Errorf("error getting user session: %w", err) 117 + } 118 + if userSession.IsNew { 119 + return fmt.Errorf("no session available for user") 120 } 121 122 + d := userSession.Values[SessionDid].(string) 123 + sessDid, err := syntax.ParseDID(d) 124 if err != nil { 125 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 126 } 127 128 + sessId := userSession.Values[SessionId].(string) 129 130 + // delete the session 131 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 132 133 + // remove the cookie 134 + userSession.Options.MaxAge = -1 135 + err2 := o.SessStore.Save(r, w, userSession) 136 137 + return errors.Join(err1, err2) 138 + } 139 140 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 141 + k, err := jwk.ParseKey([]byte(jwks)) 142 + if err != nil { 143 + return nil, err 144 } 145 + pubKey, err := k.PublicKey() 146 + if err != nil { 147 + return nil, err 148 + } 149 + return pubKey, nil 150 } 151 152 type User struct { 153 + Did string 154 + Pds string 155 } 156 157 + func (o *OAuth) GetUser(r *http.Request) *User { 158 + sess, err := o.SessStore.Get(r, SessionName) 159 160 + if err != nil || sess.IsNew { 161 return nil 162 } 163 164 return &User{ 165 + Did: sess.Values[SessionDid].(string), 166 + Pds: sess.Values[SessionPds].(string), 167 } 168 } 169 170 + func (o *OAuth) GetDid(r *http.Request) string { 171 + if u := o.GetUser(r); u != nil { 172 + return u.Did 173 } 174 175 + return "" 176 } 177 178 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 179 + session, err := o.ResumeSession(r) 180 if err != nil { 181 return nil, fmt.Errorf("error getting session: %w", err) 182 } 183 + return session.APIClient(), nil 184 } 185 186 // this is a higher level abstraction on ServerGetServiceAuth 187 type ServiceClientOpts struct { 188 service string ··· 233 return scheme + s.service 234 } 235 236 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 237 opts := ServiceClientOpts{} 238 for _, o := range os { 239 o(&opts) 240 } 241 242 + client, err := o.AuthorizedClient(r) 243 if err != nil { 244 return nil, err 245 } ··· 250 opts.exp = sixty 251 } 252 253 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 254 if err != nil { 255 return nil, err 256 } 257 258 + return &xrpc.Client{ 259 + Auth: &xrpc.AuthInfo{ 260 AccessJwt: resp.Token, 261 }, 262 Host: opts.Host(), ··· 265 }, 266 }, nil 267 }
+147
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 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+535
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 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 630 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + 398 + body := resp.Body 399 + bodyBytes, err := io.ReadAll(body) 400 + if err != nil { 401 + log.Printf("error when fetching external image from %s: %v", url, err) 402 + return nil, false 403 + } 404 + 405 + // Handle SVG separately 406 + if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 407 + return c.convertSVGToPNG(bodyBytes) 408 + } 409 + 410 + // Support content types are in-sync with the allowed custom avatar file types 411 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 412 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 413 + return nil, false 414 + } 415 + 416 + bodyBuffer := bytes.NewReader(bodyBytes) 417 + _, imgType, err := image.DecodeConfig(bodyBuffer) 418 + if err != nil { 419 + log.Printf("error when decoding external image from %s: %v", url, err) 420 + return nil, false 421 + } 422 + 423 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 424 + if (contentType == "image/png" && imgType != "png") || 425 + (contentType == "image/jpeg" && imgType != "jpeg") || 426 + (contentType == "image/gif" && imgType != "gif") || 427 + (contentType == "image/webp" && imgType != "webp") { 428 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 429 + return nil, false 430 + } 431 + 432 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 433 + if err != nil { 434 + log.Printf("error w/ bodyBuffer.Seek") 435 + return nil, false 436 + } 437 + img, _, err := image.Decode(bodyBuffer) 438 + if err != nil { 439 + log.Printf("error when decoding external image from %s: %v", url, err) 440 + return nil, false 441 + } 442 + 443 + return img, true 444 + } 445 + 446 + // convertSVGToPNG converts SVG data to a PNG image 447 + func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 448 + // Parse the SVG 449 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 450 + if err != nil { 451 + log.Printf("error parsing SVG: %v", err) 452 + return nil, false 453 + } 454 + 455 + // Set a reasonable size for the rasterized image 456 + width := 256 457 + height := 256 458 + icon.SetTarget(0, 0, float64(width), float64(height)) 459 + 460 + // Create an image to draw on 461 + rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 462 + 463 + // Fill with white background 464 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 465 + 466 + // Create a scanner and rasterize the SVG 467 + scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 468 + raster := rasterx.NewDasher(width, height, scanner) 469 + 470 + icon.Draw(raster, 1.0) 471 + 472 + return rgba, true 473 + } 474 + 475 + func (c *Card) DrawExternalImage(url string) { 476 + image, ok := c.fetchExternalImage(url) 477 + if !ok { 478 + image = fallbackImage() 479 + } 480 + c.DrawImage(image) 481 + } 482 + 483 + // DrawCircularExternalImage draws an external image as a circle at the specified position 484 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 485 + img, ok := c.fetchExternalImage(url) 486 + if !ok { 487 + img = fallbackImage() 488 + } 489 + 490 + // Create a circular mask 491 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 492 + center := size / 2 493 + radius := float64(size / 2) 494 + 495 + // Scale the source image to fit the circle 496 + srcBounds := img.Bounds() 497 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 498 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 499 + 500 + // Draw the image with circular clipping 501 + for cy := 0; cy < size; cy++ { 502 + for cx := 0; cx < size; cx++ { 503 + // Calculate distance from center 504 + dx := float64(cx - center) 505 + dy := float64(cy - center) 506 + distance := math.Sqrt(dx*dx + dy*dy) 507 + 508 + // Only draw pixels within the circle 509 + if distance <= radius { 510 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 511 + } 512 + } 513 + } 514 + 515 + // Draw the circle onto the card 516 + bounds := c.Img.Bounds() 517 + destRect := image.Rect(x, y, x+size, y+size) 518 + 519 + // Make sure we don't draw outside the card bounds 520 + if destRect.Max.X > bounds.Max.X { 521 + destRect.Max.X = bounds.Max.X 522 + } 523 + if destRect.Max.Y > bounds.Max.Y { 524 + destRect.Max.Y = bounds.Max.Y 525 + } 526 + 527 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 528 + 529 + return nil 530 + } 531 + 532 + // DrawRect draws a rect with the given color 533 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 534 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 535 + }
+10 -9
appview/pages/funcmap.go
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 - "cssContentHash": CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 297 }, 298 299 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 302 }, 303 "sshFingerprint": func(pubKey string) string { 304 fp, err := crypto.SSHFingerprint(pubKey) ··· 310 } 311 } 312 313 - func (p *Pages) avatarUri(handle, size string) string { 314 handle = strings.TrimPrefix(handle, "@") 315 316 secret := p.avatar.SharedSecret ··· 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 } 327 328 - func icon(name string, classes []string) (template.HTML, error) { 329 iconPath := filepath.Join("static", "icons", name) 330 331 if filepath.Ext(name) == "" {
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 + data, err := p.icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 + data, _ = p.icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 + "cssContentHash": p.CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 + return p.AvatarUrl(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 + return p.AvatarUrl(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 297 }, 298 299 "normalizeForHtmlId": func(s string) string { 300 + normalized := strings.ReplaceAll(s, ":", "_") 301 + normalized = strings.ReplaceAll(normalized, ".", "_") 302 + return normalized 303 }, 304 "sshFingerprint": func(pubKey string) string { 305 fp, err := crypto.SSHFingerprint(pubKey) ··· 311 } 312 } 313 314 + func (p *Pages) AvatarUrl(handle, size string) string { 315 handle = strings.TrimPrefix(handle, "@") 316 317 secret := p.avatar.SharedSecret ··· 326 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 327 } 328 329 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 330 iconPath := filepath.Join("static", "icons", name) 331 332 if filepath.Ext(name) == "" {
+5 -2
appview/pages/funcmap_test.go
··· 2 3 import ( 4 "html/template" 5 "tangled.org/core/appview/config" 6 "tangled.org/core/idresolver" 7 - "testing" 8 ) 9 10 func TestPages_funcMap(t *testing.T) { ··· 13 // Named input parameters for receiver constructor. 14 config *config.Config 15 res *idresolver.Resolver 16 want template.FuncMap 17 }{ 18 // TODO: Add test cases. 19 } 20 for _, tt := range tests { 21 t.Run(tt.name, func(t *testing.T) { 22 - p := NewPages(tt.config, tt.res) 23 got := p.funcMap() 24 // TODO: update the condition below to compare got with tt.want. 25 if true {
··· 2 3 import ( 4 "html/template" 5 + "log/slog" 6 + "testing" 7 + 8 "tangled.org/core/appview/config" 9 "tangled.org/core/idresolver" 10 ) 11 12 func TestPages_funcMap(t *testing.T) { ··· 15 // Named input parameters for receiver constructor. 16 config *config.Config 17 res *idresolver.Resolver 18 + l *slog.Logger 19 want template.FuncMap 20 }{ 21 // TODO: Add test cases. 22 } 23 for _, tt := range tests { 24 t.Run(tt.name, func(t *testing.T) { 25 + p := NewPages(tt.config, tt.res, tt.l) 26 got := p.funcMap() 27 // TODO: update the condition below to compare got with tt.want. 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.
+15 -17
appview/pages/markup/format.go
··· 1 package markup 2 3 - import "strings" 4 5 type Format string 6 ··· 10 ) 11 12 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 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", 26 } 27 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 - } 34 } 35 } 36 // default format
··· 1 package markup 2 3 + import ( 4 + "regexp" 5 + ) 6 7 type Format string 8 ··· 12 ) 13 14 var FileTypes map[Format][]string = map[Format][]string{ 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 16 } 17 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 } 27 28 func GetFormat(filename string) Format { 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 32 } 33 } 34 // default format
+6 -1
appview/pages/markup/markdown.go
··· 5 "bytes" 6 "fmt" 7 "io" 8 "net/url" 9 "path" 10 "strings" ··· 20 "github.com/yuin/goldmark/renderer/html" 21 "github.com/yuin/goldmark/text" 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 "tangled.org/core/api/tangled" ··· 45 IsDev bool 46 RendererType RendererType 47 Sanitizer Sanitizer 48 } 49 50 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 treeblood.MathML(), 65 ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), ··· 140 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 switch node.Type { 142 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 144 for i, attr := range node.Attr { 145 if attr.Key != "src" { 146 continue
··· 5 "bytes" 6 "fmt" 7 "io" 8 + "io/fs" 9 "net/url" 10 "path" 11 "strings" ··· 21 "github.com/yuin/goldmark/renderer/html" 22 "github.com/yuin/goldmark/text" 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" ··· 47 IsDev bool 48 RendererType RendererType 49 Sanitizer Sanitizer 50 + Files fs.FS 51 } 52 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 66 ), 67 treeblood.MathML(), 68 + callout.CalloutExtention, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(), ··· 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 145 switch node.Type { 146 case htmlparse.ElementNode: 147 + switch node.Data { 148 + case "img", "source": 149 for i, attr := range node.Attr { 150 if attr.Key != "src" { 151 continue
+3
appview/pages/markup/sanitizer.go
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 return policy 118 } 119
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 120 return policy 121 } 122
+126 -39
appview/pages/pages.go
··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 - //go:embed templates/* static 42 var Files embed.FS 43 44 type Pages struct { ··· 54 logger *slog.Logger 55 } 56 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 } 65 66 p := &Pages{ ··· 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 - logger: slog.Default().With("component", "pages"), 75 } 76 77 if p.dev { ··· 226 return p.executePlain("user/login", w, params) 227 } 228 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 231 } 232 233 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 242 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 filename := "terms.md" 244 filePath := filepath.Join("legal", filename) 245 - markdownBytes, err := os.ReadFile(filePath) 246 if err != nil { 247 return fmt.Errorf("failed to read %s: %w", filename, err) 248 } ··· 263 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 filename := "privacy.md" 265 filePath := filepath.Join("legal", filename) 266 - markdownBytes, err := os.ReadFile(filePath) 267 if err != nil { 268 return fmt.Errorf("failed to read %s: %w", filename, err) 269 } ··· 276 return p.execute("legal/privacy", w, params) 277 } 278 279 type TimelineParams struct { 280 LoggedInUser *oauth.User 281 Timeline []models.TimelineEvent 282 Repos []models.Repo 283 } 284 285 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 return p.execute("timeline/timeline", w, params) 287 } 288 289 type UserProfileSettingsParams struct { 290 LoggedInUser *oauth.User 291 Tabs []map[string]any ··· 294 295 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 296 return p.execute("user/settings/profile", w, params) 297 } 298 299 type UserKeysSettingsParams struct { ··· 318 return p.execute("user/settings/emails", w, params) 319 } 320 321 type UpgradeBannerParams struct { 322 Registrations []models.Registration 323 Spindles []models.Spindle ··· 484 485 type FollowCard struct { 486 UserDid string 487 FollowStatus models.FollowStatus 488 FollowersCount int64 489 FollowingCount int64 ··· 654 } 655 656 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 666 types.RepoTreeResponse 667 } 668 ··· 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 params.Active = "overview" 692 693 - if params.ReadmeFileName != "" { 694 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 695 696 ext := filepath.Ext(params.ReadmeFileName) 697 switch ext { 698 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 905 LabelDefs map[string]*models.LabelDefinition 906 907 OrderedReactionKinds []models.ReactionKind 908 - Reactions map[models.ReactionKind]int 909 UserReacted map[models.ReactionKind]bool 910 } 911 ··· 930 ThreadAt syntax.ATURI 931 Kind models.ReactionKind 932 Count int 933 IsReacted bool 934 } 935 ··· 1020 FilteringBy models.PullState 1021 Stacks map[string]models.Stack 1022 Pipelines map[string]models.Pipeline 1023 } 1024 1025 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1046 } 1047 1048 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 1058 1059 OrderedReactionKinds []models.ReactionKind 1060 - Reactions map[models.ReactionKind]int 1061 UserReacted map[models.ReactionKind]bool 1062 } 1063 1064 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1148 } 1149 1150 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 1158 } 1159 1160 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1391 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1392 } 1393 1394 - sub, err := fs.Sub(Files, "static") 1395 if err != nil { 1396 p.logger.Error("no static dir found? that's crazy", "err", err) 1397 panic(err) ··· 1414 }) 1415 } 1416 1417 - func CssContentHash() string { 1418 - cssFile, err := Files.Open("static/tw.css") 1419 if err != nil { 1420 slog.Debug("Error opening CSS file", "err", err) 1421 return ""
··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 + //go:embed templates/* static legal 42 var Files embed.FS 43 44 type Pages struct { ··· 54 logger *slog.Logger 55 } 56 57 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 65 } 66 67 p := &Pages{ ··· 72 rctx: rctx, 73 resolver: res, 74 templateDir: "appview/pages", 75 + logger: logger, 76 } 77 78 if p.dev { ··· 227 return p.executePlain("user/login", w, params) 228 } 229 230 + type SignupParams struct { 231 + CloudflareSiteKey string 232 + } 233 + 234 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 235 + return p.executePlain("user/signup", w, params) 236 } 237 238 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 247 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 248 filename := "terms.md" 249 filePath := filepath.Join("legal", filename) 250 + 251 + file, err := p.embedFS.Open(filePath) 252 + if err != nil { 253 + return fmt.Errorf("failed to read %s: %w", filename, err) 254 + } 255 + defer file.Close() 256 + 257 + markdownBytes, err := io.ReadAll(file) 258 if err != nil { 259 return fmt.Errorf("failed to read %s: %w", filename, err) 260 } ··· 275 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 276 filename := "privacy.md" 277 filePath := filepath.Join("legal", filename) 278 + 279 + file, err := p.embedFS.Open(filePath) 280 + if err != nil { 281 + return fmt.Errorf("failed to read %s: %w", filename, err) 282 + } 283 + defer file.Close() 284 + 285 + markdownBytes, err := io.ReadAll(file) 286 if err != nil { 287 return fmt.Errorf("failed to read %s: %w", filename, err) 288 } ··· 295 return p.execute("legal/privacy", w, params) 296 } 297 298 + type BrandParams struct { 299 + LoggedInUser *oauth.User 300 + } 301 + 302 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 303 + return p.execute("brand/brand", w, params) 304 + } 305 + 306 type TimelineParams struct { 307 LoggedInUser *oauth.User 308 Timeline []models.TimelineEvent 309 Repos []models.Repo 310 + GfiLabel *models.LabelDefinition 311 } 312 313 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 314 return p.execute("timeline/timeline", w, params) 315 } 316 317 + type GoodFirstIssuesParams struct { 318 + LoggedInUser *oauth.User 319 + Issues []models.Issue 320 + RepoGroups []*models.RepoGroup 321 + LabelDefs map[string]*models.LabelDefinition 322 + GfiLabel *models.LabelDefinition 323 + Page pagination.Page 324 + } 325 + 326 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 327 + return p.execute("goodfirstissues/index", w, params) 328 + } 329 + 330 type UserProfileSettingsParams struct { 331 LoggedInUser *oauth.User 332 Tabs []map[string]any ··· 335 336 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 337 return p.execute("user/settings/profile", w, params) 338 + } 339 + 340 + type NotificationsParams struct { 341 + LoggedInUser *oauth.User 342 + Notifications []*models.NotificationWithEntity 343 + UnreadCount int 344 + Page pagination.Page 345 + Total int64 346 + } 347 + 348 + func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 349 + return p.execute("notifications/list", w, params) 350 + } 351 + 352 + type NotificationItemParams struct { 353 + Notification *models.Notification 354 + } 355 + 356 + func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 357 + return p.executePlain("notifications/fragments/item", w, params) 358 + } 359 + 360 + type NotificationCountParams struct { 361 + Count int64 362 + } 363 + 364 + func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 365 + return p.executePlain("notifications/fragments/count", w, params) 366 } 367 368 type UserKeysSettingsParams struct { ··· 387 return p.execute("user/settings/emails", w, params) 388 } 389 390 + type UserNotificationSettingsParams struct { 391 + LoggedInUser *oauth.User 392 + Preferences *models.NotificationPreferences 393 + Tabs []map[string]any 394 + Tab string 395 + } 396 + 397 + func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 398 + return p.execute("user/settings/notifications", w, params) 399 + } 400 + 401 type UpgradeBannerParams struct { 402 Registrations []models.Registration 403 Spindles []models.Spindle ··· 564 565 type FollowCard struct { 566 UserDid string 567 + LoggedInUser *oauth.User 568 FollowStatus models.FollowStatus 569 FollowersCount int64 570 FollowingCount int64 ··· 735 } 736 737 type RepoTreeParams struct { 738 + LoggedInUser *oauth.User 739 + RepoInfo repoinfo.RepoInfo 740 + Active string 741 + BreadCrumbs [][]string 742 + TreePath string 743 + Raw bool 744 + HTMLReadme template.HTML 745 types.RepoTreeResponse 746 } 747 ··· 769 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 770 params.Active = "overview" 771 772 + p.rctx.RepoInfo = params.RepoInfo 773 + p.rctx.RepoInfo.Ref = params.Ref 774 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 775 776 + if params.ReadmeFileName != "" { 777 ext := filepath.Ext(params.ReadmeFileName) 778 switch ext { 779 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 986 LabelDefs map[string]*models.LabelDefinition 987 988 OrderedReactionKinds []models.ReactionKind 989 + Reactions map[models.ReactionKind]models.ReactionDisplayData 990 UserReacted map[models.ReactionKind]bool 991 } 992 ··· 1011 ThreadAt syntax.ATURI 1012 Kind models.ReactionKind 1013 Count int 1014 + Users []string 1015 IsReacted bool 1016 } 1017 ··· 1102 FilteringBy models.PullState 1103 Stacks map[string]models.Stack 1104 Pipelines map[string]models.Pipeline 1105 + LabelDefs map[string]*models.LabelDefinition 1106 } 1107 1108 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1129 } 1130 1131 type RepoSinglePullParams struct { 1132 + LoggedInUser *oauth.User 1133 + RepoInfo repoinfo.RepoInfo 1134 + Active string 1135 + Pull *models.Pull 1136 + Stack models.Stack 1137 + AbandonedPulls []*models.Pull 1138 + BranchDeleteStatus *models.BranchDeleteStatus 1139 + MergeCheck types.MergeCheckResponse 1140 + ResubmitCheck ResubmitResult 1141 + Pipelines map[string]models.Pipeline 1142 1143 OrderedReactionKinds []models.ReactionKind 1144 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1145 UserReacted map[models.ReactionKind]bool 1146 + 1147 + LabelDefs map[string]*models.LabelDefinition 1148 } 1149 1150 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1234 } 1235 1236 type PullActionsParams struct { 1237 + LoggedInUser *oauth.User 1238 + RepoInfo repoinfo.RepoInfo 1239 + Pull *models.Pull 1240 + RoundNumber int 1241 + MergeCheck types.MergeCheckResponse 1242 + ResubmitCheck ResubmitResult 1243 + BranchDeleteStatus *models.BranchDeleteStatus 1244 + Stack models.Stack 1245 } 1246 1247 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1478 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1479 } 1480 1481 + sub, err := fs.Sub(p.embedFS, "static") 1482 if err != nil { 1483 p.logger.Error("no static dir found? that's crazy", "err", err) 1484 panic(err) ··· 1501 }) 1502 } 1503 1504 + func (p *Pages) CssContentHash() string { 1505 + cssFile, err := p.embedFS.Open("static/tw.css") 1506 if err != nil { 1507 slog.Debug("Error opening CSS file", "err", err) 1508 return ""
+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 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 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" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 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> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 <button onclick="location.reload()" class="btn-create gap-2"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> 35 </div>
··· 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 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 "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 16 <p class="text-gray-600 dark:text-gray-300"> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 <button onclick="location.reload()" class="btn-create gap-2"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 back to home 27 </a> 28 </div>
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
··· 1 + <svg 2 + version="1.1" 3 + id="svg1" 4 + width="32" 5 + height="32" 6 + viewBox="0 0 25 25" 7 + sodipodi:docname="tangled_dolly_silhouette.png" 8 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 + xmlns="http://www.w3.org/2000/svg" 11 + xmlns:svg="http://www.w3.org/2000/svg"> 12 + <title>Dolly</title> 13 + <defs 14 + id="defs1" /> 15 + <sodipodi:namedview 16 + id="namedview1" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="true" 23 + inkscape:deskcolor="#d1d1d1"> 24 + <inkscape:page 25 + x="0" 26 + y="0" 27 + width="25" 28 + height="25" 29 + id="page2" 30 + margin="0" 31 + bleed="0" /> 32 + </sodipodi:namedview> 33 + <g 34 + inkscape:groupmode="layer" 35 + inkscape:label="Image" 36 + id="g1"> 37 + <path 38 + class="dolly" 39 + fill="currentColor" 40 + style="stroke-width:1.12248" 41 + 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" 42 + id="path1" /> 43 + </g> 44 + </svg>
+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 }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 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"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 <!-- preload main font --> 18 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 ··· 21 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </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);"> 26 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 29 {{ if .LoggedInUser }} 30 <div id="upgrade-banner" ··· 38 {{ end }} 39 40 {{ 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 {{ block "content" . }}{{ end }} 45 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 - {{ end }} 53 </div> 54 {{ end }} 55 56 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 58 {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }}
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 20 <!-- preload main font --> 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 22 ··· 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 25 {{ block "extrameta" . }}{{ end }} 26 </head> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 {{ block "topbarLayout" . }} 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 40 {{ end }} 41 42 {{ block "mainLayout" . }} 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 47 {{ block "content" . }}{{ end }} 48 </main> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 53 {{ block "contentAfter" . }}{{ end }} 54 </main> 55 + {{ end }} 56 + </div> 57 </div> 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 + <footer class="mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 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> 10 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> 19 </div> 20 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 </div> 27 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> 33 </div> 34 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> 39 </div> 40 - </div> 41 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> 44 </div> 45 </div> 46 </div>
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 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> 46 </div> 47 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 + </div> 53 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> 64 </div> 65 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> 93 </div> 94 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 </div> 99 </div> 100 </div>
+18 -8
appview/pages/templates/layouts/fragments/topbar.html
··· 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"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 7 </a> 8 </div> 9 10 - <div id="right-items" class="flex items-center gap-2"> 11 {{ with .LoggedInUser }} 12 {{ block "newButton" . }} {{ end }} 13 {{ block "dropDown" . }} {{ end }} 14 {{ else }} 15 <a href="/login">login</a> ··· 26 {{ define "newButton" }} 27 <details class="relative inline-block text-left nav-dropdown"> 28 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 30 </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"> 32 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 {{ define "dropDown" }} 45 <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> 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"
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 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> 11 </a> 12 </div> 13 14 + <div id="right-items" class="flex items-center gap-4"> 15 {{ with .LoggedInUser }} 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 18 {{ block "dropDown" . }} {{ end }} 19 {{ else }} 20 <a href="/login">login</a> ··· 31 {{ define "newButton" }} 32 <details class="relative inline-block text-left nav-dropdown"> 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 </summary> 36 <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 49 {{ define "dropDown" }} 50 <details class="relative inline-block text-left nav-dropdown"> 51 <summary 52 + class="cursor-pointer list-none flex items-center gap-1" 53 > 54 + {{ $user := .Did }} 55 + <img 56 + src="{{ tinyAvatar $user }}" 57 + alt="" 58 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 59 + /> 60 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 </summary> 62 <div 63 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+9
appview/pages/templates/layouts/profilebase.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 <meta property="og:type" content="profile" /> 6 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 {{ end }} 9 10 {{ define "content" }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 + {{ $avatarUrl := fullAvatar .Card.UserHandle }} 5 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 6 <meta property="og:type" content="profile" /> 7 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 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 }}" /> 17 {{ end }} 18 19 {{ define "content" }}
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 {{ define "title" }}privacy policy{{ end }} 2 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> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}privacy policy{{ 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">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 }} 15 </div> 16 + </main> 17 </div> 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 {{ define "title" }}terms of service{{ end }} 2 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> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}terms of service{{ 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">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 }} 15 </div> 16 + </main> 17 </div> 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}}
+84
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 "pull_created" }} 44 + created a pull request 45 + {{ else if eq .Type "pull_commented" }} 46 + commented on a pull request 47 + {{ else if eq .Type "pull_merged" }} 48 + merged a pull request 49 + {{ else if eq .Type "pull_closed" }} 50 + closed a pull request 51 + {{ else if eq .Type "followed" }} 52 + followed you 53 + {{ else }} 54 + {{ end }} 55 + {{ end }} 56 + 57 + {{ define "notificationSummary" }} 58 + {{ if eq .Type "repo_starred" }} 59 + <!-- no summary --> 60 + {{ else if .Issue }} 61 + #{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 62 + {{ else if .Pull }} 63 + #{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 64 + {{ else if eq .Type "followed" }} 65 + <!-- no summary --> 66 + {{ else }} 67 + {{ end }} 68 + {{ end }} 69 + 70 + {{ define "notificationUrl" }} 71 + {{ $url := "" }} 72 + {{ if eq .Type "repo_starred" }} 73 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 74 + {{ else if .Issue }} 75 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 76 + {{ else if .Pull }} 77 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 78 + {{ else if eq .Type "followed" }} 79 + {{$url = printf "/%s" (resolve .ActorDid)}} 80 + {{ else }} 81 + {{ end }} 82 + 83 + {{ $url }} 84 + {{ 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 }}
+3 -3
appview/pages/templates/repo/commit.html
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 + <header class="col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 + <footer class="col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
+7
appview/pages/templates/repo/fork.html
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2">
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 16 <fieldset class="space-y-3"> 17 <legend class="dark:text-white">Select a knot to fork into</legend> 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+9 -1
appview/pages/templates/repo/fragments/og.html
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - 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 {{ end }}
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 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 }}
+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 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 {{ if .IsReacted }} 24 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 {{ else }}
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 relative group 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 28 {{ if .IsReacted }} 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 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"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 {{- if .ReadmeFileName -}} 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+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 35 {{ define "editIssueComment" }} 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 hx-swap="outerHTML" 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 45 {{ define "deleteIssueComment" }} 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 hx-confirm="Are you sure you want to delete your comment?" 50 hx-swap="outerHTML"
··· 34 35 {{ define "editIssueComment" }} 36 <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 hx-swap="outerHTML" 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 45 {{ define "deleteIssueComment" }} 46 <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 hx-confirm="Are you sure you want to delete your comment?" 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">#{{ .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 </div> 139 </form> 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 143 </div> 144 {{ end }} 145 {{ end }}
··· 138 </div> 139 </form> 140 {{ else }} 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 148 </div> 149 {{ end }} 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 }}
+8 -35
appview/pages/templates/repo/issues/issue.html
··· 2 3 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) }} 9 {{ end }} 10 11 {{ define "repoContentLayout" }} ··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 26 </div> 27 </div> 28 {{ end }} ··· 87 88 {{ define "editIssue" }} 89 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 91 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 hx-swap="innerHTML" 93 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 98 {{ define "deleteIssue" }} 99 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 101 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 hx-confirm="Are you sure you want to delete your issue?" 103 hx-swap="none"> ··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 {{ 114 template "repo/fragments/reaction" 115 (dict 116 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 }} 121 {{ end }} 122 </div> 123 {{ end }} 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 152 {{ define "repoAfter" }} 153 <div class="flex flex-col gap-4 mt-4">
··· 2 3 4 {{ define "extrameta" }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 6 {{ end }} 7 8 {{ define "repoContentLayout" }} ··· 19 "Defs" $.LabelDefs 20 "Subject" $.Issue.AtUri 21 "State" $.Issue.Labels) }} 22 + {{ template "repo/fragments/participants" $.Issue.Participants }} 23 </div> 24 </div> 25 {{ end }} ··· 84 85 {{ define "editIssue" }} 86 <a 87 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 88 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 89 hx-swap="innerHTML" 90 hx-target="#issue-{{.Issue.IssueId}}"> ··· 94 95 {{ define "deleteIssue" }} 96 <a 97 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 98 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 99 hx-confirm="Are you sure you want to delete your issue?" 100 hx-swap="none"> ··· 107 <div class="flex items-center gap-2"> 108 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 109 {{ range $kind := .OrderedReactionKinds }} 110 + {{ $reactionData := index $.Reactions $kind }} 111 {{ 112 template "repo/fragments/reaction" 113 (dict 114 "Kind" $kind 115 + "Count" $reactionData.Count 116 "IsReacted" (index $.UserReacted $kind) 117 + "ThreadAt" $.Issue.AtUri 118 + "Users" $reactionData.Users) 119 }} 120 {{ end }} 121 </div> 122 {{ end }} 123 124 125 {{ define "repoAfter" }} 126 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 39 {{ 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 }} 92 </div> 93 {{ block "pagination" . }} {{ end }} 94 {{ end }}
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 42 </div> 43 {{ block "pagination" . }} {{ end }} 44 {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 6 </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> 19 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 - /> 29 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 - /> 37 </div> 38 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 41 <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> 58 </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> 61 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> 71 </div> 72 - </form> 73 - </div> 74 {{ end }}
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 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" . }} 13 </div> 14 + {{ end }} 15 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 }} 21 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> 35 </div> 36 + <div id="repo" class="error mt-2"></div> 37 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 + 52 <div class="space-y-2"> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 55 </div> 56 + </div> 57 + </div> 58 + {{ end }} 59 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 }} 64 </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> 176 {{ 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 }}
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 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 }}
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 </button> 36 + {{ if .BranchDeleteStatus }} 37 + <button 38 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 + hx-swap="none" 41 + 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"> 42 + {{ i "git-branch" "w-4 h-4" }} 43 + <span>delete branch</span> 44 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 + </button> 46 + {{ end }} 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 48 {{ $disabled := "" }} 49 {{ if $isConflicted }}
+15 -11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 {{ if not .Pull.IsPatchBased }} 43 from 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 -}} 54 </span> 55 {{ end }} 56 </span> ··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 {{ 70 template "repo/fragments/reaction" 71 (dict 72 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 }} 77 {{ end }} 78 </div>
··· 42 {{ if not .Pull.IsPatchBased }} 43 from 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 + {{ if not .Pull.IsForkBased }} 46 + {{ $repoPath := .RepoInfo.FullName }} 47 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 + {{ else if .Pull.PullSource.Repo }} 49 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 + <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 + {{ else }} 53 + <span class="italic">[deleted fork]</span>: 54 + {{ .Pull.PullSource.Branch }} 55 + {{ end }} 56 </span> 57 {{ end }} 58 </span> ··· 68 <div class="flex items-center gap-2 mt-2"> 69 {{ template "repo/fragments/reactionsPopUp" . }} 70 {{ range $kind := . }} 71 + {{ $reactionData := index $.Reactions $kind }} 72 {{ 73 template "repo/fragments/reaction" 74 (dict 75 "Kind" $kind 76 + "Count" $reactionData.Count 77 "IsReacted" (index $.UserReacted $kind) 78 + "ThreadAt" $.Pull.PullAt 79 + "Users" $reactionData.Users) 80 }} 81 {{ end }} 82 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ resolve .LoggedInUser.Did }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 29 {{ end }} 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 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 52 {{ end }} 53 </div> 54 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 63 {{ define "contentAfter" }} 64 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
··· 28 29 {{ end }} 30 31 {{ define "mainLayout" }} 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 33 {{ block "contentLayout" . }} 34 {{ block "content" . }}{{ end }} 35 {{ end }} ··· 46 {{ end }} 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
appview/pages/templates/repo/pulls/patch.html
··· 34 </section> 35 {{ end }} 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 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 45 {{ block "contentLayout" . }} 46 {{ block "content" . }}{{ end }} 47 {{ end }} ··· 57 </div> 58 {{ end }} 59 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 {{ end }} 67 68 {{ define "contentAfter" }}
··· 34 </section> 35 {{ end }} 36 37 {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 51 </div> 52 {{ end }} 53 </div> 54 {{ end }} 55 56 {{ define "contentAfter" }}
+48 -20
appview/pages/templates/repo/pulls/pull.html
··· 3 {{ end }} 4 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 }} 8 - 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 13 {{ define "repoContent" }} 14 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 {{ with $item }} 40 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 43 <!-- round number --> 44 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 </div> 47 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 {{ $owner := resolve $.Pull.OwnerDid }} 51 {{ $re := "re" }} ··· 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 <span id="interdiff-error-{{.RoundNumber}}"></span> 84 - {{ end }} 85 </div> 86 </summary> 87 ··· 146 147 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 {{ 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"> 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} ··· 169 {{ end }} 170 171 {{ if $.LoggedInUser }} 172 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 173 {{ 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 177 </div> 178 {{ end }} 179 </div>
··· 3 {{ end }} 4 5 {{ define "extrameta" }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 {{ end }} 8 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.PullAt 22 + "State" $.Pull.Labels) }} 23 + {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + </div> 25 + </div> 26 + {{ end }} 27 28 {{ define "repoContent" }} 29 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 54 {{ with $item }} 55 <details {{ if eq $idx $lastIdx }}open{{ end }}> 56 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 57 + <div class="flex flex-wrap gap-2 items-stretch"> 58 <!-- round number --> 59 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 60 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 61 </div> 62 <!-- round summary --> 63 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 64 <span class="gap-1 flex items-center"> 65 {{ $owner := resolve $.Pull.OwnerDid }} 66 {{ $re := "re" }} ··· 87 <span class="hidden md:inline">diff</span> 88 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 89 </a> 90 + {{ if ne $idx 0 }} 91 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 92 + hx-boost="true" 93 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 94 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 95 + <span class="hidden md:inline">interdiff</span> 96 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 97 + </a> 98 + {{ end }} 99 <span id="interdiff-error-{{.RoundNumber}}"></span> 100 </div> 101 </summary> 102 ··· 161 162 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 163 {{ range $cidx, $c := .Comments }} 164 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 165 {{ if gt $cidx 0 }} 166 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 167 {{ end }} ··· 184 {{ end }} 185 186 {{ if $.LoggedInUser }} 187 + {{ template "repo/pulls/fragments/pullActions" 188 + (dict 189 + "LoggedInUser" $.LoggedInUser 190 + "Pull" $.Pull 191 + "RepoInfo" $.RepoInfo 192 + "RoundNumber" .RoundNumber 193 + "MergeCheck" $.MergeCheck 194 + "ResubmitCheck" $.ResubmitCheck 195 + "BranchDeleteStatus" $.BranchDeleteStatus 196 + "Stack" $.Stack) }} 197 {{ else }} 198 + <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"> 199 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 200 + sign up 201 + </a> 202 + <span class="text-gray-500 dark:text-gray-400">or</span> 203 + <a href="/login" class="underline">login</a> 204 + to add to the discussion 205 </div> 206 {{ end }} 207 </div>
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 </div> 112 </div> 113 {{ if .StackId }}
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 118 </div> 119 </div> 120 {{ if .StackId }}
+2
appview/pages/templates/repo/settings/access.html
··· 83 </label> 84 <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"
··· 83 </label> 84 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 <input 86 + autocapitalize="none" 87 + autocorrect="off" 88 type="text" 89 id="add-collaborator" 90 name="collaborator"
+1 -1
appview/pages/templates/repo/tree.html
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 30 </label> 31 <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"
··· 30 </label> 31 <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 + autocapitalize="none" 34 + autocorrect="off" 35 type="text" 36 id="member-did-{{ .Id }}" 37 name="member"
+2 -2
appview/pages/templates/strings/put.html
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 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> 8 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }} 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 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 <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> 32 </div> 33 {{ with .Description }} 34 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 43 {{ define "stringCardInfo" }} 44 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 <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 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 <span class="select-none [&:before]:content-['·']"></span> 53 {{ with .Edited }}
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 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> 35 </div> 36 {{ with .Description }} 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 45 46 {{ define "stringCardInfo" }} 47 {{ $stat := .Stats }} 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 50 <span class="select-none [&:before]:content-['·']"></span> 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 }}
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 {{ $event := index . 1 }} 83 {{ $follow := $event.Follow }} 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 86 87 {{ $userHandle := resolve $follow.UserDid }} 88 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 </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> 127 {{ end }}
··· 82 {{ $event := index . 1 }} 83 {{ $follow := $event.Follow }} 84 {{ $profile := $event.Profile }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 87 88 {{ $userHandle := resolve $follow.UserDid }} 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 95 </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) }} 104 {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 {{ template "timeline/fragments/trending" . }} 16 {{ template "timeline/fragments/timeline" . }} 17 <div class="flex justify-end">
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 {{ template "timeline/fragments/trending" . }} 18 {{ template "timeline/fragments/timeline" . }} 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link 24 rel="stylesheet" 25 href="/static/tw.css?{{ cssContentHash }}"
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 24 <link 25 rel="stylesheet" 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 14 {{ else }} 15 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 {{ end }}
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Followers }} 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) }} 21 {{ else }} 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 14 {{ else }} 15 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 {{ end }}
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Following }} 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) }} 21 {{ else }} 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 {{ define "user/fragments/follow" }} 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 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 }} 16 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 </button> 18 {{ end }}
··· 1 {{ define "user/fragments/follow" }} 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 + class="btn w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 14 > 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 }} 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 </button> 22 {{ end }}
+21 -18
appview/pages/templates/user/fragments/followCard.html
··· 1 {{ define "user/fragments/followCard" }} 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"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 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 }}" /> 7 </div> 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> 19 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 {{ template "user/fragments/follow" . }} 25 </div> 26 - {{ end }} 27 </div> 28 </div> 29 - {{ end }}
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 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> 23 </div> 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"> 26 {{ template "user/fragments/follow" . }} 27 </div> 28 + {{ end }} 29 + </div> 30 </div> 31 </div> 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 - {{ . | truncateAt30 }} 8 {{ end }}
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 /> 7 + {{ . | resolve | truncateAt30 }} 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 5 </a> 6 {{ end }}
··· 1 {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 4 </a> 5 {{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
··· 14 {{ with $repo }} 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 <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 - 24 {{ $repoOwner := resolve .Did }} 25 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 27 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 29 {{- end -}} 30 </div> 31 - 32 {{ if and $starButton $root.LoggedInUser }} 33 {{ template "repo/fragments/repoStar" $starData }} 34 {{ end }} 35 </div> 36 {{ with .Description }}
··· 14 {{ with $repo }} 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 <div class="font-medium dark:text-white flex items-center justify-between"> 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 }} 23 {{ $repoOwner := resolve .Did }} 24 {{- if $fullName -}} 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 26 {{- else -}} 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 28 {{- end -}} 29 </div> 30 {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 32 {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 34 {{ end }} 35 </div> 36 {{ with .Description }}
+5 -1
appview/pages/templates/user/login.html
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &middot; tangled</title> 13 </head> ··· 28 <div class="flex flex-col"> 29 <label for="handle">handle</label> 30 <input 31 type="text" 32 id="handle" 33 name="handle" ··· 36 placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 handle to log in. If you're unsure, this is likely 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span>
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>login &middot; tangled</title> 14 </head> ··· 29 <div class="flex flex-col"> 30 <label for="handle">handle</label> 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 35 type="text" 36 id="handle" 37 name="handle" ··· 40 placeholder="akshay.tngl.sh" 41 /> 42 <span class="text-sm text-gray-500 mt-1"> 43 + Use your <a href="https://atproto.com">AT Protocol</a> 44 handle to log in. If you're unsure, this is likely 45 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 46 </span>
+173
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">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 39 </span> 40 - {{ end }} 41 </div> 42 </div> 43 <div class="flex items-center justify-between p-4">
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 <span class="font-bold"> 37 + {{ resolve .LoggedInUser.Did }} 38 </span> 39 </div> 40 </div> 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> ··· 39 invite code, desired username, and password in the next 40 page to complete your registration. 41 </span> 42 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 <span>join now</span> 44 </button> 45 </form> 46 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 </p> 49 50 <p id="signup-msg" class="error w-full"></p>
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 16 </head> 17 <body class="flex items-center justify-center min-h-screen"> 18 <main class="max-w-md px-6 -mt-4"> ··· 42 invite code, desired username, and password in the next 43 page to complete your registration. 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 </form> 52 <p class="text-sm text-gray-500"> 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 </p> 55 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+3 -4
appview/pipelines/pipelines.go
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 - "tangled.org/core/log" 20 "tangled.org/core/rbac" 21 spindlemodel "tangled.org/core/spindle/models" 22 ··· 45 db *db.DB, 46 config *config.Config, 47 enforcer *rbac.Enforcer, 48 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 - return &Pipelines{oauth: oauth, 52 repoResolver: repoResolver, 53 pages: pages, 54 idResolver: idResolver,
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/rbac" 20 spindlemodel "tangled.org/core/spindle/models" 21 ··· 44 db *db.DB, 45 config *config.Config, 46 enforcer *rbac.Enforcer, 47 + logger *slog.Logger, 48 ) *Pipelines { 49 + return &Pipelines{ 50 + oauth: oauth, 51 repoResolver: repoResolver, 52 pages: pages, 53 idResolver: idResolver,
-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 = "static/icons/git-pull-request.svg" 150 + statusText = "open" 151 + statusColor = color.RGBA{34, 139, 34, 255} // green 152 + } else if pull.State.IsMerged() { 153 + statusIcon = "static/icons/git-merge.svg" 154 + statusText = "merged" 155 + statusColor = color.RGBA{138, 43, 226, 255} // purple 156 + } else { 157 + statusIcon = "static/icons/git-pull-request-closed.svg" 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.DrawSVGIcon(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.DrawSVGIcon("static/icons/message-square.svg", 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.DrawSVGIcon("static/icons/file-diff.svg", 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", 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 + }
+233 -190
appview/pulls/pulls.go
··· 6 "errors" 7 "fmt" 8 "log" 9 "net/http" 10 "sort" 11 "strconv" 12 "strings" ··· 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/markup" 23 "tangled.org/core/appview/reporesolver" 24 "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/idresolver" 26 "tangled.org/core/patchutil" 27 "tangled.org/core/tid" 28 "tangled.org/core/types" 29 30 - "github.com/bluekeyes/go-gitdiff/gitdiff" 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 43 db *db.DB 44 config *config.Config 45 notifier notify.Notifier 46 } 47 48 func New( ··· 53 db *db.DB, 54 config *config.Config, 55 notifier notify.Notifier, 56 ) *Pulls { 57 return &Pulls{ 58 oauth: oauth, ··· 62 db: db, 63 config: config, 64 notifier: notifier, 65 } 66 } 67 ··· 98 } 99 100 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 101 resubmitResult := pages.Unknown 102 if user.Did == pull.OwnerDid { 103 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 } 105 106 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, 114 }) 115 return 116 } ··· 135 stack, _ := r.Context().Value("stack").(models.Stack) 136 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 138 - totalIdents := 1 139 - for _, submission := range pull.Submissions { 140 - totalIdents += len(submission.Comments) 141 - } 142 - 143 - identsToResolve := make([]string, totalIdents) 144 - 145 - // populate idents 146 - identsToResolve[0] = pull.OwnerDid 147 - idx := 1 148 - for _, submission := range pull.Submissions { 149 - for _, comment := range submission.Comments { 150 - identsToResolve[idx] = comment.OwnerDid 151 - idx += 1 152 - } 153 - } 154 - 155 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 156 resubmitResult := pages.Unknown 157 if user != nil && user.Did == pull.OwnerDid { 158 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 189 m[p.Sha] = p 190 } 191 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 if err != nil { 194 log.Println("failed to get pull reactions") 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 } 202 203 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, 212 213 OrderedReactionKinds: models.OrderedReactionKinds, 214 - Reactions: reactionCountMap, 215 UserReacted: userReactions, 216 }) 217 } 218 ··· 283 return result 284 } 285 286 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 287 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 288 return pages.Unknown ··· 330 331 targetBranch := branchResp 332 333 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 334 335 if pull.IsStacked() && stack != nil { 336 top := stack[0] 337 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 338 } 339 340 if latestSourceRev != targetBranch.Hash { ··· 374 return 375 } 376 377 - patch := pull.Submissions[roundIdInt].Patch 378 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 379 380 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 425 return 426 } 427 428 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 429 if err != nil { 430 log.Println("failed to interdiff; current patch malformed") 431 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 432 return 433 } 434 435 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 436 if err != nil { 437 log.Println("failed to interdiff; previous patch malformed") 438 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 557 m[p.Sha] = p 558 } 559 560 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 LoggedInUser: s.oauth.GetUser(r), 562 RepoInfo: f.RepoInfo(user), 563 Pulls: pulls, 564 FilteringBy: state, 565 Stacks: stacks, 566 Pipelines: m, ··· 617 618 createdAt := time.Now().Format(time.RFC3339) 619 620 - pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 621 - if err != nil { 622 - log.Println("failed to get pull at", err) 623 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 624 - return 625 - } 626 - 627 client, err := s.oauth.AuthorizedClient(r) 628 if err != nil { 629 log.Println("failed to get authorized client", err) 630 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 631 return 632 } 633 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 634 Collection: tangled.RepoPullCommentNSID, 635 Repo: user.Did, 636 Rkey: tid.TID(), 637 Record: &lexutil.LexiconTypeDecoder{ 638 Val: &tangled.RepoPullComment{ 639 - Pull: string(pullAt), 640 Body: body, 641 CreatedAt: createdAt, 642 }, ··· 884 } 885 886 sourceRev := comparison.Rev2 887 - patch := comparison.Patch 888 889 - if !patchutil.IsPatchValid(patch) { 890 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 891 return 892 } ··· 899 Sha: comparison.Rev2, 900 } 901 902 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 903 } 904 905 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) { 907 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 908 return 909 } 910 911 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 912 } 913 914 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { ··· 991 } 992 993 sourceRev := comparison.Rev2 994 - patch := comparison.Patch 995 996 - if !patchutil.IsPatchValid(patch) { 997 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 998 return 999 } ··· 1011 Sha: sourceRev, 1012 } 1013 1014 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1015 } 1016 1017 func (s *Pulls) createPullRequest( ··· 1021 user *oauth.User, 1022 title, body, targetBranch string, 1023 patch string, 1024 sourceRev string, 1025 pullSource *models.PullSource, 1026 recordPullSource *tangled.RepoPull_Source, ··· 1058 1059 // We've already checked earlier if it's diff-based and title is empty, 1060 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1062 formatPatches, err := patchutil.ExtractPatches(patch) 1063 if err != nil { 1064 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 return 1070 } 1071 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1074 } 1075 1076 rkey := tid.TID() 1077 initialSubmission := models.PullSubmission{ 1078 Patch: patch, 1079 SourceRev: sourceRev, 1080 } 1081 pull := &models.Pull{ ··· 1103 return 1104 } 1105 1106 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1107 Collection: tangled.RepoPullNSID, 1108 Repo: user.Did, 1109 Rkey: rkey, ··· 1114 Repo: string(f.RepoAt()), 1115 Branch: targetBranch, 1116 }, 1117 - Patch: patch, 1118 - Source: recordPullSource, 1119 }, 1120 }, 1121 }) ··· 1200 } 1201 writes = append(writes, &write) 1202 } 1203 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1204 Repo: user.Did, 1205 Writes: writes, 1206 }) ··· 1250 return 1251 } 1252 1253 - if patch == "" || !patchutil.IsPatchValid(patch) { 1254 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1255 return 1256 } ··· 1504 1505 patch := r.FormValue("patch") 1506 1507 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1508 } 1509 1510 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1565 } 1566 1567 sourceRev := comparison.Rev2 1568 - patch := comparison.Patch 1569 1570 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1571 } 1572 1573 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1599 return 1600 } 1601 1602 - // extract patch by performing compare 1603 - forkScheme := "http" 1604 - if !s.config.Core.Dev { 1605 - forkScheme = "https" 1606 - } 1607 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1608 - forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1609 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1610 - if err != nil { 1611 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1612 - log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1613 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1614 - return 1615 - } 1616 - log.Printf("failed to compare branches: %s", err) 1617 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1618 - return 1619 - } 1620 - 1621 - var forkComparison types.RepoFormatPatchResponse 1622 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1623 - log.Println("failed to decode XRPC compare response for fork", err) 1624 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1625 - return 1626 - } 1627 - 1628 // update the hidden tracking branch to latest 1629 client, err := s.oauth.ServiceClient( 1630 r, ··· 1656 return 1657 } 1658 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.") 1672 } 1673 1674 - if patch == pull.LatestPatch() { 1675 - return fmt.Errorf("Patch is identical to previous submission.") 1676 - } 1677 1678 - if !patchutil.IsPatchValid(patch) { 1679 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1680 - } 1681 1682 - return nil 1683 } 1684 1685 func (s *Pulls) resubmitPullHelper( ··· 1689 user *oauth.User, 1690 pull *models.Pull, 1691 patch string, 1692 sourceRev string, 1693 ) { 1694 if pull.IsStacked() { ··· 1697 return 1698 } 1699 1700 - if err := validateResubmittedPatch(pull, patch); err != nil { 1701 s.pages.Notice(w, "resubmit-error", err.Error()) 1702 return 1703 } 1704 1705 // validate sourceRev if branch/fork based 1706 if pull.IsBranchBased() || pull.IsForkBased() { 1707 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1708 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1709 return 1710 } ··· 1718 } 1719 defer tx.Rollback() 1720 1721 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 1722 if err != nil { 1723 log.Println("failed to create pull request", err) 1724 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1731 return 1732 } 1733 1734 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1735 if err != nil { 1736 // failed to get record 1737 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1754 } 1755 } 1756 1757 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1758 Collection: tangled.RepoPullNSID, 1759 Repo: user.Did, 1760 Rkey: pull.Rkey, ··· 1766 Repo: string(f.RepoAt()), 1767 Branch: pull.TargetBranch, 1768 }, 1769 - Patch: patch, // new patch 1770 - Source: recordPullSource, 1771 }, 1772 }, 1773 }) ··· 1818 // commits that got deleted: corresponding pull is closed 1819 // commits that got added: new pull is created 1820 // commits that got updated: corresponding pull is resubmitted & new round begins 1821 - // 1822 - // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1823 additions := make(map[string]*models.Pull) 1824 deletions := make(map[string]*models.Pull) 1825 - unchanged := make(map[string]struct{}) 1826 updated := make(map[string]struct{}) 1827 1828 // pulls in orignal stack but not in new one ··· 1844 for _, np := range newStack { 1845 if op, ok := origById[np.ChangeId]; ok { 1846 // 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 - } 1867 } 1868 } 1869 ··· 1930 continue 1931 } 1932 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 - 1977 - if err != nil { 1978 - log.Println("failed to update pull", err, op.PullId) 1979 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1980 - return 1981 - } 1982 - 1983 - record := op.AsRecord() 1984 - record.Patch = newSubmission.Patch 1985 1986 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1987 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2026 return 2027 } 2028 2029 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2030 Repo: user.Did, 2031 Writes: writes, 2032 }) ··· 2147 return 2148 } 2149 2150 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2151 } 2152 ··· 2212 log.Println("failed to commit transaction", err) 2213 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2214 return 2215 } 2216 2217 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2313 initialSubmission := models.PullSubmission{ 2314 Patch: fp.Raw, 2315 SourceRev: fp.SHA, 2316 } 2317 pull := models.Pull{ 2318 Title: title,
··· 6 "errors" 7 "fmt" 8 "log" 9 + "log/slog" 10 "net/http" 11 + "slices" 12 "sort" 13 "strconv" 14 "strings" ··· 23 "tangled.org/core/appview/pages" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/validator" 27 "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/idresolver" 29 "tangled.org/core/patchutil" 30 + "tangled.org/core/rbac" 31 "tangled.org/core/tid" 32 "tangled.org/core/types" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 35 lexutil "github.com/bluesky-social/indigo/lex/util" 36 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 46 db *db.DB 47 config *config.Config 48 notifier notify.Notifier 49 + enforcer *rbac.Enforcer 50 + logger *slog.Logger 51 + validator *validator.Validator 52 } 53 54 func New( ··· 59 db *db.DB, 60 config *config.Config, 61 notifier notify.Notifier, 62 + enforcer *rbac.Enforcer, 63 + validator *validator.Validator, 64 + logger *slog.Logger, 65 ) *Pulls { 66 return &Pulls{ 67 oauth: oauth, ··· 71 db: db, 72 config: config, 73 notifier: notifier, 74 + enforcer: enforcer, 75 + logger: logger, 76 + validator: validator, 77 } 78 } 79 ··· 110 } 111 112 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 113 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 114 resubmitResult := pages.Unknown 115 if user.Did == pull.OwnerDid { 116 resubmitResult = s.resubmitCheck(r, f, pull, stack) 117 } 118 119 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 120 + LoggedInUser: user, 121 + RepoInfo: f.RepoInfo(user), 122 + Pull: pull, 123 + RoundNumber: roundNumber, 124 + MergeCheck: mergeCheckResponse, 125 + ResubmitCheck: resubmitResult, 126 + BranchDeleteStatus: branchDeleteStatus, 127 + Stack: stack, 128 }) 129 return 130 } ··· 149 stack, _ := r.Context().Value("stack").(models.Stack) 150 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 151 152 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 153 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 154 resubmitResult := pages.Unknown 155 if user != nil && user.Did == pull.OwnerDid { 156 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 187 m[p.Sha] = p 188 } 189 190 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 191 if err != nil { 192 log.Println("failed to get pull reactions") 193 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 198 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 199 } 200 201 + labelDefs, err := db.GetLabelDefinitions( 202 + s.db, 203 + db.FilterIn("at_uri", f.Repo.Labels), 204 + db.FilterContains("scope", tangled.RepoPullNSID), 205 + ) 206 + if err != nil { 207 + log.Println("failed to fetch labels", err) 208 + s.pages.Error503(w) 209 + return 210 + } 211 + 212 + defs := make(map[string]*models.LabelDefinition) 213 + for _, l := range labelDefs { 214 + defs[l.AtUri().String()] = &l 215 + } 216 + 217 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 218 + LoggedInUser: user, 219 + RepoInfo: repoInfo, 220 + Pull: pull, 221 + Stack: stack, 222 + AbandonedPulls: abandonedPulls, 223 + BranchDeleteStatus: branchDeleteStatus, 224 + MergeCheck: mergeCheckResponse, 225 + ResubmitCheck: resubmitResult, 226 + Pipelines: m, 227 228 OrderedReactionKinds: models.OrderedReactionKinds, 229 + Reactions: reactionMap, 230 UserReacted: userReactions, 231 + 232 + LabelDefs: defs, 233 }) 234 } 235 ··· 300 return result 301 } 302 303 + func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 304 + if pull.State != models.PullMerged { 305 + return nil 306 + } 307 + 308 + user := s.oauth.GetUser(r) 309 + if user == nil { 310 + return nil 311 + } 312 + 313 + var branch string 314 + var repo *models.Repo 315 + // check if the branch exists 316 + // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 317 + if pull.IsBranchBased() { 318 + branch = pull.PullSource.Branch 319 + repo = &f.Repo 320 + } else if pull.IsForkBased() { 321 + branch = pull.PullSource.Branch 322 + repo = pull.PullSource.Repo 323 + } else { 324 + return nil 325 + } 326 + 327 + // deleted fork 328 + if repo == nil { 329 + return nil 330 + } 331 + 332 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 333 + perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 334 + if !slices.Contains(perms, "repo:push") { 335 + return nil 336 + } 337 + 338 + scheme := "http" 339 + if !s.config.Core.Dev { 340 + scheme = "https" 341 + } 342 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 343 + xrpcc := &indigoxrpc.Client{ 344 + Host: host, 345 + } 346 + 347 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 348 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 349 + return nil 350 + } 351 + 352 + return &models.BranchDeleteStatus{ 353 + Repo: repo, 354 + Branch: resp.Name, 355 + } 356 + } 357 + 358 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 359 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 360 return pages.Unknown ··· 402 403 targetBranch := branchResp 404 405 + latestSourceRev := pull.LatestSha() 406 407 if pull.IsStacked() && stack != nil { 408 top := stack[0] 409 + latestSourceRev = top.LatestSha() 410 } 411 412 if latestSourceRev != targetBranch.Hash { ··· 446 return 447 } 448 449 + patch := pull.Submissions[roundIdInt].CombinedPatch() 450 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 451 452 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 497 return 498 } 499 500 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 501 if err != nil { 502 log.Println("failed to interdiff; current patch malformed") 503 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 504 return 505 } 506 507 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 508 if err != nil { 509 log.Println("failed to interdiff; previous patch malformed") 510 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 629 m[p.Sha] = p 630 } 631 632 + labelDefs, err := db.GetLabelDefinitions( 633 + s.db, 634 + db.FilterIn("at_uri", f.Repo.Labels), 635 + db.FilterContains("scope", tangled.RepoPullNSID), 636 + ) 637 + if err != nil { 638 + log.Println("failed to fetch labels", err) 639 + s.pages.Error503(w) 640 + return 641 + } 642 + 643 + defs := make(map[string]*models.LabelDefinition) 644 + for _, l := range labelDefs { 645 + defs[l.AtUri().String()] = &l 646 + } 647 + 648 s.pages.RepoPulls(w, pages.RepoPullsParams{ 649 LoggedInUser: s.oauth.GetUser(r), 650 RepoInfo: f.RepoInfo(user), 651 Pulls: pulls, 652 + LabelDefs: defs, 653 FilteringBy: state, 654 Stacks: stacks, 655 Pipelines: m, ··· 706 707 createdAt := time.Now().Format(time.RFC3339) 708 709 client, err := s.oauth.AuthorizedClient(r) 710 if err != nil { 711 log.Println("failed to get authorized client", err) 712 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 713 return 714 } 715 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 716 Collection: tangled.RepoPullCommentNSID, 717 Repo: user.Did, 718 Rkey: tid.TID(), 719 Record: &lexutil.LexiconTypeDecoder{ 720 Val: &tangled.RepoPullComment{ 721 + Pull: pull.PullAt().String(), 722 Body: body, 723 CreatedAt: createdAt, 724 }, ··· 966 } 967 968 sourceRev := comparison.Rev2 969 + patch := comparison.FormatPatchRaw 970 + combined := comparison.CombinedPatchRaw 971 972 + if err := s.validator.ValidatePatch(&patch); err != nil { 973 + s.logger.Error("failed to validate patch", "err", err) 974 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 975 return 976 } ··· 983 Sha: comparison.Rev2, 984 } 985 986 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 987 } 988 989 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 990 + if err := s.validator.ValidatePatch(&patch); err != nil { 991 + s.logger.Error("patch validation failed", "err", err) 992 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 993 return 994 } 995 996 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 997 } 998 999 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) { ··· 1076 } 1077 1078 sourceRev := comparison.Rev2 1079 + patch := comparison.FormatPatchRaw 1080 + combined := comparison.CombinedPatchRaw 1081 1082 + if err := s.validator.ValidatePatch(&patch); err != nil { 1083 + s.logger.Error("failed to validate patch", "err", err) 1084 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1085 return 1086 } ··· 1098 Sha: sourceRev, 1099 } 1100 1101 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1102 } 1103 1104 func (s *Pulls) createPullRequest( ··· 1108 user *oauth.User, 1109 title, body, targetBranch string, 1110 patch string, 1111 + combined string, 1112 sourceRev string, 1113 pullSource *models.PullSource, 1114 recordPullSource *tangled.RepoPull_Source, ··· 1146 1147 // We've already checked earlier if it's diff-based and title is empty, 1148 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1149 + if title == "" || body == "" { 1150 formatPatches, err := patchutil.ExtractPatches(patch) 1151 if err != nil { 1152 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1157 return 1158 } 1159 1160 + if title == "" { 1161 + title = formatPatches[0].Title 1162 + } 1163 + if body == "" { 1164 + body = formatPatches[0].Body 1165 + } 1166 } 1167 1168 rkey := tid.TID() 1169 initialSubmission := models.PullSubmission{ 1170 Patch: patch, 1171 + Combined: combined, 1172 SourceRev: sourceRev, 1173 } 1174 pull := &models.Pull{ ··· 1196 return 1197 } 1198 1199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1200 Collection: tangled.RepoPullNSID, 1201 Repo: user.Did, 1202 Rkey: rkey, ··· 1207 Repo: string(f.RepoAt()), 1208 Branch: targetBranch, 1209 }, 1210 + Patch: patch, 1211 + Source: recordPullSource, 1212 + CreatedAt: time.Now().Format(time.RFC3339), 1213 }, 1214 }, 1215 }) ··· 1294 } 1295 writes = append(writes, &write) 1296 } 1297 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1298 Repo: user.Did, 1299 Writes: writes, 1300 }) ··· 1344 return 1345 } 1346 1347 + if err := s.validator.ValidatePatch(&patch); err != nil { 1348 + s.logger.Error("faield to validate patch", "err", err) 1349 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1350 return 1351 } ··· 1599 1600 patch := r.FormValue("patch") 1601 1602 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1603 } 1604 1605 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1660 } 1661 1662 sourceRev := comparison.Rev2 1663 + patch := comparison.FormatPatchRaw 1664 + combined := comparison.CombinedPatchRaw 1665 1666 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1667 } 1668 1669 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1695 return 1696 } 1697 1698 // update the hidden tracking branch to latest 1699 client, err := s.oauth.ServiceClient( 1700 r, ··· 1726 return 1727 } 1728 1729 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1730 + // extract patch by performing compare 1731 + forkScheme := "http" 1732 + if !s.config.Core.Dev { 1733 + forkScheme = "https" 1734 + } 1735 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1736 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1737 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1738 + if err != nil { 1739 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1740 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1741 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1742 + return 1743 + } 1744 + log.Printf("failed to compare branches: %s", err) 1745 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1746 + return 1747 + } 1748 1749 + var forkComparison types.RepoFormatPatchResponse 1750 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1751 + log.Println("failed to decode XRPC compare response for fork", err) 1752 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1753 + return 1754 } 1755 1756 + // Use the fork comparison we already made 1757 + comparison := forkComparison 1758 1759 + sourceRev := comparison.Rev2 1760 + patch := comparison.FormatPatchRaw 1761 + combined := comparison.CombinedPatchRaw 1762 1763 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1764 } 1765 1766 func (s *Pulls) resubmitPullHelper( ··· 1770 user *oauth.User, 1771 pull *models.Pull, 1772 patch string, 1773 + combined string, 1774 sourceRev string, 1775 ) { 1776 if pull.IsStacked() { ··· 1779 return 1780 } 1781 1782 + if err := s.validator.ValidatePatch(&patch); err != nil { 1783 s.pages.Notice(w, "resubmit-error", err.Error()) 1784 return 1785 } 1786 1787 + if patch == pull.LatestPatch() { 1788 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1789 + return 1790 + } 1791 + 1792 // validate sourceRev if branch/fork based 1793 if pull.IsBranchBased() || pull.IsForkBased() { 1794 + if sourceRev == pull.LatestSha() { 1795 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1796 return 1797 } ··· 1805 } 1806 defer tx.Rollback() 1807 1808 + pullAt := pull.PullAt() 1809 + newRoundNumber := len(pull.Submissions) 1810 + newPatch := patch 1811 + newSourceRev := sourceRev 1812 + combinedPatch := combined 1813 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1814 if err != nil { 1815 log.Println("failed to create pull request", err) 1816 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1823 return 1824 } 1825 1826 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1827 if err != nil { 1828 // failed to get record 1829 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1846 } 1847 } 1848 1849 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1850 Collection: tangled.RepoPullNSID, 1851 Repo: user.Did, 1852 Rkey: pull.Rkey, ··· 1858 Repo: string(f.RepoAt()), 1859 Branch: pull.TargetBranch, 1860 }, 1861 + Patch: patch, // new patch 1862 + Source: recordPullSource, 1863 + CreatedAt: time.Now().Format(time.RFC3339), 1864 }, 1865 }, 1866 }) ··· 1911 // commits that got deleted: corresponding pull is closed 1912 // commits that got added: new pull is created 1913 // commits that got updated: corresponding pull is resubmitted & new round begins 1914 additions := make(map[string]*models.Pull) 1915 deletions := make(map[string]*models.Pull) 1916 updated := make(map[string]struct{}) 1917 1918 // pulls in orignal stack but not in new one ··· 1934 for _, np := range newStack { 1935 if op, ok := origById[np.ChangeId]; ok { 1936 // pull exists in both stacks 1937 + updated[op.ChangeId] = struct{}{} 1938 } 1939 } 1940 ··· 2001 continue 2002 } 2003 2004 + // resubmit the new pull 2005 + pullAt := op.PullAt() 2006 + newRoundNumber := len(op.Submissions) 2007 + newPatch := np.LatestPatch() 2008 + combinedPatch := np.LatestSubmission().Combined 2009 + newSourceRev := np.LatestSha() 2010 + err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 2011 if err != nil { 2012 log.Println("failed to update pull", err, op.PullId) 2013 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2014 return 2015 } 2016 2017 + record := np.AsRecord() 2018 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2020 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2059 return 2060 } 2061 2062 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2063 Repo: user.Did, 2064 Writes: writes, 2065 }) ··· 2180 return 2181 } 2182 2183 + // notify about the pull merge 2184 + for _, p := range pullsToMerge { 2185 + s.notifier.NewPullMerged(r.Context(), p) 2186 + } 2187 + 2188 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2189 } 2190 ··· 2250 log.Println("failed to commit transaction", err) 2251 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2252 return 2253 + } 2254 + 2255 + for _, p := range pullsToClose { 2256 + s.notifier.NewPullClosed(r.Context(), p) 2257 } 2258 2259 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2355 initialSubmission := models.PullSubmission{ 2356 Patch: fp.Raw, 2357 SourceRev: fp.SHA, 2358 + Combined: fp.Raw, 2359 } 2360 pull := models.Pull{ 2361 Title: title,
+1
appview/pulls/router.go
··· 23 r.Route("/{pull}", func(r chi.Router) { 24 r.Use(mw.ResolvePull()) 25 r.Get("/", s.RepoSinglePull) 26 27 r.Route("/round/{round}", func(r chi.Router) { 28 r.Get("/", s.RepoPullPatch)
··· 23 r.Route("/{pull}", func(r chi.Router) { 24 r.Use(mw.ResolvePull()) 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 27 28 r.Route("/round/{round}", func(r chi.Router) { 29 r.Get("/", s.RepoPullPatch)
+11 -10
appview/repo/artifact.go
··· 10 "net/url" 11 "time" 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 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" ··· 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/tid" 27 "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 60 return 61 } 62 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 if err != nil { 65 log.Println("failed to upload blob", err) 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 rkey := tid.TID() 73 createdAt := time.Now() 74 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 Collection: tangled.RepoArtifactNSID, 77 Repo: user.Did, 78 Rkey: rkey, ··· 249 return 250 } 251 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 Collection: tangled.RepoArtifactNSID, 254 Repo: user.Did, 255 Rkey: artifact.Rkey,
··· 10 "net/url" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" ··· 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 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" 29 ) 30 31 // TODO: proper statuses here on early exit ··· 61 return 62 } 63 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 65 if err != nil { 66 log.Println("failed to upload blob", err) 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 rkey := tid.TID() 74 createdAt := time.Now() 75 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 Repo: user.Did, 79 Rkey: rkey, ··· 250 return 251 } 252 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 254 Collection: tangled.RepoArtifactNSID, 255 Repo: user.Did, 256 Rkey: artifact.Rkey,
+31 -33
appview/repo/index.go
··· 3 import ( 4 "errors" 5 "fmt" 6 - "log" 7 "net/http" 8 "net/url" 9 "slices" ··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/types" ··· 32 ) 33 34 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 35 ref := chi.URLParam(r, "ref") 36 ref, _ = url.PathUnescape(ref) 37 38 f, err := rp.repoResolver.Resolve(r) 39 if err != nil { 40 - log.Println("failed to fully resolve repo", err) 41 return 42 } 43 ··· 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 58 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 59 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 60 - log.Println("failed to call XRPC repo.index", err) 61 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 62 LoggedInUser: user, 63 NeedsKnotUpgrade: true, ··· 67 } 68 69 rp.pages.Error503(w) 70 - log.Println("failed to build index response", err) 71 return 72 } 73 ··· 120 emails := uniqueEmails(commitsTrunc) 121 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 122 if err != nil { 123 - log.Println("failed to get email to did map", err) 124 } 125 126 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 127 if err != nil { 128 - log.Println(err) 129 } 130 131 // TODO: a bit dirty 132 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 133 if err != nil { 134 - log.Printf("failed to compute language percentages: %s", err) 135 // non-fatal 136 } 137 ··· 141 } 142 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 143 if err != nil { 144 - log.Printf("failed to fetch pipeline statuses: %s", err) 145 // non-fatal 146 } 147 ··· 163 164 func (rp *Repo) getLanguageInfo( 165 ctx context.Context, 166 f *reporesolver.ResolvedRepo, 167 xrpcc *indigoxrpc.Client, 168 currentRef string, ··· 181 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 182 if err != nil { 183 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 184 - log.Println("failed to call XRPC repo.languages", xrpcerr) 185 return nil, xrpcerr 186 } 187 return nil, err ··· 201 }) 202 } 203 204 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 206 if err != nil { 207 // non-fatal 208 - log.Println("failed to cache lang results", err) 209 } 210 } 211 ··· 328 } 329 }() 330 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 wg.Wait() 352 353 if errs != nil { ··· 374 } 375 files = append(files, niceFile) 376 } 377 } 378 379 result := &types.RepoIndexResponse{
··· 3 import ( 4 "errors" 5 "fmt" 6 + "log/slog" 7 "net/http" 8 "net/url" 9 "slices" ··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/xrpcclient" 27 "tangled.org/core/types" ··· 31 ) 32 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 36 ref := chi.URLParam(r, "ref") 37 ref, _ = url.PathUnescape(ref) 38 39 f, err := rp.repoResolver.Resolve(r) 40 if err != nil { 41 + l.Error("failed to fully resolve repo", "err", err) 42 return 43 } 44 ··· 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 61 + l.Error("failed to call XRPC repo.index", "err", err) 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 LoggedInUser: user, 64 NeedsKnotUpgrade: true, ··· 68 } 69 70 rp.pages.Error503(w) 71 + l.Error("failed to build index response", "err", err) 72 return 73 } 74 ··· 121 emails := uniqueEmails(commitsTrunc) 122 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 123 if err != nil { 124 + l.Error("failed to get email to did map", "err", err) 125 } 126 127 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 128 if err != nil { 129 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 130 } 131 132 // TODO: a bit dirty 133 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 134 if err != nil { 135 + l.Warn("failed to compute language percentages", "err", err) 136 // non-fatal 137 } 138 ··· 142 } 143 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 144 if err != nil { 145 + l.Error("failed to fetch pipeline statuses", "err", err) 146 // non-fatal 147 } 148 ··· 164 165 func (rp *Repo) getLanguageInfo( 166 ctx context.Context, 167 + l *slog.Logger, 168 f *reporesolver.ResolvedRepo, 169 xrpcc *indigoxrpc.Client, 170 currentRef string, ··· 183 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 184 if err != nil { 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 187 return nil, xrpcerr 188 } 189 return nil, err ··· 203 }) 204 } 205 206 + tx, err := rp.db.Begin() 207 + if err != nil { 208 + return nil, err 209 + } 210 + defer tx.Rollback() 211 + 212 // update appview's cache 213 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 214 if err != nil { 215 // non-fatal 216 + l.Error("failed to cache lang results", "err", err) 217 + } 218 + 219 + err = tx.Commit() 220 + if err != nil { 221 + return nil, err 222 } 223 } 224 ··· 341 } 342 }() 343 344 wg.Wait() 345 346 if errs != nil { ··· 367 } 368 files = append(files, niceFile) 369 } 370 + } 371 + 372 + if treeResp != nil && treeResp.Readme != nil { 373 + readmeFileName = treeResp.Readme.Filename 374 + readmeContent = treeResp.Readme.Contents 375 } 376 377 result := &types.RepoIndexResponse{
+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.DrawSVGIcon("static/icons/star.svg", 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.DrawSVGIcon("static/icons/circle-dot.svg", 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.DrawSVGIcon("static/icons/git-pull-request.svg", 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", 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) RepoOpenGraphSummary(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 + }
+229 -158
appview/repo/repo.go
··· 7 "errors" 8 "fmt" 9 "io" 10 - "log" 11 "log/slog" 12 "net/http" 13 "net/url" ··· 17 "strings" 18 "time" 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.org/core/api/tangled" 24 "tangled.org/core/appview/commitverify" 25 "tangled.org/core/appview/config" ··· 40 "tangled.org/core/types" 41 "tangled.org/core/xrpc/serviceauth" 42 43 securejoin "github.com/cyphar/filepath-securejoin" 44 "github.com/go-chi/chi/v5" 45 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 ) 49 50 type Repo struct { ··· 90 } 91 92 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 93 ref := chi.URLParam(r, "ref") 94 ref, _ = url.PathUnescape(ref) 95 96 f, err := rp.repoResolver.Resolve(r) 97 if err != nil { 98 - log.Println("failed to get repo and knot", err) 99 return 100 } 101 ··· 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 } ··· 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 ··· 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 } ··· 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 } ··· 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) ··· 230 } 231 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 232 if err != nil { 233 - log.Println(err) 234 // non-fatal 235 } 236 ··· 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 } ··· 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 } ··· 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 } ··· 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 } ··· 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 } ··· 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, ··· 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 ··· 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") ··· 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 ··· 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 ··· 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 { ··· 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). ··· 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 ··· 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 ··· 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 ··· 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 } ··· 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 ··· 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 } ··· 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 } ··· 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 ··· 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 } ··· 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 ··· 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 } ··· 883 user := rp.oauth.GetUser(r) 884 l := rp.logger.With("handler", "EditSpindle") 885 l = l.With("did", user.Did) 886 - l = l.With("handle", user.Handle) 887 888 errorId := "operation-error" 889 fail := func(msg string, err error) { ··· 936 return 937 } 938 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 if err != nil { 941 fail("Failed to update spindle, no record found on PDS.", err) 942 return 943 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 945 Collection: tangled.RepoNSID, 946 Repo: newRepo.Did, 947 Rkey: newRepo.Rkey, ··· 971 user := rp.oauth.GetUser(r) 972 l := rp.logger.With("handler", "AddLabel") 973 l = l.With("did", user.Did) 974 - l = l.With("handle", user.Handle) 975 976 f, err := rp.repoResolver.Resolve(r) 977 if err != nil { ··· 1040 1041 // emit a labelRecord 1042 labelRecord := label.AsRecord() 1043 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1044 Collection: tangled.LabelDefinitionNSID, 1045 Repo: label.Did, 1046 Rkey: label.Rkey, ··· 1063 newRepo.Labels = append(newRepo.Labels, aturi) 1064 repoRecord := newRepo.AsRecord() 1065 1066 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1067 if err != nil { 1068 fail("Failed to update labels, no record found on PDS.", err) 1069 return 1070 } 1071 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1072 Collection: tangled.RepoNSID, 1073 Repo: newRepo.Did, 1074 Rkey: newRepo.Rkey, ··· 1131 user := rp.oauth.GetUser(r) 1132 l := rp.logger.With("handler", "DeleteLabel") 1133 l = l.With("did", user.Did) 1134 - l = l.With("handle", user.Handle) 1135 1136 f, err := rp.repoResolver.Resolve(r) 1137 if err != nil { ··· 1161 } 1162 1163 // delete label record from PDS 1164 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1165 Collection: tangled.LabelDefinitionNSID, 1166 Repo: label.Did, 1167 Rkey: label.Rkey, ··· 1183 newRepo.Labels = updated 1184 repoRecord := newRepo.AsRecord() 1185 1186 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1187 if err != nil { 1188 fail("Failed to update labels, no record found on PDS.", err) 1189 return 1190 } 1191 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1192 Collection: tangled.RepoNSID, 1193 Repo: newRepo.Did, 1194 Rkey: newRepo.Rkey, ··· 1240 user := rp.oauth.GetUser(r) 1241 l := rp.logger.With("handler", "SubscribeLabel") 1242 l = l.With("did", user.Did) 1243 - l = l.With("handle", user.Handle) 1244 1245 f, err := rp.repoResolver.Resolve(r) 1246 if err != nil { ··· 1281 return 1282 } 1283 1284 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1285 if err != nil { 1286 fail("Failed to update labels, no record found on PDS.", err) 1287 return 1288 } 1289 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1290 Collection: tangled.RepoNSID, 1291 Repo: newRepo.Did, 1292 Rkey: newRepo.Rkey, ··· 1327 user := rp.oauth.GetUser(r) 1328 l := rp.logger.With("handler", "UnsubscribeLabel") 1329 l = l.With("did", user.Did) 1330 - l = l.With("handle", user.Handle) 1331 1332 f, err := rp.repoResolver.Resolve(r) 1333 if err != nil { ··· 1370 return 1371 } 1372 1373 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1374 if err != nil { 1375 fail("Failed to update labels, no record found on PDS.", err) 1376 return 1377 } 1378 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1379 Collection: tangled.RepoNSID, 1380 Repo: newRepo.Did, 1381 Rkey: newRepo.Rkey, ··· 1421 db.FilterContains("scope", subject.Collection().String()), 1422 ) 1423 if err != nil { 1424 - log.Println("failed to fetch label defs", err) 1425 return 1426 } 1427 ··· 1432 1433 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1434 if err != nil { 1435 - log.Println("failed to build label state", err) 1436 return 1437 } 1438 state := states[subject] ··· 1469 db.FilterContains("scope", subject.Collection().String()), 1470 ) 1471 if err != nil { 1472 - log.Println("failed to fetch labels", err) 1473 return 1474 } 1475 ··· 1480 1481 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1482 if err != nil { 1483 - log.Println("failed to build label state", err) 1484 return 1485 } 1486 state := states[subject] ··· 1499 user := rp.oauth.GetUser(r) 1500 l := rp.logger.With("handler", "AddCollaborator") 1501 l = l.With("did", user.Did) 1502 - l = l.With("handle", user.Handle) 1503 1504 f, err := rp.repoResolver.Resolve(r) 1505 if err != nil { ··· 1546 currentUser := rp.oauth.GetUser(r) 1547 rkey := tid.TID() 1548 createdAt := time.Now() 1549 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1550 Collection: tangled.RepoCollaboratorNSID, 1551 Repo: currentUser.Did, 1552 Rkey: rkey, ··· 1628 1629 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1630 user := rp.oauth.GetUser(r) 1631 1632 noticeId := "operation-error" 1633 f, err := rp.repoResolver.Resolve(r) 1634 if err != nil { 1635 - log.Println("failed to get repo and knot", err) 1636 return 1637 } 1638 1639 // remove record from pds 1640 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1641 if err != nil { 1642 - log.Println("failed to get authorized client", err) 1643 return 1644 } 1645 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1646 Collection: tangled.RepoNSID, 1647 Repo: user.Did, 1648 Rkey: f.Rkey, 1649 }) 1650 if err != nil { 1651 - log.Printf("failed to delete record: %s", err) 1652 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1653 return 1654 } 1655 - log.Println("removed repo record ", f.RepoAt().String()) 1656 1657 client, err := rp.oauth.ServiceClient( 1658 r, ··· 1661 oauth.WithDev(rp.config.Core.Dev), 1662 ) 1663 if err != nil { 1664 - log.Println("failed to connect to knot server:", err) 1665 return 1666 } 1667 ··· 1678 rp.pages.Notice(w, noticeId, err.Error()) 1679 return 1680 } 1681 - log.Println("deleted repo from knot") 1682 1683 tx, err := rp.db.BeginTx(r.Context(), nil) 1684 if err != nil { 1685 - log.Println("failed to start tx") 1686 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1687 return 1688 } ··· 1690 tx.Rollback() 1691 err = rp.enforcer.E.LoadPolicy() 1692 if err != nil { 1693 - log.Println("failed to rollback policies") 1694 } 1695 }() 1696 ··· 1704 did := c[0] 1705 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1706 } 1707 - log.Println("removed collaborators") 1708 1709 // remove repo RBAC 1710 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1719 rp.pages.Notice(w, noticeId, "Failed to update appview") 1720 return 1721 } 1722 - log.Println("removed repo from db") 1723 1724 err = tx.Commit() 1725 if err != nil { 1726 - log.Println("failed to commit changes", err) 1727 http.Error(w, err.Error(), http.StatusInternalServerError) 1728 return 1729 } 1730 1731 err = rp.enforcer.E.SavePolicy() 1732 if err != nil { 1733 - log.Println("failed to update ACLs", err) 1734 http.Error(w, err.Error(), http.StatusInternalServerError) 1735 return 1736 } ··· 1739 } 1740 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 ··· 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 } ··· 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 } ··· 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 ··· 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 ··· 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 ··· 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 } ··· 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{ ··· 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 ··· 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 } ··· 2060 } 2061 2062 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2063 ref := chi.URLParam(r, "ref") 2064 ref, _ = url.PathUnescape(ref) 2065 2066 user := rp.oauth.GetUser(r) 2067 f, err := rp.repoResolver.Resolve(r) 2068 if err != nil { 2069 - log.Printf("failed to resolve source repo: %v", err) 2070 return 2071 } 2072 ··· 2110 } 2111 2112 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2113 user := rp.oauth.GetUser(r) 2114 f, err := rp.repoResolver.Resolve(r) 2115 if err != nil { 2116 - log.Printf("failed to resolve source repo: %v", err) 2117 return 2118 } 2119 ··· 2149 } 2150 2151 // choose a name for a fork 2152 - forkName := f.Name 2153 // this check is *only* to see if the forked repo name already exists 2154 // in the user's account. 2155 existingRepo, err := db.GetRepo( 2156 rp.db, 2157 db.FilterEq("did", user.Did), 2158 - db.FilterEq("name", f.Name), 2159 ) 2160 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) 2165 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2166 return 2167 } 2168 } else if existingRepo != nil { 2169 - // repo with this name already exists, append random string 2170 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2171 } 2172 l = l.With("forkName", forkName) 2173 ··· 2189 Knot: targetKnot, 2190 Rkey: rkey, 2191 Source: sourceAt, 2192 - Description: existingRepo.Description, 2193 Created: time.Now(), 2194 Labels: models.DefaultLabelDefs(), 2195 } 2196 record := repo.AsRecord() 2197 2198 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2199 if err != nil { 2200 l.Error("failed to create xrpcclient", "err", err) 2201 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2202 return 2203 } 2204 2205 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2206 Collection: tangled.RepoNSID, 2207 Repo: user.Did, 2208 Rkey: rkey, ··· 2234 rollback := func() { 2235 err1 := tx.Rollback() 2236 err2 := rp.enforcer.E.LoadPolicy() 2237 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2238 2239 // ignore txn complete errors, this is okay 2240 if errors.Is(err1, sql.ErrTxDone) { ··· 2275 2276 err = db.AddRepo(tx, repo) 2277 if err != nil { 2278 - log.Println(err) 2279 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2280 return 2281 } ··· 2284 p, _ := securejoin.SecureJoin(user.Did, forkName) 2285 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2286 if err != nil { 2287 - log.Println(err) 2288 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2289 return 2290 } 2291 2292 err = tx.Commit() 2293 if err != nil { 2294 - log.Println("failed to commit changes", err) 2295 http.Error(w, err.Error(), http.StatusInternalServerError) 2296 return 2297 } 2298 2299 err = rp.enforcer.E.SavePolicy() 2300 if err != nil { 2301 - log.Println("failed to update ACLs", err) 2302 http.Error(w, err.Error(), http.StatusInternalServerError) 2303 return 2304 } ··· 2307 aturi = "" 2308 2309 rp.notifier.NewRepo(r.Context(), repo) 2310 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2311 } 2312 } 2313 2314 // this is used to rollback changes made to the PDS 2315 // 2316 // it is a no-op if the provided ATURI is empty 2317 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2318 if aturi == "" { 2319 return nil 2320 } ··· 2325 repo := parsed.Authority().String() 2326 rkey := parsed.RecordKey().String() 2327 2328 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2329 Collection: collection, 2330 Repo: repo, 2331 Rkey: rkey, ··· 2334 } 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 ··· 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 } ··· 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 } ··· 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 ··· 2445 head, _ = url.PathUnescape(head) 2446 2447 if base == "" || head == "" { 2448 - log.Printf("invalid comparison") 2449 rp.pages.Error404(w) 2450 return 2451 } ··· 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
··· 7 "errors" 8 "fmt" 9 "io" 10 "log/slog" 11 "net/http" 12 "net/url" ··· 16 "strings" 17 "time" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" ··· 36 "tangled.org/core/types" 37 "tangled.org/core/xrpc/serviceauth" 38 39 + comatproto "github.com/bluesky-social/indigo/api/atproto" 40 + atpclient "github.com/bluesky-social/indigo/atproto/client" 41 + "github.com/bluesky-social/indigo/atproto/syntax" 42 + lexutil "github.com/bluesky-social/indigo/lex/util" 43 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 securejoin "github.com/cyphar/filepath-securejoin" 45 "github.com/go-chi/chi/v5" 46 "github.com/go-git/go-git/v5/plumbing" 47 ) 48 49 type Repo struct { ··· 89 } 90 91 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 + l := rp.logger.With("handler", "DownloadArchive") 93 + 94 ref := chi.URLParam(r, "ref") 95 ref, _ = url.PathUnescape(ref) 96 97 f, err := rp.repoResolver.Resolve(r) 98 if err != nil { 99 + l.Error("failed to get repo and knot", "err", err) 100 return 101 } 102 ··· 112 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 113 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 114 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 115 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 116 rp.pages.Error503(w) 117 return 118 } ··· 129 } 130 131 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 + l := rp.logger.With("handler", "RepoLog") 133 + 134 f, err := rp.repoResolver.Resolve(r) 135 if err != nil { 136 + l.Error("failed to fully resolve repo", "err", err) 137 return 138 } 139 ··· 168 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 169 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 170 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 171 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 172 rp.pages.Error503(w) 173 return 174 } 175 176 var xrpcResp types.RepoLogResponse 177 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 178 + l.Error("failed to decode XRPC response", "err", err) 179 rp.pages.Error503(w) 180 return 181 } 182 183 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 185 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 186 rp.pages.Error503(w) 187 return 188 } ··· 192 var tagResp types.RepoTagsResponse 193 if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 194 for _, tag := range tagResp.Tags { 195 + hash := tag.Hash 196 + if tag.Tag != nil { 197 + hash = tag.Tag.Target.String() 198 + } 199 + tagMap[hash] = append(tagMap[hash], tag.Name) 200 } 201 } 202 } 203 204 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 205 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 206 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 207 rp.pages.Error503(w) 208 return 209 } ··· 221 222 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 223 if err != nil { 224 + l.Error("failed to fetch email to did mapping", "err", err) 225 } 226 227 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 228 if err != nil { 229 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 230 } 231 232 repoInfo := f.RepoInfo(user) ··· 237 } 238 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 239 if err != nil { 240 + l.Error("failed to getPipelineStatuses", "err", err) 241 // non-fatal 242 } 243 ··· 253 } 254 255 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 + l := rp.logger.With("handler", "RepoDescriptionEdit") 257 + 258 f, err := rp.repoResolver.Resolve(r) 259 if err != nil { 260 + l.Error("failed to get repo and knot", "err", err) 261 w.WriteHeader(http.StatusBadRequest) 262 return 263 } ··· 269 } 270 271 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 + l := rp.logger.With("handler", "RepoDescription") 273 + 274 f, err := rp.repoResolver.Resolve(r) 275 if err != nil { 276 + l.Error("failed to get repo and knot", "err", err) 277 w.WriteHeader(http.StatusBadRequest) 278 return 279 } ··· 281 repoAt := f.RepoAt() 282 rkey := repoAt.RecordKey().String() 283 if rkey == "" { 284 + l.Error("invalid aturi for repo", "err", err) 285 w.WriteHeader(http.StatusInternalServerError) 286 return 287 } ··· 298 newDescription := r.FormValue("description") 299 client, err := rp.oauth.AuthorizedClient(r) 300 if err != nil { 301 + l.Error("failed to get client") 302 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 303 return 304 } ··· 306 // optimistic update 307 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 308 if err != nil { 309 + l.Error("failed to perform update-description query", "err", err) 310 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 311 return 312 } ··· 318 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 319 // 320 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 321 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 322 if err != nil { 323 // failed to get record 324 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 325 return 326 } 327 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 328 Collection: tangled.RepoNSID, 329 Repo: newRepo.Did, 330 Rkey: newRepo.Rkey, ··· 335 }) 336 337 if err != nil { 338 + l.Error("failed to perferom update-description query", "err", err) 339 // failed to get record 340 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 341 return ··· 352 } 353 354 func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 + l := rp.logger.With("handler", "RepoCommit") 356 + 357 f, err := rp.repoResolver.Resolve(r) 358 if err != nil { 359 + l.Error("failed to fully resolve repo", "err", err) 360 return 361 } 362 ref := chi.URLParam(r, "ref") ··· 384 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 385 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 386 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 387 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 388 rp.pages.Error503(w) 389 return 390 } 391 392 var result types.RepoCommitResponse 393 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 394 + l.Error("failed to decode XRPC response", "err", err) 395 rp.pages.Error503(w) 396 return 397 } 398 399 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 400 if err != nil { 401 + l.Error("failed to get email to did mapping", "err", err) 402 } 403 404 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 405 if err != nil { 406 + l.Error("failed to GetVerifiedCommits", "err", err) 407 } 408 409 user := rp.oauth.GetUser(r) 410 repoInfo := f.RepoInfo(user) 411 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 412 if err != nil { 413 + l.Error("failed to getPipelineStatuses", "err", err) 414 // non-fatal 415 } 416 var pipeline *models.Pipeline ··· 430 } 431 432 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 + l := rp.logger.With("handler", "RepoTree") 434 + 435 f, err := rp.repoResolver.Resolve(r) 436 if err != nil { 437 + l.Error("failed to fully resolve repo", "err", err) 438 return 439 } 440 ··· 459 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 460 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 461 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 462 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 463 rp.pages.Error503(w) 464 return 465 } 466 467 // Convert XRPC response to internal types.RepoTreeResponse 468 files := make([]types.NiceTree, len(xrpcResp.Files)) 469 for i, xrpcFile := range xrpcResp.Files { ··· 499 if xrpcResp.Dotdot != nil { 500 result.DotDot = *xrpcResp.Dotdot 501 } 502 + if xrpcResp.Readme != nil { 503 + result.ReadmeFileName = xrpcResp.Readme.Filename 504 + result.Readme = xrpcResp.Readme.Contents 505 + } 506 507 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 508 // so we can safely redirect to the "parent" (which is the same file). ··· 529 BreadCrumbs: breadcrumbs, 530 TreePath: treePath, 531 RepoInfo: f.RepoInfo(user), 532 RepoTreeResponse: result, 533 }) 534 } 535 536 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 + l := rp.logger.With("handler", "RepoTags") 538 + 539 f, err := rp.repoResolver.Resolve(r) 540 if err != nil { 541 + l.Error("failed to get repo and knot", "err", err) 542 return 543 } 544 ··· 554 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 555 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 556 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 557 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 558 rp.pages.Error503(w) 559 return 560 } 561 562 var result types.RepoTagsResponse 563 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 564 + l.Error("failed to decode XRPC response", "err", err) 565 rp.pages.Error503(w) 566 return 567 } 568 569 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 570 if err != nil { 571 + l.Error("failed grab artifacts", "err", err) 572 return 573 } 574 ··· 605 } 606 607 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 + l := rp.logger.With("handler", "RepoBranches") 609 + 610 f, err := rp.repoResolver.Resolve(r) 611 if err != nil { 612 + l.Error("failed to get repo and knot", "err", err) 613 return 614 } 615 ··· 625 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 626 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 627 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 628 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 629 rp.pages.Error503(w) 630 return 631 } 632 633 var result types.RepoBranchesResponse 634 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 635 + l.Error("failed to decode XRPC response", "err", err) 636 rp.pages.Error503(w) 637 return 638 } ··· 647 }) 648 } 649 650 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 651 + l := rp.logger.With("handler", "DeleteBranch") 652 + 653 + f, err := rp.repoResolver.Resolve(r) 654 + if err != nil { 655 + l.Error("failed to get repo and knot", "err", err) 656 + return 657 + } 658 + 659 + noticeId := "delete-branch-error" 660 + fail := func(msg string, err error) { 661 + l.Error(msg, "err", err) 662 + rp.pages.Notice(w, noticeId, msg) 663 + } 664 + 665 + branch := r.FormValue("branch") 666 + if branch == "" { 667 + fail("No branch provided.", nil) 668 + return 669 + } 670 + 671 + client, err := rp.oauth.ServiceClient( 672 + r, 673 + oauth.WithService(f.Knot), 674 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 675 + oauth.WithDev(rp.config.Core.Dev), 676 + ) 677 + if err != nil { 678 + fail("Failed to connect to knotserver", nil) 679 + return 680 + } 681 + 682 + err = tangled.RepoDeleteBranch( 683 + r.Context(), 684 + client, 685 + &tangled.RepoDeleteBranch_Input{ 686 + Branch: branch, 687 + Repo: f.RepoAt().String(), 688 + }, 689 + ) 690 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 691 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 692 + return 693 + } 694 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 695 + 696 + rp.pages.HxRefresh(w) 697 + } 698 + 699 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 + l := rp.logger.With("handler", "RepoBlob") 701 + 702 f, err := rp.repoResolver.Resolve(r) 703 if err != nil { 704 + l.Error("failed to get repo and knot", "err", err) 705 return 706 } 707 ··· 723 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 724 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 725 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 726 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 727 rp.pages.Error503(w) 728 return 729 } ··· 823 } 824 825 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 + l := rp.logger.With("handler", "RepoBlobRaw") 827 + 828 f, err := rp.repoResolver.Resolve(r) 829 if err != nil { 830 + l.Error("failed to get repo and knot", "err", err) 831 w.WriteHeader(http.StatusBadRequest) 832 return 833 } ··· 859 860 req, err := http.NewRequest("GET", blobURL, nil) 861 if err != nil { 862 + l.Error("failed to create request", "err", err) 863 return 864 } 865 ··· 871 client := &http.Client{} 872 resp, err := client.Do(req) 873 if err != nil { 874 + l.Error("failed to reach knotserver", "err", err) 875 rp.pages.Error503(w) 876 return 877 } ··· 884 } 885 886 if resp.StatusCode != http.StatusOK { 887 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 888 w.WriteHeader(resp.StatusCode) 889 _, _ = io.Copy(w, resp.Body) 890 return ··· 893 contentType := resp.Header.Get("Content-Type") 894 body, err := io.ReadAll(resp.Body) 895 if err != nil { 896 + l.Error("error reading response body from knotserver", "err", err) 897 w.WriteHeader(http.StatusInternalServerError) 898 return 899 } ··· 935 user := rp.oauth.GetUser(r) 936 l := rp.logger.With("handler", "EditSpindle") 937 l = l.With("did", user.Did) 938 939 errorId := "operation-error" 940 fail := func(msg string, err error) { ··· 987 return 988 } 989 990 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 991 if err != nil { 992 fail("Failed to update spindle, no record found on PDS.", err) 993 return 994 } 995 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 996 Collection: tangled.RepoNSID, 997 Repo: newRepo.Did, 998 Rkey: newRepo.Rkey, ··· 1022 user := rp.oauth.GetUser(r) 1023 l := rp.logger.With("handler", "AddLabel") 1024 l = l.With("did", user.Did) 1025 1026 f, err := rp.repoResolver.Resolve(r) 1027 if err != nil { ··· 1090 1091 // emit a labelRecord 1092 labelRecord := label.AsRecord() 1093 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1094 Collection: tangled.LabelDefinitionNSID, 1095 Repo: label.Did, 1096 Rkey: label.Rkey, ··· 1113 newRepo.Labels = append(newRepo.Labels, aturi) 1114 repoRecord := newRepo.AsRecord() 1115 1116 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1117 if err != nil { 1118 fail("Failed to update labels, no record found on PDS.", err) 1119 return 1120 } 1121 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1122 Collection: tangled.RepoNSID, 1123 Repo: newRepo.Did, 1124 Rkey: newRepo.Rkey, ··· 1181 user := rp.oauth.GetUser(r) 1182 l := rp.logger.With("handler", "DeleteLabel") 1183 l = l.With("did", user.Did) 1184 1185 f, err := rp.repoResolver.Resolve(r) 1186 if err != nil { ··· 1210 } 1211 1212 // delete label record from PDS 1213 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1214 Collection: tangled.LabelDefinitionNSID, 1215 Repo: label.Did, 1216 Rkey: label.Rkey, ··· 1232 newRepo.Labels = updated 1233 repoRecord := newRepo.AsRecord() 1234 1235 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1236 if err != nil { 1237 fail("Failed to update labels, no record found on PDS.", err) 1238 return 1239 } 1240 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1241 Collection: tangled.RepoNSID, 1242 Repo: newRepo.Did, 1243 Rkey: newRepo.Rkey, ··· 1289 user := rp.oauth.GetUser(r) 1290 l := rp.logger.With("handler", "SubscribeLabel") 1291 l = l.With("did", user.Did) 1292 1293 f, err := rp.repoResolver.Resolve(r) 1294 if err != nil { ··· 1329 return 1330 } 1331 1332 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1333 if err != nil { 1334 fail("Failed to update labels, no record found on PDS.", err) 1335 return 1336 } 1337 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1338 Collection: tangled.RepoNSID, 1339 Repo: newRepo.Did, 1340 Rkey: newRepo.Rkey, ··· 1375 user := rp.oauth.GetUser(r) 1376 l := rp.logger.With("handler", "UnsubscribeLabel") 1377 l = l.With("did", user.Did) 1378 1379 f, err := rp.repoResolver.Resolve(r) 1380 if err != nil { ··· 1417 return 1418 } 1419 1420 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1421 if err != nil { 1422 fail("Failed to update labels, no record found on PDS.", err) 1423 return 1424 } 1425 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1426 Collection: tangled.RepoNSID, 1427 Repo: newRepo.Did, 1428 Rkey: newRepo.Rkey, ··· 1468 db.FilterContains("scope", subject.Collection().String()), 1469 ) 1470 if err != nil { 1471 + l.Error("failed to fetch label defs", "err", err) 1472 return 1473 } 1474 ··· 1479 1480 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1481 if err != nil { 1482 + l.Error("failed to build label state", "err", err) 1483 return 1484 } 1485 state := states[subject] ··· 1516 db.FilterContains("scope", subject.Collection().String()), 1517 ) 1518 if err != nil { 1519 + l.Error("failed to fetch labels", "err", err) 1520 return 1521 } 1522 ··· 1527 1528 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1529 if err != nil { 1530 + l.Error("failed to build label state", "err", err) 1531 return 1532 } 1533 state := states[subject] ··· 1546 user := rp.oauth.GetUser(r) 1547 l := rp.logger.With("handler", "AddCollaborator") 1548 l = l.With("did", user.Did) 1549 1550 f, err := rp.repoResolver.Resolve(r) 1551 if err != nil { ··· 1592 currentUser := rp.oauth.GetUser(r) 1593 rkey := tid.TID() 1594 createdAt := time.Now() 1595 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1596 Collection: tangled.RepoCollaboratorNSID, 1597 Repo: currentUser.Did, 1598 Rkey: rkey, ··· 1674 1675 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1676 user := rp.oauth.GetUser(r) 1677 + l := rp.logger.With("handler", "DeleteRepo") 1678 1679 noticeId := "operation-error" 1680 f, err := rp.repoResolver.Resolve(r) 1681 if err != nil { 1682 + l.Error("failed to get repo and knot", "err", err) 1683 return 1684 } 1685 1686 // remove record from pds 1687 + atpClient, err := rp.oauth.AuthorizedClient(r) 1688 if err != nil { 1689 + l.Error("failed to get authorized client", "err", err) 1690 return 1691 } 1692 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1693 Collection: tangled.RepoNSID, 1694 Repo: user.Did, 1695 Rkey: f.Rkey, 1696 }) 1697 if err != nil { 1698 + l.Error("failed to delete record", "err", err) 1699 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1700 return 1701 } 1702 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1703 1704 client, err := rp.oauth.ServiceClient( 1705 r, ··· 1708 oauth.WithDev(rp.config.Core.Dev), 1709 ) 1710 if err != nil { 1711 + l.Error("failed to connect to knot server", "err", err) 1712 return 1713 } 1714 ··· 1725 rp.pages.Notice(w, noticeId, err.Error()) 1726 return 1727 } 1728 + l.Info("deleted repo from knot") 1729 1730 tx, err := rp.db.BeginTx(r.Context(), nil) 1731 if err != nil { 1732 + l.Error("failed to start tx") 1733 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1734 return 1735 } ··· 1737 tx.Rollback() 1738 err = rp.enforcer.E.LoadPolicy() 1739 if err != nil { 1740 + l.Error("failed to rollback policies") 1741 } 1742 }() 1743 ··· 1751 did := c[0] 1752 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1753 } 1754 + l.Info("removed collaborators") 1755 1756 // remove repo RBAC 1757 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1766 rp.pages.Notice(w, noticeId, "Failed to update appview") 1767 return 1768 } 1769 + l.Info("removed repo from db") 1770 1771 err = tx.Commit() 1772 if err != nil { 1773 + l.Error("failed to commit changes", "err", err) 1774 http.Error(w, err.Error(), http.StatusInternalServerError) 1775 return 1776 } 1777 1778 err = rp.enforcer.E.SavePolicy() 1779 if err != nil { 1780 + l.Error("failed to update ACLs", "err", err) 1781 http.Error(w, err.Error(), http.StatusInternalServerError) 1782 return 1783 } ··· 1786 } 1787 1788 func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 + l := rp.logger.With("handler", "SetDefaultBranch") 1790 + 1791 f, err := rp.repoResolver.Resolve(r) 1792 if err != nil { 1793 + l.Error("failed to get repo and knot", "err", err) 1794 return 1795 } 1796 ··· 1808 oauth.WithDev(rp.config.Core.Dev), 1809 ) 1810 if err != nil { 1811 + l.Error("failed to connect to knot server", "err", err) 1812 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1813 return 1814 } ··· 1822 }, 1823 ) 1824 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1825 + l.Error("xrpc failed", "err", xe) 1826 rp.pages.Notice(w, noticeId, err.Error()) 1827 return 1828 } ··· 1833 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1834 user := rp.oauth.GetUser(r) 1835 l := rp.logger.With("handler", "Secrets") 1836 l = l.With("did", user.Did) 1837 1838 f, err := rp.repoResolver.Resolve(r) 1839 if err != nil { 1840 + l.Error("failed to get repo and knot", "err", err) 1841 return 1842 } 1843 1844 if f.Spindle == "" { 1845 + l.Error("empty spindle cannot add/rm secret", "err", err) 1846 return 1847 } 1848 ··· 1859 oauth.WithDev(rp.config.Core.Dev), 1860 ) 1861 if err != nil { 1862 + l.Error("failed to create spindle client", "err", err) 1863 return 1864 } 1865 ··· 1945 } 1946 1947 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 + l := rp.logger.With("handler", "generalSettings") 1949 + 1950 f, err := rp.repoResolver.Resolve(r) 1951 user := rp.oauth.GetUser(r) 1952 ··· 1962 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1963 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1964 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1965 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1966 rp.pages.Error503(w) 1967 return 1968 } 1969 1970 var result types.RepoBranchesResponse 1971 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1972 + l.Error("failed to decode XRPC response", "err", err) 1973 rp.pages.Error503(w) 1974 return 1975 } 1976 1977 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1978 if err != nil { 1979 + l.Error("failed to fetch labels", "err", err) 1980 rp.pages.Error503(w) 1981 return 1982 } 1983 1984 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1985 if err != nil { 1986 + l.Error("failed to fetch labels", "err", err) 1987 rp.pages.Error503(w) 1988 return 1989 } ··· 2031 } 2032 2033 func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 + l := rp.logger.With("handler", "accessSettings") 2035 + 2036 f, err := rp.repoResolver.Resolve(r) 2037 user := rp.oauth.GetUser(r) 2038 2039 repoCollaborators, err := f.Collaborators(r.Context()) 2040 if err != nil { 2041 + l.Error("failed to get collaborators", "err", err) 2042 } 2043 2044 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ ··· 2051 } 2052 2053 func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 + l := rp.logger.With("handler", "pipelineSettings") 2055 + 2056 f, err := rp.repoResolver.Resolve(r) 2057 user := rp.oauth.GetUser(r) 2058 2059 // all spindles that the repo owner is a member of 2060 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2061 if err != nil { 2062 + l.Error("failed to fetch spindles", "err", err) 2063 return 2064 } 2065 ··· 2072 oauth.WithExp(60), 2073 oauth.WithDev(rp.config.Core.Dev), 2074 ); err != nil { 2075 + l.Error("failed to create spindle client", "err", err) 2076 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2077 + l.Error("failed to fetch secrets", "err", err) 2078 } else { 2079 secrets = resp.Secrets 2080 } ··· 2114 } 2115 2116 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 + l := rp.logger.With("handler", "SyncRepoFork") 2118 + 2119 ref := chi.URLParam(r, "ref") 2120 ref, _ = url.PathUnescape(ref) 2121 2122 user := rp.oauth.GetUser(r) 2123 f, err := rp.repoResolver.Resolve(r) 2124 if err != nil { 2125 + l.Error("failed to resolve source repo", "err", err) 2126 return 2127 } 2128 ··· 2166 } 2167 2168 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2169 + l := rp.logger.With("handler", "ForkRepo") 2170 + 2171 user := rp.oauth.GetUser(r) 2172 f, err := rp.repoResolver.Resolve(r) 2173 if err != nil { 2174 + l.Error("failed to resolve source repo", "err", err) 2175 return 2176 } 2177 ··· 2207 } 2208 2209 // choose a name for a fork 2210 + forkName := r.FormValue("repo_name") 2211 + if forkName == "" { 2212 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2213 + return 2214 + } 2215 + 2216 // this check is *only* to see if the forked repo name already exists 2217 // in the user's account. 2218 existingRepo, err := db.GetRepo( 2219 rp.db, 2220 db.FilterEq("did", user.Did), 2221 + db.FilterEq("name", forkName), 2222 ) 2223 if err != nil { 2224 + if !errors.Is(err, sql.ErrNoRows) { 2225 + l.Error("error fetching existing repo from db", "err", err) 2226 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2227 return 2228 } 2229 } else if existingRepo != nil { 2230 + // repo with this name already exists 2231 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2232 + return 2233 } 2234 l = l.With("forkName", forkName) 2235 ··· 2251 Knot: targetKnot, 2252 Rkey: rkey, 2253 Source: sourceAt, 2254 + Description: f.Repo.Description, 2255 Created: time.Now(), 2256 Labels: models.DefaultLabelDefs(), 2257 } 2258 record := repo.AsRecord() 2259 2260 + atpClient, err := rp.oauth.AuthorizedClient(r) 2261 if err != nil { 2262 l.Error("failed to create xrpcclient", "err", err) 2263 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2264 return 2265 } 2266 2267 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2268 Collection: tangled.RepoNSID, 2269 Repo: user.Did, 2270 Rkey: rkey, ··· 2296 rollback := func() { 2297 err1 := tx.Rollback() 2298 err2 := rp.enforcer.E.LoadPolicy() 2299 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2300 2301 // ignore txn complete errors, this is okay 2302 if errors.Is(err1, sql.ErrTxDone) { ··· 2337 2338 err = db.AddRepo(tx, repo) 2339 if err != nil { 2340 + l.Error("failed to AddRepo", "err", err) 2341 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2342 return 2343 } ··· 2346 p, _ := securejoin.SecureJoin(user.Did, forkName) 2347 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2348 if err != nil { 2349 + l.Error("failed to add ACLs", "err", err) 2350 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2351 return 2352 } 2353 2354 err = tx.Commit() 2355 if err != nil { 2356 + l.Error("failed to commit changes", "err", err) 2357 http.Error(w, err.Error(), http.StatusInternalServerError) 2358 return 2359 } 2360 2361 err = rp.enforcer.E.SavePolicy() 2362 if err != nil { 2363 + l.Error("failed to update ACLs", "err", err) 2364 http.Error(w, err.Error(), http.StatusInternalServerError) 2365 return 2366 } ··· 2369 aturi = "" 2370 2371 rp.notifier.NewRepo(r.Context(), repo) 2372 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2373 } 2374 } 2375 2376 // this is used to rollback changes made to the PDS 2377 // 2378 // it is a no-op if the provided ATURI is empty 2379 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2380 if aturi == "" { 2381 return nil 2382 } ··· 2387 repo := parsed.Authority().String() 2388 rkey := parsed.RecordKey().String() 2389 2390 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2391 Collection: collection, 2392 Repo: repo, 2393 Rkey: rkey, ··· 2396 } 2397 2398 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 + l := rp.logger.With("handler", "RepoCompareNew") 2400 + 2401 user := rp.oauth.GetUser(r) 2402 f, err := rp.repoResolver.Resolve(r) 2403 if err != nil { 2404 + l.Error("failed to get repo and knot", "err", err) 2405 return 2406 } 2407 ··· 2417 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2418 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2419 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2420 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2421 rp.pages.Error503(w) 2422 return 2423 } 2424 2425 var branchResult types.RepoBranchesResponse 2426 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2427 + l.Error("failed to decode XRPC branches response", "err", err) 2428 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2429 return 2430 } ··· 2454 2455 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2457 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2458 rp.pages.Error503(w) 2459 return 2460 } 2461 2462 var tags types.RepoTagsResponse 2463 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2464 + l.Error("failed to decode XRPC tags response", "err", err) 2465 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2466 return 2467 } ··· 2479 } 2480 2481 func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 + l := rp.logger.With("handler", "RepoCompare") 2483 + 2484 user := rp.oauth.GetUser(r) 2485 f, err := rp.repoResolver.Resolve(r) 2486 if err != nil { 2487 + l.Error("failed to get repo and knot", "err", err) 2488 return 2489 } 2490 ··· 2511 head, _ = url.PathUnescape(head) 2512 2513 if base == "" || head == "" { 2514 + l.Error("invalid comparison") 2515 rp.pages.Error404(w) 2516 return 2517 } ··· 2529 2530 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2531 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2532 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2533 rp.pages.Error503(w) 2534 return 2535 } 2536 2537 var branches types.RepoBranchesResponse 2538 if err := json.Unmarshal(branchBytes, &branches); err != nil { 2539 + l.Error("failed to decode XRPC branches response", "err", err) 2540 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2541 return 2542 } 2543 2544 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2545 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2546 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2547 rp.pages.Error503(w) 2548 return 2549 } 2550 2551 var tags types.RepoTagsResponse 2552 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2553 + l.Error("failed to decode XRPC tags response", "err", err) 2554 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2555 return 2556 } 2557 2558 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2560 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2561 rp.pages.Error503(w) 2562 return 2563 } 2564 2565 var formatPatch types.RepoFormatPatchResponse 2566 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2567 + l.Error("failed to decode XRPC compare response", "err", err) 2568 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2569 return 2570 } 2571 2572 + var diff types.NiceDiff 2573 + if formatPatch.CombinedPatchRaw != "" { 2574 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2575 + } else { 2576 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2577 + } 2578 2579 repoinfo := f.RepoInfo(user) 2580
+2
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) { ··· 18 }) 19 r.Get("/commit/{ref}", rp.RepoCommit) 20 r.Get("/branches", rp.RepoBranches) 21 r.Route("/tags", func(r chi.Router) { 22 r.Get("/", rp.RepoTags) 23 r.Route("/{tag}", func(r chi.Router) {
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 r.Get("/feed.atom", rp.RepoAtomFeed) 15 r.Get("/commits/{ref}", rp.RepoLog) 16 r.Route("/tree/{ref}", func(r chi.Router) { ··· 19 }) 20 r.Get("/commit/{ref}", rp.RepoCommit) 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 r.Get("/", rp.RepoTags) 25 r.Route("/{tag}", func(r chi.Router) {
+54 -2
appview/settings/settings.go
··· 22 "tangled.org/core/tid" 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/gliderlabs/ssh" 27 "github.com/google/uuid" ··· 41 {"Name": "profile", "Icon": "user"}, 42 {"Name": "keys", "Icon": "key"}, 43 {"Name": "emails", "Icon": "mail"}, 44 } 45 ) 46 ··· 66 r.Get("/verify", s.emailsVerify) 67 r.Post("/verify/resend", s.emailsVerifyResend) 68 r.Post("/primary", s.emailsPrimary) 69 }) 70 71 return r ··· 79 Tabs: settingsTabs, 80 Tab: "profile", 81 }) 82 } 83 84 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 419 } 420 421 // store in pds too 422 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 423 Collection: tangled.PublicKeyNSID, 424 Repo: did, 425 Rkey: rkey, ··· 476 477 if rkey != "" { 478 // remove from pds too 479 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 480 Collection: tangled.PublicKeyNSID, 481 Repo: did, 482 Rkey: rkey,
··· 22 "tangled.org/core/tid" 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 lexutil "github.com/bluesky-social/indigo/lex/util" 27 "github.com/gliderlabs/ssh" 28 "github.com/google/uuid" ··· 42 {"Name": "profile", "Icon": "user"}, 43 {"Name": "keys", "Icon": "key"}, 44 {"Name": "emails", "Icon": "mail"}, 45 + {"Name": "notifications", "Icon": "bell"}, 46 } 47 ) 48 ··· 68 r.Get("/verify", s.emailsVerify) 69 r.Post("/verify/resend", s.emailsVerifyResend) 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) 76 }) 77 78 return r ··· 86 Tabs: settingsTabs, 87 Tab: "profile", 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 + EmailNotifications: r.FormValue("email_notifications") == "on", 124 + } 125 + 126 + err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 127 + if err != nil { 128 + log.Printf("failed to update notification preferences: %s", err) 129 + s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 130 + return 131 + } 132 + 133 + s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.") 134 } 135 136 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 471 } 472 473 // store in pds too 474 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 475 Collection: tangled.PublicKeyNSID, 476 Repo: did, 477 Rkey: rkey, ··· 528 529 if rkey != "" { 530 // remove from pds too 531 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 532 Collection: tangled.PublicKeyNSID, 533 Repo: did, 534 Rkey: rkey,
+18
appview/signup/requests.go
··· 102 103 return result.DID, nil 104 }
··· 102 103 return result.DID, nil 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 3 import ( 4 "bufio" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "os" 9 "strings" 10 ··· 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/pages" 19 "tangled.org/core/appview/state/userutil" 20 - "tangled.org/core/appview/xrpcclient" 21 "tangled.org/core/idresolver" 22 ) 23 ··· 26 db *db.DB 27 cf *dns.Cloudflare 28 posthog posthog.Client 29 - xrpc *xrpcclient.Client 30 idResolver *idresolver.Resolver 31 pages *pages.Pages 32 l *slog.Logger ··· 61 disallowed := make(map[string]bool) 62 63 if filepath == "" { 64 - logger.Debug("no disallowed nicknames file configured") 65 return disallowed 66 } 67 ··· 116 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 switch r.Method { 118 case http.MethodGet: 119 - s.pages.Signup(w) 120 case http.MethodPost: 121 if s.cf == nil { 122 http.Error(w, "signup is disabled", http.StatusFailedDependency) 123 } 124 emailId := r.FormValue("email") 125 126 noticeId := "signup-msg" 127 if !email.IsValidEmail(emailId) { 128 s.pages.Notice(w, noticeId, "Invalid email address.") 129 return ··· 204 return 205 } 206 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 if s.cf == nil { 215 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 216 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 217 return 218 } 219 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 - }) 227 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.") 230 return 231 } 232 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 243 } 244 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)) 248 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 256 } 257 }
··· 2 3 import ( 4 "bufio" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 "fmt" 9 "log/slog" 10 "net/http" 11 + "net/url" 12 "os" 13 "strings" 14 ··· 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/state/userutil" 24 "tangled.org/core/idresolver" 25 ) 26 ··· 29 db *db.DB 30 cf *dns.Cloudflare 31 posthog posthog.Client 32 idResolver *idresolver.Resolver 33 pages *pages.Pages 34 l *slog.Logger ··· 63 disallowed := make(map[string]bool) 64 65 if filepath == "" { 66 + logger.Warn("no disallowed nicknames file configured") 67 return disallowed 68 } 69 ··· 118 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 119 switch r.Method { 120 case http.MethodGet: 121 + s.pages.Signup(w, pages.SignupParams{ 122 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 123 + }) 124 case http.MethodPost: 125 if s.cf == nil { 126 http.Error(w, "signup is disabled", http.StatusFailedDependency) 127 + return 128 } 129 emailId := r.FormValue("email") 130 + cfToken := r.FormValue("cf-turnstile-response") 131 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 + 140 if !email.IsValidEmail(emailId) { 141 s.pages.Notice(w, noticeId, "Invalid email address.") 142 return ··· 217 return 218 } 219 220 if s.cf == nil { 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 223 return 224 } 225 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 228 if err != nil { 229 + // Error already logged and notice already sent 230 return 231 } 232 + } 233 + } 234 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])) 354 } 355 + } else { 356 + data.Set("remoteip", r.RemoteAddr) 357 + } 358 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() 364 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) 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 376 }
+5 -5
appview/spindles/spindles.go
··· 189 return 190 } 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
··· 189 return 190 } 191 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
+3 -2
appview/state/follow.go
··· 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 if err != nil { 28 log.Println("failed to follow, invalid did") 29 } 30 31 if currentUser.Did == subjectIdent.DID.String() { ··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
··· 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 if err != nil { 28 log.Println("failed to follow, invalid did") 29 + return 30 } 31 32 if currentUser.Did == subjectIdent.DID.String() { ··· 44 case http.MethodPost: 45 createdAt := time.Now().Format(time.RFC3339) 46 rkey := tid.TID() 47 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 Collection: tangled.GraphFollowNSID, 49 Repo: currentUser.Did, 50 Rkey: rkey, ··· 89 return 90 } 91 92 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 93 Collection: tangled.GraphFollowNSID, 94 Repo: currentUser.Did, 95 Rkey: follow.Rkey,
+151
appview/state/gfi.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+17 -2
appview/state/knotstream.go
··· 25 ) 26 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 knots, err := db.GetRegistrations( 29 d, 30 db.FilterIsNot("registered", "null"), ··· 39 srcs[s] = struct{}{} 40 } 41 42 - logger := log.New("knotstream") 43 cache := cache.New(c.Redis.Addr) 44 cursorStore := cursor.NewRedisCursorStore(cache) 45 ··· 172 }) 173 } 174 175 - return db.InsertRepoLanguages(d, langs) 176 } 177 178 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
··· 25 ) 26 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 + logger := log.FromContext(ctx) 29 + logger = log.SubLogger(logger, "knotstream") 30 + 31 knots, err := db.GetRegistrations( 32 d, 33 db.FilterIsNot("registered", "null"), ··· 42 srcs[s] = struct{}{} 43 } 44 45 cache := cache.New(c.Redis.Addr) 46 cursorStore := cursor.NewRedisCursorStore(cache) 47 ··· 174 }) 175 } 176 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() 191 } 192 193 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+66
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 + s.pages.Login(w, pages.LoginParams{ 18 + ReturnUrl: returnURL, 19 + }) 20 + case http.MethodPost: 21 + handle := r.FormValue("handle") 22 + 23 + // when users copy their handle from bsky.app, it tends to have these characters around it: 24 + // 25 + // @nelind.dk: 26 + // \u202a ensures that the handle is always rendered left to right and 27 + // \u202c reverts that so the rest of the page renders however it should 28 + handle = strings.TrimPrefix(handle, "\u202a") 29 + handle = strings.TrimSuffix(handle, "\u202c") 30 + 31 + // `@` is harmless 32 + handle = strings.TrimPrefix(handle, "@") 33 + 34 + // basic handle validation 35 + if !strings.Contains(handle, ".") { 36 + l.Error("invalid handle format", "raw", handle) 37 + s.pages.Notice( 38 + w, 39 + "login-msg", 40 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 41 + ) 42 + return 43 + } 44 + 45 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 46 + if err != nil { 47 + http.Error(w, err.Error(), http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + s.pages.HxRedirect(w, redirectURL) 52 + } 53 + } 54 + 55 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 56 + l := s.logger.With("handler", "Logout") 57 + 58 + err := s.oauth.DeleteSession(w, r) 59 + if err != nil { 60 + l.Error("failed to logout", "err", err) 61 + } else { 62 + l.Info("logged out successfully") 63 + } 64 + 65 + s.pages.HxRedirect(w, "/login") 66 + }
+3 -2
appview/state/profile.go
··· 336 profile.Did = did 337 } 338 followCards[i] = pages.FollowCard{ 339 UserDid: did, 340 FollowStatus: followStatus, 341 FollowersCount: followStats.Followers, ··· 633 vanityStats = append(vanityStats, string(v.Kind)) 634 } 635 636 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 var cid *string 638 if ex != nil { 639 cid = ex.Cid 640 } 641 642 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 Collection: tangled.ActorProfileNSID, 644 Repo: user.Did, 645 Rkey: "self",
··· 336 profile.Did = did 337 } 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 340 UserDid: did, 341 FollowStatus: followStatus, 342 FollowersCount: followStats.Followers, ··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 - Count: count, 84 IsReacted: true, 85 }) 86 ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedReactionNSID, 97 Repo: currentUser.Did, 98 Rkey: reaction.Rkey, ··· 109 // this is not an issue, the firehose event might have already done this 110 } 111 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 return 116 } 117 118 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 ThreadAt: subjectUri, 120 Kind: reactionKind, 121 - Count: count, 122 IsReacted: false, 123 }) 124
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 if err != nil { 75 + log.Println("failed to get reactions for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 85 IsReacted: true, 86 }) 87 ··· 93 return 94 } 95 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 Collection: tangled.FeedReactionNSID, 98 Repo: currentUser.Did, 99 Rkey: reaction.Rkey, ··· 110 // this is not an issue, the firehose event might have already done this 111 } 112 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 114 if err != nil { 115 + log.Println("failed to get reactions for ", subjectUri) 116 return 117 } 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 ThreadAt: subjectUri, 121 Kind: reactionKind, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 124 IsReacted: false, 125 }) 126
+79 -23
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 "tangled.org/core/appview/issues" 10 "tangled.org/core/appview/knots" 11 "tangled.org/core/appview/labels" 12 "tangled.org/core/appview/middleware" 13 - oauthhandler "tangled.org/core/appview/oauth/handler" 14 "tangled.org/core/appview/pipelines" 15 "tangled.org/core/appview/pulls" 16 "tangled.org/core/appview/repo" ··· 35 36 router.Get("/favicon.svg", s.Favicon) 37 router.Get("/favicon.ico", s.Favicon) 38 39 userRouter := s.UserRouter(&middleware) 40 standardRouter := s.StandardRouter(&middleware) ··· 115 116 r.Get("/", s.HomeOrTimeline) 117 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 119 120 // special-case handler for serving tangled.org/core 121 r.Get("/core", s.Core()) 122 123 r.Route("/repo", func(r chi.Router) { 124 r.Route("/new", func(r chi.Router) { 125 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 128 }) 129 // r.Post("/import", s.ImportRepo) 130 }) 131 132 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 133 r.Post("/", s.Follow) ··· 156 r.Mount("/strings", s.StringsRouter(mw)) 157 r.Mount("/knots", s.KnotsRouter()) 158 r.Mount("/spindles", s.SpindlesRouter()) 159 r.Mount("/signup", s.SignupRouter()) 160 - r.Mount("/", s.OAuthRouter()) 161 162 r.Get("/keys/{user}", s.Keys) 163 r.Get("/terms", s.TermsOfService) 164 r.Get("/privacy", s.PrivacyPolicy) 165 166 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 167 s.pages.Error404(w) ··· 175 return func(w http.ResponseWriter, r *http.Request) { 176 if r.URL.Query().Get("go-get") == "1" { 177 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">`)) 179 return 180 } 181 182 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 183 } 184 - } 185 - 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 func (s *State) SettingsRouter() http.Handler { ··· 201 } 202 203 func (s *State) SpindlesRouter() http.Handler { 204 - logger := log.New("spindles") 205 206 spindles := &spindles.Spindles{ 207 Db: s.db, ··· 217 } 218 219 func (s *State) KnotsRouter() http.Handler { 220 - logger := log.New("knots") 221 222 knots := &knots.Knots{ 223 Db: s.db, ··· 234 } 235 236 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 237 - logger := log.New("strings") 238 239 strs := &avstrings.Strings{ 240 Db: s.db, ··· 249 } 250 251 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) 253 return issues.Router(mw) 254 } 255 256 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) 258 return pulls.Router(mw) 259 } 260 261 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) 264 return repo.Router(mw) 265 } 266 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) 270 } 271 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) 275 } 276 277 - 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) 281 return sig.Router() 282 }
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.org/core/appview/issues" 9 "tangled.org/core/appview/knots" 10 "tangled.org/core/appview/labels" 11 "tangled.org/core/appview/middleware" 12 + "tangled.org/core/appview/notifications" 13 "tangled.org/core/appview/pipelines" 14 "tangled.org/core/appview/pulls" 15 "tangled.org/core/appview/repo" ··· 34 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware) 41 standardRouter := s.StandardRouter(&middleware) ··· 116 117 r.Get("/", s.HomeOrTimeline) 118 r.Get("/timeline", s.Timeline) 119 + r.Get("/upgradeBanner", s.UpgradeBanner) 120 121 // special-case handler for serving tangled.org/core 122 r.Get("/core", s.Core()) 123 124 + r.Get("/login", s.Login) 125 + r.Post("/login", s.Login) 126 + r.Post("/logout", s.Logout) 127 + 128 r.Route("/repo", func(r chi.Router) { 129 r.Route("/new", func(r chi.Router) { 130 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 133 }) 134 // r.Post("/import", s.ImportRepo) 135 }) 136 + 137 + r.Get("/goodfirstissues", s.GoodFirstIssues) 138 139 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 140 r.Post("/", s.Follow) ··· 163 r.Mount("/strings", s.StringsRouter(mw)) 164 r.Mount("/knots", s.KnotsRouter()) 165 r.Mount("/spindles", s.SpindlesRouter()) 166 + r.Mount("/notifications", s.NotificationsRouter(mw)) 167 + 168 r.Mount("/signup", s.SignupRouter()) 169 + r.Mount("/", s.oauth.Router()) 170 171 r.Get("/keys/{user}", s.Keys) 172 r.Get("/terms", s.TermsOfService) 173 r.Get("/privacy", s.PrivacyPolicy) 174 + r.Get("/brand", s.Brand) 175 176 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 177 s.pages.Error404(w) ··· 185 return func(w http.ResponseWriter, r *http.Request) { 186 if r.URL.Query().Get("go-get") == "1" { 187 w.Header().Set("Content-Type", "text/html") 188 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 189 return 190 } 191 192 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 193 } 194 } 195 196 func (s *State) SettingsRouter() http.Handler { ··· 205 } 206 207 func (s *State) SpindlesRouter() http.Handler { 208 + logger := log.SubLogger(s.logger, "spindles") 209 210 spindles := &spindles.Spindles{ 211 Db: s.db, ··· 221 } 222 223 func (s *State) KnotsRouter() http.Handler { 224 + logger := log.SubLogger(s.logger, "knots") 225 226 knots := &knots.Knots{ 227 Db: s.db, ··· 238 } 239 240 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 241 + logger := log.SubLogger(s.logger, "strings") 242 243 strs := &avstrings.Strings{ 244 Db: s.db, ··· 253 } 254 255 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 256 + issues := issues.New( 257 + s.oauth, 258 + s.repoResolver, 259 + s.pages, 260 + s.idResolver, 261 + s.db, 262 + s.config, 263 + s.notifier, 264 + s.validator, 265 + log.SubLogger(s.logger, "issues"), 266 + ) 267 return issues.Router(mw) 268 } 269 270 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 271 + pulls := pulls.New( 272 + s.oauth, 273 + s.repoResolver, 274 + s.pages, 275 + s.idResolver, 276 + s.db, 277 + s.config, 278 + s.notifier, 279 + s.enforcer, 280 + s.validator, 281 + log.SubLogger(s.logger, "pulls"), 282 + ) 283 return pulls.Router(mw) 284 } 285 286 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 287 + repo := repo.New( 288 + s.oauth, 289 + s.repoResolver, 290 + s.pages, 291 + s.spindlestream, 292 + s.idResolver, 293 + s.db, 294 + s.config, 295 + s.notifier, 296 + s.enforcer, 297 + log.SubLogger(s.logger, "repo"), 298 + s.validator, 299 + ) 300 return repo.Router(mw) 301 } 302 303 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 304 + pipes := pipelines.New( 305 + s.oauth, 306 + s.repoResolver, 307 + s.pages, 308 + s.spindlestream, 309 + s.idResolver, 310 + s.db, 311 + s.config, 312 + s.enforcer, 313 + log.SubLogger(s.logger, "pipelines"), 314 + ) 315 return pipes.Router(mw) 316 } 317 318 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 319 + ls := labels.New( 320 + s.oauth, 321 + s.pages, 322 + s.db, 323 + s.validator, 324 + s.enforcer, 325 + log.SubLogger(s.logger, "labels"), 326 + ) 327 return ls.Router(mw) 328 } 329 330 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 331 + notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 332 + return notifs.Router(mw) 333 + } 334 335 + func (s *State) SignupRouter() http.Handler { 336 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 337 return sig.Router() 338 }
+3 -1
appview/state/spindlestream.go
··· 22 ) 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 spindles, err := db.GetSpindles( 26 d, 27 db.FilterIsNot("verified", "null"), ··· 36 srcs[src] = struct{}{} 37 } 38 39 - logger := log.New("spindlestream") 40 cache := cache.New(c.Redis.Addr) 41 cursorStore := cursor.NewRedisCursorStore(cache) 42
··· 22 ) 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 + logger := log.FromContext(ctx) 26 + logger = log.SubLogger(logger, "spindlestream") 27 + 28 spindles, err := db.GetSpindles( 29 d, 30 db.FilterIsNot("verified", "null"), ··· 39 srcs[src] = struct{}{} 40 } 41 42 cache := cache.New(c.Redis.Addr) 43 cursorStore := cursor.NewRedisCursorStore(cache) 44
+2 -2
appview/state/star.go
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
+101 -40
appview/state/state.go
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 - "log" 9 "log/slog" 10 "net/http" 11 "strings" 12 "time" 13 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview" 22 - "tangled.org/core/appview/cache" 23 - "tangled.org/core/appview/cache/session" 24 "tangled.org/core/appview/config" 25 "tangled.org/core/appview/db" 26 "tangled.org/core/appview/models" 27 "tangled.org/core/appview/notify" 28 "tangled.org/core/appview/oauth" 29 "tangled.org/core/appview/pages" 30 - posthogService "tangled.org/core/appview/posthog" 31 "tangled.org/core/appview/reporesolver" 32 "tangled.org/core/appview/validator" 33 xrpcclient "tangled.org/core/appview/xrpcclient" 34 "tangled.org/core/eventconsumer" 35 "tangled.org/core/idresolver" 36 "tangled.org/core/jetstream" 37 tlog "tangled.org/core/log" 38 "tangled.org/core/rbac" 39 "tangled.org/core/tid" 40 ) 41 42 type State struct { ··· 45 oauth *oauth.OAuth 46 enforcer *rbac.Enforcer 47 pages *pages.Pages 48 - sess *session.SessionStore 49 idResolver *idresolver.Resolver 50 posthog posthog.Client 51 jc *jetstream.JetstreamClient ··· 58 } 59 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 61 - d, err := db.Make(config.Core.DbPath) 62 if err != nil { 63 return nil, fmt.Errorf("failed to create db: %w", err) 64 } ··· 70 71 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 72 if err != nil { 73 - log.Printf("failed to create redis resolver: %v", err) 74 res = idresolver.DefaultResolver() 75 } 76 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 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 if err != nil { 85 return nil, fmt.Errorf("failed to create posthog client: %w", err) 86 } 87 88 repoResolver := reporesolver.New(config, enforcer, res, d) 89 90 wrapper := db.DbWrapper{Execer: d} ··· 106 tangled.LabelOpNSID, 107 }, 108 nil, 109 - slog.Default(), 110 wrapper, 111 false, 112 ··· 118 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 119 } 120 121 - if err := db.BackfillDefaultDefs(d, res); err != nil { 122 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 123 } 124 ··· 127 Enforcer: enforcer, 128 IdResolver: res, 129 Config: config, 130 - Logger: tlog.New("ingester"), 131 Validator: validator, 132 } 133 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 148 spindlestream.Start(ctx) 149 150 var notifiers []notify.Notifier 151 if !config.Core.Dev { 152 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 153 } 154 notifier := notify.NewMergedNotifier(notifiers...) 155 ··· 158 notifier, 159 oauth, 160 enforcer, 161 - pgs, 162 - sess, 163 res, 164 posthog, 165 jc, ··· 167 repoResolver, 168 knotstream, 169 spindlestream, 170 - slog.Default(), 171 validator, 172 } 173 ··· 192 s.pages.Favicon(w) 193 } 194 195 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 196 user := s.oauth.GetUser(r) 197 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 206 }) 207 } 208 209 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 210 if s.oauth.GetUser(r) != nil { 211 s.Timeline(w, r) ··· 217 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 218 user := s.oauth.GetUser(r) 219 220 var userDid string 221 if user != nil { 222 userDid = user.Did 223 } 224 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 225 if err != nil { 226 - log.Println(err) 227 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 228 } 229 230 repos, err := db.GetTopStarredReposLastWeek(s.db) 231 if err != nil { 232 - log.Println(err) 233 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 234 return 235 } 236 237 s.pages.Timeline(w, pages.TimelineParams{ 238 LoggedInUser: user, 239 Timeline: timeline, 240 Repos: repos, 241 }) 242 } 243 244 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 245 user := s.oauth.GetUser(r) 246 l := s.logger.With("handler", "UpgradeBanner") 247 l = l.With("did", user.Did) 248 - l = l.With("handle", user.Handle) 249 250 regs, err := db.GetRegistrations( 251 s.db, ··· 276 } 277 278 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 279 - timeline, err := db.MakeTimeline(s.db, 5, "") 280 if err != nil { 281 - log.Println(err) 282 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 283 return 284 } 285 286 repos, err := db.GetTopStarredReposLastWeek(s.db) 287 if err != nil { 288 - log.Println(err) 289 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 290 return 291 } ··· 385 386 user := s.oauth.GetUser(r) 387 l = l.With("did", user.Did) 388 - l = l.With("handle", user.Handle) 389 390 // form validation 391 domain := r.FormValue("domain") ··· 449 } 450 record := repo.AsRecord() 451 452 - xrpcClient, err := s.oauth.AuthorizedClient(r) 453 if err != nil { 454 l.Info("PDS write failed", "err", err) 455 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 456 return 457 } 458 459 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 460 Collection: tangled.RepoNSID, 461 Repo: user.Did, 462 Rkey: rkey, ··· 488 rollback := func() { 489 err1 := tx.Rollback() 490 err2 := s.enforcer.E.LoadPolicy() 491 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 492 493 // ignore txn complete errors, this is okay 494 if errors.Is(err1, sql.ErrTxDone) { ··· 561 aturi = "" 562 563 s.notifier.NewRepo(r.Context(), repo) 564 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 565 } 566 } 567 568 // this is used to rollback changes made to the PDS 569 // 570 // it is a no-op if the provided ATURI is empty 571 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 572 if aturi == "" { 573 return nil 574 } ··· 579 repo := parsed.Authority().String() 580 rkey := parsed.RecordKey().String() 581 582 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 583 Collection: collection, 584 Repo: repo, 585 Rkey: rkey,
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "strings" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/notify" 19 + dbnotify "tangled.org/core/appview/notify/db" 20 + phnotify "tangled.org/core/appview/notify/posthog" 21 "tangled.org/core/appview/oauth" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/reporesolver" 24 "tangled.org/core/appview/validator" 25 xrpcclient "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/eventconsumer" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/jetstream" 29 + "tangled.org/core/log" 30 tlog "tangled.org/core/log" 31 "tangled.org/core/rbac" 32 "tangled.org/core/tid" 33 + 34 + comatproto "github.com/bluesky-social/indigo/api/atproto" 35 + atpclient "github.com/bluesky-social/indigo/atproto/client" 36 + "github.com/bluesky-social/indigo/atproto/syntax" 37 + lexutil "github.com/bluesky-social/indigo/lex/util" 38 + securejoin "github.com/cyphar/filepath-securejoin" 39 + "github.com/go-chi/chi/v5" 40 + "github.com/posthog/posthog-go" 41 ) 42 43 type State struct { ··· 46 oauth *oauth.OAuth 47 enforcer *rbac.Enforcer 48 pages *pages.Pages 49 idResolver *idresolver.Resolver 50 posthog posthog.Client 51 jc *jetstream.JetstreamClient ··· 58 } 59 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 61 + logger := tlog.FromContext(ctx) 62 + 63 + d, err := db.Make(ctx, config.Core.DbPath) 64 if err != nil { 65 return nil, fmt.Errorf("failed to create db: %w", err) 66 } ··· 72 73 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 74 if err != nil { 75 + logger.Error("failed to create redis resolver", "err", err) 76 res = idresolver.DefaultResolver() 77 } 78 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 80 if err != nil { 81 return nil, fmt.Errorf("failed to create posthog client: %w", err) 82 } 83 84 + pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 85 + oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 86 + if err != nil { 87 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 88 + } 89 + validator := validator.New(d, res, enforcer) 90 + 91 repoResolver := reporesolver.New(config, enforcer, res, d) 92 93 wrapper := db.DbWrapper{Execer: d} ··· 109 tangled.LabelOpNSID, 110 }, 111 nil, 112 + tlog.SubLogger(logger, "jetstream"), 113 wrapper, 114 false, 115 ··· 121 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 122 } 123 124 + if err := BackfillDefaultDefs(d, res); err != nil { 125 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 126 } 127 ··· 130 Enforcer: enforcer, 131 IdResolver: res, 132 Config: config, 133 + Logger: log.SubLogger(logger, "ingester"), 134 Validator: validator, 135 } 136 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 151 spindlestream.Start(ctx) 152 153 var notifiers []notify.Notifier 154 + 155 + // Always add the database notifier 156 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 157 + 158 + // Add other notifiers in production only 159 if !config.Core.Dev { 160 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 161 } 162 notifier := notify.NewMergedNotifier(notifiers...) 163 ··· 166 notifier, 167 oauth, 168 enforcer, 169 + pages, 170 res, 171 posthog, 172 jc, ··· 174 repoResolver, 175 knotstream, 176 spindlestream, 177 + logger, 178 validator, 179 } 180 ··· 199 s.pages.Favicon(w) 200 } 201 202 + func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 203 + w.Header().Set("Content-Type", "text/plain") 204 + w.Header().Set("Cache-Control", "public, max-age=86400") // one day 205 + 206 + robotsTxt := `User-agent: * 207 + Allow: / 208 + ` 209 + w.Write([]byte(robotsTxt)) 210 + } 211 + 212 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 213 + const manifestJson = `{ 214 + "name": "tangled", 215 + "description": "tightly-knit social coding.", 216 + "icons": [ 217 + { 218 + "src": "/favicon.svg", 219 + "sizes": "144x144" 220 + } 221 + ], 222 + "start_url": "/", 223 + "id": "org.tangled", 224 + 225 + "display": "standalone", 226 + "background_color": "#111827", 227 + "theme_color": "#111827" 228 + }` 229 + 230 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 231 + w.Header().Set("Content-Type", "application/json") 232 + w.Write([]byte(manifestJson)) 233 + } 234 + 235 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 236 user := s.oauth.GetUser(r) 237 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 246 }) 247 } 248 249 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 250 + user := s.oauth.GetUser(r) 251 + s.pages.Brand(w, pages.BrandParams{ 252 + LoggedInUser: user, 253 + }) 254 + } 255 + 256 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 257 if s.oauth.GetUser(r) != nil { 258 s.Timeline(w, r) ··· 264 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 265 user := s.oauth.GetUser(r) 266 267 + // TODO: set this flag based on the UI 268 + filtered := false 269 + 270 var userDid string 271 if user != nil { 272 userDid = user.Did 273 } 274 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 275 if err != nil { 276 + s.logger.Error("failed to make timeline", "err", err) 277 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 278 } 279 280 repos, err := db.GetTopStarredReposLastWeek(s.db) 281 if err != nil { 282 + s.logger.Error("failed to get top starred repos", "err", err) 283 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 284 return 285 + } 286 + 287 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 288 + if err != nil { 289 + // non-fatal 290 } 291 292 s.pages.Timeline(w, pages.TimelineParams{ 293 LoggedInUser: user, 294 Timeline: timeline, 295 Repos: repos, 296 + GfiLabel: gfiLabel, 297 }) 298 } 299 300 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 301 user := s.oauth.GetUser(r) 302 + if user == nil { 303 + return 304 + } 305 + 306 l := s.logger.With("handler", "UpgradeBanner") 307 l = l.With("did", user.Did) 308 309 regs, err := db.GetRegistrations( 310 s.db, ··· 335 } 336 337 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 338 + // TODO: set this flag based on the UI 339 + filtered := false 340 + 341 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 342 if err != nil { 343 + s.logger.Error("failed to make timeline", "err", err) 344 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 345 return 346 } 347 348 repos, err := db.GetTopStarredReposLastWeek(s.db) 349 if err != nil { 350 + s.logger.Error("failed to get top starred repos", "err", err) 351 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 352 return 353 } ··· 447 448 user := s.oauth.GetUser(r) 449 l = l.With("did", user.Did) 450 451 // form validation 452 domain := r.FormValue("domain") ··· 510 } 511 record := repo.AsRecord() 512 513 + atpClient, err := s.oauth.AuthorizedClient(r) 514 if err != nil { 515 l.Info("PDS write failed", "err", err) 516 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 517 return 518 } 519 520 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 521 Collection: tangled.RepoNSID, 522 Repo: user.Did, 523 Rkey: rkey, ··· 549 rollback := func() { 550 err1 := tx.Rollback() 551 err2 := s.enforcer.E.LoadPolicy() 552 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 553 554 // ignore txn complete errors, this is okay 555 if errors.Is(err1, sql.ErrTxDone) { ··· 622 aturi = "" 623 624 s.notifier.NewRepo(r.Context(), repo) 625 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 626 } 627 } 628 629 // this is used to rollback changes made to the PDS 630 // 631 // it is a no-op if the provided ATURI is empty 632 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 633 if aturi == "" { 634 return nil 635 } ··· 640 repo := parsed.Authority().String() 641 rkey := parsed.RecordKey().String() 642 643 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 644 Collection: collection, 645 Repo: repo, 646 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/go-chi/chi/v5" 27 ) 28 29 type Strings struct { ··· 254 } 255 256 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 if err != nil { 259 fail("Failed to updated existing record.", err) 260 return 261 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 263 Collection: tangled.StringNSID, 264 Repo: entry.Did.String(), 265 Rkey: entry.Rkey, ··· 284 s.Notifier.EditString(r.Context(), &entry) 285 286 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 288 } 289 290 } ··· 336 return 337 } 338 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 340 Collection: tangled.StringNSID, 341 Repo: user.Did, 342 Rkey: string.Rkey, ··· 360 s.Notifier.NewString(r.Context(), &string) 361 362 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 364 } 365 } 366 ··· 403 404 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 407 } 408 409 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 ) 30 31 type Strings struct { ··· 256 } 257 258 // first replace the existing record in the PDS 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 260 if err != nil { 261 fail("Failed to updated existing record.", err) 262 return 263 } 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 265 Collection: tangled.StringNSID, 266 Repo: entry.Did.String(), 267 Rkey: entry.Rkey, ··· 286 s.Notifier.EditString(r.Context(), &entry) 287 288 // if that went okay, redir to the string 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 290 } 291 292 } ··· 338 return 339 } 340 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 342 Collection: tangled.StringNSID, 343 Repo: user.Did, 344 Rkey: string.Rkey, ··· 362 s.Notifier.NewString(r.Context(), &string) 363 364 // successful 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 366 } 367 } 368 ··· 405 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 409 } 410 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+15 -1
appview/validator/label.go
··· 95 return nil 96 } 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 if labelOp == nil { 103 return fmt.Errorf("label operation is required") 104 } 105 106 expectedKey := labelDef.AtUri().String()
··· 95 return nil 96 } 97 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 105 if labelOp == nil { 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") 118 } 119 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 + }
+4 -1
appview/validator/validator.go
··· 4 "tangled.org/core/appview/db" 5 "tangled.org/core/appview/pages/markup" 6 "tangled.org/core/idresolver" 7 ) 8 9 type Validator struct { 10 db *db.DB 11 sanitizer markup.Sanitizer 12 resolver *idresolver.Resolver 13 } 14 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 16 return &Validator{ 17 db: db, 18 sanitizer: markup.NewSanitizer(), 19 resolver: res, 20 } 21 }
··· 4 "tangled.org/core/appview/db" 5 "tangled.org/core/appview/pages/markup" 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 8 ) 9 10 type Validator struct { 11 db *db.DB 12 sanitizer markup.Sanitizer 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 15 } 16 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 return &Validator{ 19 db: db, 20 sanitizer: markup.NewSanitizer(), 21 resolver: res, 22 + enforcer: enforcer, 23 } 24 }
-99
appview/xrpcclient/xrpc.go
··· 1 package xrpcclient 2 3 import ( 4 - "bytes" 5 - "context" 6 "errors" 7 - "io" 8 "net/http" 9 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 ) 15 16 var ( ··· 19 ErrXrpcFailed = errors.New("xrpc request failed") 20 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 116 // produces a more manageable error 117 func HandleXrpcErr(err error) error {
··· 1 package xrpcclient 2 3 import ( 4 "errors" 5 "net/http" 6 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 8 ) 9 10 var ( ··· 13 ErrXrpcFailed = errors.New("xrpc request failed") 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 15 ) 16 17 // produces a more manageable error 18 func HandleXrpcErr(err error) error {
+14 -9
cmd/appview/main.go
··· 2 3 import ( 4 "context" 5 - "log" 6 - "log/slog" 7 "net/http" 8 "os" 9 10 "tangled.org/core/appview/config" 11 "tangled.org/core/appview/state" 12 ) 13 14 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 ctx := context.Background() 18 19 c, err := config.LoadConfig(ctx) 20 if err != nil { 21 - log.Println("failed to load config", "error", err) 22 return 23 } 24 25 state, err := state.Make(ctx, c) 26 defer func() { 27 - log.Println(state.Close()) 28 }() 29 30 if err != nil { 31 - log.Fatal(err) 32 } 33 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 }
··· 2 3 import ( 4 "context" 5 "net/http" 6 "os" 7 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 11 ) 12 13 func main() { 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 17 18 c, err := config.LoadConfig(ctx) 19 if err != nil { 20 + logger.Error("failed to load config", "error", err) 21 return 22 } 23 24 state, err := state.Make(ctx, c) 25 defer func() { 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 29 }() 30 31 if err != nil { 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 34 } 35 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 + } 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 - }
···
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 2 3 package main 4
··· 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 3 package main 4
+6 -3
cmd/knot/main.go
··· 2 3 import ( 4 "context" 5 "os" 6 7 "github.com/urfave/cli/v3" ··· 9 "tangled.org/core/hook" 10 "tangled.org/core/keyfetch" 11 "tangled.org/core/knotserver" 12 - "tangled.org/core/log" 13 ) 14 15 func main() { ··· 24 }, 25 } 26 27 ctx := context.Background() 28 - logger := log.New("knot") 29 - ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 30 31 if err := cmd.Run(ctx, os.Args); err != nil { 32 logger.Error(err.Error())
··· 2 3 import ( 4 "context" 5 + "log/slog" 6 "os" 7 8 "github.com/urfave/cli/v3" ··· 10 "tangled.org/core/hook" 11 "tangled.org/core/keyfetch" 12 "tangled.org/core/knotserver" 13 + tlog "tangled.org/core/log" 14 ) 15 16 func main() { ··· 25 }, 26 } 27 28 + logger := tlog.New("knot") 29 + slog.SetDefault(logger) 30 + 31 ctx := context.Background() 32 + ctx = tlog.IntoContext(ctx, logger) 33 34 if err := cmd.Run(ctx, os.Args); err != nil { 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 3 import ( 4 "context" 5 "os" 6 7 - "tangled.org/core/log" 8 "tangled.org/core/spindle" 9 - _ "tangled.org/core/tid" 10 ) 11 12 func main() { 13 - ctx := log.NewContext(context.Background(), "spindle") 14 err := spindle.Run(ctx) 15 if err != nil { 16 - log.FromContext(ctx).Error("error running spindle", "error", err) 17 os.Exit(-1) 18 } 19 }
··· 2 3 import ( 4 "context" 5 + "log/slog" 6 "os" 7 8 + tlog "tangled.org/core/log" 9 "tangled.org/core/spindle" 10 ) 11 12 func main() { 13 + logger := tlog.New("spindle") 14 + slog.SetDefault(logger) 15 + 16 + ctx := context.Background() 17 + ctx = tlog.IntoContext(ctx, logger) 18 + 19 err := spindle.Run(ctx) 20 if err != nil { 21 + logger.Error("error running spindle", "error", err) 22 os.Exit(-1) 23 } 24 }
+2 -1
docs/knot-hosting.md
··· 39 ``` 40 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/knot` is a good choice: 43 44 ``` 45 sudo mv knot /usr/local/bin/knot 46 ``` 47 48 This is necessary because SSH `AuthorizedKeysCommand` requires [really
··· 39 ``` 40 41 Next, move the `knot` binary to a location owned by `root` -- 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 44 ``` 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 47 ``` 48 49 This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1 -1
docs/spindle/pipeline.md
··· 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. 23 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 26 ```yaml 27 when:
··· 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. 23 24 + 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 ```yaml 27 when:
+1 -1
flake.nix
··· 262 lexgen --build-file lexicon-build-config.json lexicons 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 266 lexgen --build-file lexicon-build-config.json lexicons 267 rm api/tangled/*.bak 268 '';
··· 262 lexgen --build-file lexicon-build-config.json lexicons 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 + go run ./cmd/cborgen/ 266 lexgen --build-file lexicon-build-config.json lexicons 267 rm api/tangled/*.bak 268 '';
+23 -6
go.mod
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 github.com/gorilla/feeds v1.2.0 26 github.com/gorilla/sessions v1.4.0 ··· 36 github.com/redis/go-redis/v9 v9.7.3 37 github.com/resend/resend-go/v2 v2.15.0 38 github.com/sethvargo/go-envconfig v1.1.0 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 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 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 ) 52 53 require ( ··· 56 github.com/ProtonMail/go-crypto v1.3.0 // indirect 57 github.com/alecthomas/repr v0.4.0 // indirect 58 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 59 github.com/aymerick/douceur v0.2.0 // indirect 60 github.com/beorn7/perks v1.0.1 // indirect 61 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 62 github.com/casbin/govaluate v1.3.0 // indirect 63 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 64 github.com/cespare/xxhash/v2 v2.3.0 // indirect 65 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 66 github.com/containerd/errdefs v1.0.0 // indirect 67 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 80 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 81 github.com/go-git/go-billy/v5 v5.6.2 // indirect 82 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 83 github.com/go-logr/logr v1.4.3 // indirect 84 github.com/go-logr/stdr v1.2.2 // indirect 85 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 122 github.com/lestrrat-go/httprc v1.0.6 // indirect 123 github.com/lestrrat-go/iter v1.0.2 // indirect 124 github.com/lestrrat-go/option v1.0.1 // indirect 125 github.com/mattn/go-isatty v0.0.20 // indirect 126 github.com/minio/sha256-simd v1.0.1 // indirect 127 github.com/mitchellh/mapstructure v1.5.0 // indirect 128 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 130 github.com/moby/term v0.5.2 // indirect 131 github.com/morikuni/aec v1.0.0 // indirect 132 github.com/mr-tron/base58 v1.2.0 // indirect 133 github.com/multiformats/go-base32 v0.1.0 // indirect 134 github.com/multiformats/go-base36 v0.2.0 // indirect 135 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 148 github.com/prometheus/client_model v0.6.2 // indirect 149 github.com/prometheus/common v0.64.0 // indirect 150 github.com/prometheus/procfs v0.16.1 // indirect 151 github.com/ryanuber/go-glob v1.0.0 // indirect 152 github.com/segmentio/asm v1.2.0 // indirect 153 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 github.com/wyatt915/treeblood v0.1.15 // indirect 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 168 go.uber.org/atomic v1.11.0 // indirect 169 go.uber.org/multierr v1.11.0 // indirect 170 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect 175 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 25 github.com/google/uuid v1.6.0 26 github.com/gorilla/feeds v1.2.0 27 github.com/gorilla/sessions v1.4.0 ··· 37 github.com/redis/go-redis/v9 v9.7.3 38 github.com/resend/resend-go/v2 v2.15.0 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 42 github.com/stretchr/testify v1.10.0 43 github.com/urfave/cli/v3 v3.3.3 44 github.com/whyrusleeping/cbor-gen v0.3.1 45 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 46 + github.com/yuin/goldmark v1.7.13 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 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 51 golang.org/x/net v0.42.0 52 + golang.org/x/sync v0.17.0 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 54 gopkg.in/yaml.v3 v3.0.1 55 ) 56 57 require ( ··· 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 61 github.com/alecthomas/repr v0.4.0 // indirect 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 64 github.com/aymerick/douceur v0.2.0 // indirect 65 github.com/beorn7/perks v1.0.1 // indirect 66 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 67 github.com/casbin/govaluate v1.3.0 // indirect 68 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 69 github.com/cespare/xxhash/v2 v2.3.0 // indirect 70 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 71 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 72 + github.com/charmbracelet/log v0.4.2 // indirect 73 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 74 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 75 + github.com/charmbracelet/x/term v0.2.1 // indirect 76 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 77 github.com/containerd/errdefs v1.0.0 // indirect 78 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 91 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 92 github.com/go-git/go-billy/v5 v5.6.2 // indirect 93 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 94 + github.com/go-logfmt/logfmt v0.6.0 // indirect 95 github.com/go-logr/logr v1.4.3 // indirect 96 github.com/go-logr/stdr v1.2.2 // indirect 97 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 134 github.com/lestrrat-go/httprc v1.0.6 // indirect 135 github.com/lestrrat-go/iter v1.0.2 // indirect 136 github.com/lestrrat-go/option v1.0.1 // indirect 137 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 138 github.com/mattn/go-isatty v0.0.20 // indirect 139 + github.com/mattn/go-runewidth v0.0.16 // indirect 140 github.com/minio/sha256-simd v1.0.1 // indirect 141 github.com/mitchellh/mapstructure v1.5.0 // indirect 142 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 144 github.com/moby/term v0.5.2 // indirect 145 github.com/morikuni/aec v1.0.0 // indirect 146 github.com/mr-tron/base58 v1.2.0 // indirect 147 + github.com/muesli/termenv v0.16.0 // indirect 148 github.com/multiformats/go-base32 v0.1.0 // indirect 149 github.com/multiformats/go-base36 v0.2.0 // indirect 150 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 163 github.com/prometheus/client_model v0.6.2 // indirect 164 github.com/prometheus/common v0.64.0 // indirect 165 github.com/prometheus/procfs v0.16.1 // indirect 166 + github.com/rivo/uniseg v0.4.7 // indirect 167 github.com/ryanuber/go-glob v1.0.0 // indirect 168 github.com/segmentio/asm v1.2.0 // indirect 169 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 172 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 173 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 174 github.com/wyatt915/treeblood v0.1.15 // indirect 175 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 176 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 177 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 178 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 179 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 186 go.uber.org/atomic v1.11.0 // indirect 187 go.uber.org/multierr v1.11.0 // indirect 188 go.uber.org/zap v1.27.0 // indirect 189 golang.org/x/sys v0.34.0 // indirect 190 + golang.org/x/text v0.29.0 // indirect 191 golang.org/x/time v0.12.0 // indirect 192 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 193 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+45 -12
go.sum
··· 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 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= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 48 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 139 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 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= 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 276 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 279 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 300 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 303 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 402 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 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= 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 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= 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 489 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 492 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 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= 533 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 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= 588 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 652 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 23 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 24 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 25 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 26 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 27 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 50 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 51 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 52 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 53 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 54 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 55 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 56 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 57 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 58 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 59 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 60 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 61 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 62 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 63 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 64 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 65 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 66 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 67 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 134 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 135 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 136 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 137 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 138 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 139 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 140 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 141 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 152 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 153 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 154 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 155 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 156 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 157 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 158 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 159 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 261 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 262 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 263 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 264 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 265 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 266 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 292 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 293 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 294 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 295 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 296 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 298 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 299 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 300 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 301 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 302 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 303 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 304 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 305 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 320 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 321 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 322 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 323 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 324 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 325 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 326 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 327 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 399 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 400 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 401 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 402 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 403 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 404 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 405 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 406 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 407 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 424 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 425 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 426 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 427 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 428 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 429 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 430 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 431 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 432 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 433 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 459 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 460 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 461 github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 462 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 465 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 466 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 467 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 468 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 469 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 470 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 471 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 472 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 473 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 474 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 475 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 476 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 477 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 478 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 522 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 523 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 524 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 525 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 526 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 527 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 528 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 529 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 563 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 564 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 565 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 566 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 567 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 568 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 569 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 570 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 618 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 619 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 620 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 621 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 622 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 623 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 624 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 625 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 687 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 688 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 689 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 690 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 691 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71 -12
input.css
··· 134 } 135 136 .prose hr { 137 - @apply my-2; 138 } 139 140 .prose li:has(input) { 141 - @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 - @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 - @apply no-underline; 162 } 163 164 .prose li { 165 - @apply my-0 py-0; 166 } 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 170 } 171 172 .prose img { ··· 176 } 177 178 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 } 181 182 .prose input[type="checkbox"] { 183 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 } 185 } 186 @layer utilities { 187 .error { ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 234 /* LineNumbersTable */
··· 134 } 135 136 .prose hr { 137 + @apply my-2; 138 } 139 140 .prose li:has(input) { 141 + @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 + @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 + @apply no-underline; 162 } 163 164 .prose li { 165 + @apply my-0 py-0; 166 } 167 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 171 } 172 173 .prose img { ··· 177 } 178 179 .prose input { 180 + @apply inline-block my-0 mb-1 mx-1; 181 } 182 183 .prose input[type="checkbox"] { 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 244 } 245 @layer utilities { 246 .error { ··· 287 } 288 /* LineHighlight */ 289 .chroma .hl { 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 291 } 292 293 /* LineNumbersTable */
+1 -1
jetstream/jetstream.go
··· 114 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 118 if err != nil { 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 }
··· 114 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 if err != nil { 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 }
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
+2 -3
knotserver/events.go
··· 8 "time" 9 10 "github.com/gorilla/websocket" 11 ) 12 13 var upgrader = websocket.Upgrader{ ··· 16 } 17 18 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 - l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 22 conn, err := upgrader.Upgrade(w, r, nil) ··· 75 } 76 case <-time.After(30 * time.Second): 77 // send a keep-alive 78 - l.Debug("sent keepalive") 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 l.Error("failed to write control", "err", err) 81 } ··· 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 return err 91 } 92 - h.l.Debug("ops", "ops", events) 93 94 for _, event := range events { 95 // first extract the inner json into a map
··· 8 "time" 9 10 "github.com/gorilla/websocket" 11 + "tangled.org/core/log" 12 ) 13 14 var upgrader = websocket.Upgrader{ ··· 17 } 18 19 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 20 + l := log.SubLogger(h.l, "eventstream") 21 l.Debug("received new connection") 22 23 conn, err := upgrader.Upgrade(w, r, nil) ··· 76 } 77 case <-time.After(30 * time.Second): 78 // send a keep-alive 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 l.Error("failed to write control", "err", err) 81 } ··· 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 return err 91 } 92 93 for _, event := range events { 94 // first extract the inner json into a map
+5
knotserver/git/branch.go
··· 110 slices.Reverse(branches) 111 return branches, nil 112 }
··· 110 slices.Reverse(branches) 111 return branches, nil 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 h plumbing.Hash 28 } 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 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 // to tar WriteHeader 45 type infoWrapper struct { ··· 50 isDir bool 51 } 52 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 func Open(path string, ref string) (*GitRepo, error) { 92 var err error 93 g := GitRepo{path: path} ··· 122 return &g, nil 123 } 124 125 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 126 commits := []*object.Commit{} 127 ··· 171 return g.r.CommitObject(h) 172 } 173 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 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 c, err := g.r.CommitObject(g.h) 184 if err != nil { ··· 211 } 212 213 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 } 240 241 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 func (i *infoWrapper) Sys() any { 411 return nil 412 } 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 - }
··· 27 h plumbing.Hash 28 } 29 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 31 // to tar WriteHeader 32 type infoWrapper struct { ··· 37 isDir bool 38 } 39 40 func Open(path string, ref string) (*GitRepo, error) { 41 var err error 42 g := GitRepo{path: path} ··· 71 return &g, nil 72 } 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 + 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 86 commits := []*object.Commit{} 87 ··· 131 return g.r.CommitObject(h) 132 } 133 134 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 135 c, err := g.r.CommitObject(g.h) 136 if err != nil { ··· 163 } 164 165 return buf.Bytes(), nil 166 } 167 168 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 337 func (i *infoWrapper) Sys() any { 338 return nil 339 }
+21 -2
knotserver/git/last_commit.go
··· 30 commitCache = cache 31 } 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 34 args := []string{} 35 args = append(args, "log") 36 args = append(args, g.h.String()) ··· 48 return nil, err 49 } 50 51 - return stdout, nil 52 } 53 54 type commit struct { ··· 104 if err != nil { 105 return nil, err 106 } 107 108 reader := bufio.NewReader(output) 109 var current commit
··· 30 commitCache = cache 31 } 32 33 + // processReader wraps a reader and ensures the associated process is cleaned up 34 + type processReader struct { 35 + io.Reader 36 + cmd *exec.Cmd 37 + stdout io.ReadCloser 38 + } 39 + 40 + func (pr *processReader) Close() error { 41 + if err := pr.stdout.Close(); err != nil { 42 + return err 43 + } 44 + return pr.cmd.Wait() 45 + } 46 + 47 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 48 args := []string{} 49 args = append(args, "log") 50 args = append(args, g.h.String()) ··· 62 return nil, err 63 } 64 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 70 } 71 72 type commit struct { ··· 122 if err != nil { 123 return nil, err 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 126 127 reader := bufio.NewReader(output) 128 var current commit
+150 -37
knotserver/git/merge.go
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 "os" 8 "os/exec" 9 "regexp" ··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 32 mergeCheckCache = MergeCheckCache{cache} 33 } 34 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 36 sep := byte(':') 37 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 return fmt.Sprintf("%x", hash) ··· 49 } 50 } 51 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 53 key := m.cacheKey(g, patch, targetBranch) 54 val := m.cacheVal(mergeCheck) 55 m.cache.Set(key, val, 0) 56 } 57 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 59 key := m.cacheKey(g, patch, targetBranch) 60 if val, ok := m.cache.Get(key); ok { 61 if val == struct{}{} { ··· 104 return fmt.Sprintf("merge failed: %s", e.Message) 105 } 106 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 108 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 if err != nil { 110 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 } 112 113 - if _, err := tmpFile.Write(patchData); err != nil { 114 tmpFile.Close() 115 os.Remove(tmpFile.Name()) 116 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 return nil 163 } 164 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 var stderr bytes.Buffer 167 var cmd *exec.Cmd 168 169 // 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 174 // if patch is a format-patch, apply using 'git am' 175 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 - } 184 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 - } 189 190 - commitArgs := []string{"-C", tmpDir, "commit"} 191 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 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 200 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 206 207 - cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 ··· 216 return nil 217 } 218 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } ··· 244 return result 245 } 246 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 + "log" 8 "os" 9 "os/exec" 10 "regexp" ··· 13 "github.com/dgraph-io/ristretto" 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 18 ) 19 20 type MergeCheckCache struct { ··· 35 mergeCheckCache = MergeCheckCache{cache} 36 } 37 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 39 sep := byte(':') 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 41 return fmt.Sprintf("%x", hash) ··· 52 } 53 } 54 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 56 key := m.cacheKey(g, patch, targetBranch) 57 val := m.cacheVal(mergeCheck) 58 m.cache.Set(key, val, 0) 59 } 60 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 62 key := m.cacheKey(g, patch, targetBranch) 63 if val, ok := m.cache.Get(key); ok { 64 if val == struct{}{} { ··· 107 return fmt.Sprintf("merge failed: %s", e.Message) 108 } 109 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 112 if err != nil { 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 114 } 115 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 117 tmpFile.Close() 118 os.Remove(tmpFile.Name()) 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 165 return nil 166 } 167 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 var stderr bytes.Buffer 170 var cmd *exec.Cmd 171 172 // configure default git user before merge 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 176 177 // if patch is a format-patch, apply using 'git am' 178 if opts.FormatPatch { 179 + return g.applyMailbox(patchData) 180 + } 181 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 + } 188 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 + } 193 194 + commitArgs := []string{"-C", g.path, "commit"} 195 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 199 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 204 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 212 213 cmd.Stderr = &stderr 214 ··· 219 return nil 220 } 221 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 { 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 return val 330 } ··· 352 return result 353 } 354 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 356 patchFile, err := g.createTempFileWithPatch(patchData) 357 if err != nil { 358 return &ErrMerge{ ··· 371 } 372 defer os.RemoveAll(tmpDir) 373 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 380 return err 381 } 382
+1 -3
knotserver/git/tag.go
··· 2 3 import ( 4 "fmt" 5 - "slices" 6 "strconv" 7 "strings" 8 "time" ··· 35 outFormat.WriteString("") 36 outFormat.WriteString(recordSeparator) 37 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 if err != nil { 40 return nil, fmt.Errorf("failed to get tags: %w", err) 41 } ··· 94 tags = append(tags, tag) 95 } 96 97 - slices.Reverse(tags) 98 return tags, nil 99 }
··· 2 3 import ( 4 "fmt" 5 "strconv" 6 "strings" 7 "time" ··· 34 outFormat.WriteString("") 35 outFormat.WriteString(recordSeparator) 36 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 38 if err != nil { 39 return nil, fmt.Errorf("failed to get tags: %w", err) 40 } ··· 93 tags = append(tags, tag) 94 } 95 96 return tags, nil 97 }
+18 -18
knotserver/git.go
··· 13 "tangled.org/core/knotserver/git/service" 14 ) 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) 20 if err != nil { 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 return 24 } 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 27 if err != nil { 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 return 31 } 32 ··· 46 47 if err := cmd.InfoRefs(); err != nil { 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 return 51 } 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 54 default: 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 } 57 } 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 63 if err != nil { 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 return 67 } 68 ··· 77 gzipReader, err := gzip.NewReader(r.Body) 78 if err != nil { 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 return 82 } 83 defer gzipReader.Close() ··· 88 w.Header().Set("Connection", "Keep-Alive") 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 93 cmd := service.ServiceCommand{ 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 w.WriteHeader(http.StatusOK) 101 102 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 return 105 } 106 } 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 112 if err != nil { 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 return 116 } 117 118 - d.RejectPush(w, r, name) 119 } 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 }
··· 13 "tangled.org/core/knotserver/git/service" 14 ) 15 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) 20 if err != nil { 21 gitError(w, "repository not found", http.StatusNotFound) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 return 24 } 25 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 if err != nil { 28 gitError(w, "repository not found", http.StatusNotFound) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 return 31 } 32 ··· 46 47 if err := cmd.InfoRefs(); err != nil { 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 return 51 } 52 case "git-receive-pack": 53 + h.RejectPush(w, r, name) 54 default: 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 } 57 } 58 59 + func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 if err != nil { 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 return 67 } 68 ··· 77 gzipReader, err := gzip.NewReader(r.Body) 78 if err != nil { 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 return 82 } 83 defer gzipReader.Close() ··· 88 w.Header().Set("Connection", "Keep-Alive") 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 91 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 93 cmd := service.ServiceCommand{ 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 w.WriteHeader(http.StatusOK) 101 102 if err := cmd.UploadPack(); err != nil { 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 return 105 } 106 } 107 108 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 if err != nil { 113 gitError(w, err.Error(), http.StatusForbidden) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 return 116 } 117 118 + h.RejectPush(w, r, name) 119 } 120 121 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 + hostname := h.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 }
-4
knotserver/http_util.go
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 }
+50 -1
knotserver/internal.go
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/hook" 18 "tangled.org/core/knotserver/config" 19 "tangled.org/core/knotserver/db" 20 "tangled.org/core/knotserver/git" 21 "tangled.org/core/notifier" 22 "tangled.org/core/rbac" 23 "tangled.org/core/workflow" ··· 118 // non-fatal 119 } 120 121 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 if err != nil { 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 128 writeJSON(w, resp) 129 } 130 131 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 132 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 133 if err != nil { ··· 268 return h.db.InsertEvent(event, h.n) 269 } 270 271 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 272 r := chi.NewRouter() 273 274 h := InternalHandle{ 275 db,
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 24 "tangled.org/core/notifier" 25 "tangled.org/core/rbac" 26 "tangled.org/core/workflow" ··· 121 // non-fatal 122 } 123 124 + if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 125 + msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 126 + if err != nil { 127 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 128 + // non-fatal 129 + } else { 130 + for msgLine := range msg { 131 + resp.Messages = append(resp.Messages, msg[msgLine]) 132 + } 133 + } 134 + } 135 + 136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 137 if err != nil { 138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 143 writeJSON(w, resp) 144 } 145 146 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 + l := h.l.With("handler", "replyCompare") 148 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 + user := repoOwner 150 + if err != nil { 151 + l.Error("Failed to fetch user identity", "err", err) 152 + // non-fatal 153 + } else { 154 + user = userIdent.Handle.String() 155 + } 156 + gr, err := git.PlainOpen(gitRelativeDir) 157 + if err != nil { 158 + l.Error("Failed to open git repository", "err", err) 159 + return []string{}, err 160 + } 161 + defaultBranch, err := gr.FindMainBranch() 162 + if err != nil { 163 + l.Error("Failed to fetch default branch", "err", err) 164 + return []string{}, err 165 + } 166 + if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 167 + return []string{}, nil 168 + } 169 + ZWS := "\u200B" 170 + var msg []string 171 + msg = append(msg, ZWS) 172 + msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 173 + msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 174 + msg = append(msg, ZWS) 175 + return msg, nil 176 + } 177 + 178 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 180 if err != nil { ··· 315 return h.db.InsertEvent(event, h.n) 316 } 317 318 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 r := chi.NewRouter() 320 + l := log.FromContext(ctx) 321 + l = log.SubLogger(l, "internal") 322 323 h := InternalHandle{ 324 db,
+35
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 + }
+16 -9
knotserver/router.go
··· 12 "tangled.org/core/knotserver/config" 13 "tangled.org/core/knotserver/db" 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 16 "tangled.org/core/notifier" 17 "tangled.org/core/rbac" 18 "tangled.org/core/xrpc/serviceauth" ··· 28 resolver *idresolver.Resolver 29 } 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 - 34 h := Knot{ 35 c: c, 36 db: db, 37 e: e, 38 - l: l, 39 jc: jc, 40 n: n, 41 resolver: idresolver.DefaultResolver(), ··· 67 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 } 69 70 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 }) ··· 86 // Socket that streams git oplogs 87 r.Get("/events", h.Events) 88 89 - return r, nil 90 } 91 92 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 94 95 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 97 xrpc := &xrpc.Xrpc{ 98 Config: h.c, 99 Db: h.db, 100 Ingester: h.jc, 101 Enforcer: h.e, 102 - Logger: logger, 103 Notifier: h.n, 104 Resolver: h.resolver, 105 ServiceAuth: serviceAuth, 106 } 107 return xrpc.Router() 108 } 109
··· 12 "tangled.org/core/knotserver/config" 13 "tangled.org/core/knotserver/db" 14 "tangled.org/core/knotserver/xrpc" 15 + "tangled.org/core/log" 16 "tangled.org/core/notifier" 17 "tangled.org/core/rbac" 18 "tangled.org/core/xrpc/serviceauth" ··· 28 resolver *idresolver.Resolver 29 } 30 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 32 h := Knot{ 33 c: c, 34 db: db, 35 e: e, 36 + l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 resolver: idresolver.DefaultResolver(), ··· 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 66 } 67 68 + return h.Router(), nil 69 + } 70 + 71 + func (h *Knot) Router() http.Handler { 72 + r := chi.NewRouter() 73 + 74 + r.Use(h.RequestLogger) 75 + 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 77 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 78 }) ··· 92 // Socket that streams git oplogs 93 r.Get("/events", h.Events) 94 95 + return r 96 } 97 98 func (h *Knot) XrpcRouter() http.Handler { 99 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 100 101 + l := log.SubLogger(h.l, "xrpc") 102 103 xrpc := &xrpc.Xrpc{ 104 Config: h.c, 105 Db: h.db, 106 Ingester: h.jc, 107 Enforcer: h.e, 108 + Logger: l, 109 Notifier: h.n, 110 Resolver: h.resolver, 111 ServiceAuth: serviceAuth, 112 } 113 + 114 return xrpc.Router() 115 } 116
+5 -4
knotserver/server.go
··· 43 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 47 48 c, err := config.Load(ctx) 49 if err != nil { ··· 80 tangled.KnotMemberNSID, 81 tangled.RepoPullNSID, 82 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 if err != nil { 85 logger.Error("failed to setup jetstream", "error", err) 86 } 87 88 notifier := notifier.New() 89 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 if err != nil { 92 return fmt.Errorf("failed to setup server: %w", err) 93 } 94 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 97 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
··· 43 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 logger := log.FromContext(ctx) 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 48 49 c, err := config.Load(ctx) 50 if err != nil { ··· 81 tangled.KnotMemberNSID, 82 tangled.RepoPullNSID, 83 tangled.RepoCollaboratorNSID, 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 85 if err != nil { 86 logger.Error("failed to setup jetstream", "error", err) 87 } 88 89 notifier := notifier.New() 90 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 92 if err != nil { 93 return fmt.Errorf("failed to setup server: %w", err) 94 } 95 96 + imux := Internal(ctx, c, db, e, &notifier) 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 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 mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 if err != nil { 90 var mergeErr *git.ErrMerge 91 if errors.As(err, &mergeErr) {
··· 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 if err != nil { 90 var mergeErr *git.ErrMerge 91 if errors.As(err, &mergeErr) {
+3 -1
knotserver/xrpc/merge_check.go
··· 51 return 52 } 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false, ··· 80 response.Error = &errMsg 81 } 82 } 83 84 w.Header().Set("Content-Type", "application/json") 85 w.WriteHeader(http.StatusOK)
··· 51 return 52 } 53 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false, ··· 80 response.Error = &errMsg 81 } 82 } 83 + 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 85 86 w.Header().Set("Content-Type", "application/json") 87 w.WriteHeader(http.StatusOK)
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 "fmt" 5 "net/http" 6 7 "tangled.org/core/knotserver/git" 8 "tangled.org/core/types" 9 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 return 72 } 73 74 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 79 } 80 81 writeJson(w, response)
··· 4 "fmt" 5 "net/http" 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 "tangled.org/core/knotserver/git" 9 "tangled.org/core/types" 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 72 return 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 + 88 response := types.RepoFormatPatchResponse{ 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 95 } 96 97 writeJson(w, response)
+24
knotserver/xrpc/repo_tree.go
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 8 "tangled.org/core/api/tangled" 9 "tangled.org/core/knotserver/git" 10 xrpcerr "tangled.org/core/xrpc/errors" 11 ) ··· 43 return 44 } 45 46 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 for i, file := range files { ··· 83 Parent: parentPtr, 84 Dotdot: dotdotPtr, 85 Files: treeEntries, 86 } 87 88 writeJson(w, response)
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 + "unicode/utf8" 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 11 "tangled.org/core/knotserver/git" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) ··· 45 return 46 } 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 + 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 for i, file := range files { ··· 103 Parent: parentPtr, 104 Dotdot: dotdotPtr, 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 110 } 111 112 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
··· 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 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.
···
+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 +
+19
lexicons/repo/tree.json
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 "files": { 45 "type": "array", 46 "items": { ··· 69 "description": "Invalid request parameters" 70 } 71 ] 72 }, 73 "treeEntry": { 74 "type": "object",
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 49 "files": { 50 "type": "array", 51 "items": { ··· 74 "description": "Invalid request parameters" 75 } 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 + } 91 }, 92 "treeEntry": { 93 "type": "object",
+23 -9
log/log.go
··· 4 "context" 5 "log/slog" 6 "os" 7 ) 8 9 - // NewHandler sets up a new slog.Handler with the service name 10 - // as an attribute 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 - Level: slog.LevelDebug, 14 }) 15 - 16 - var attrs []slog.Attr 17 - attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)}) 18 - handler.WithAttrs(attrs) 19 - return handler 20 } 21 22 func New(name string) *slog.Logger { ··· 49 50 return slog.Default() 51 }
··· 4 "context" 5 "log/slog" 6 "os" 7 + 8 + "github.com/charmbracelet/log" 9 ) 10 11 func NewHandler(name string) slog.Handler { 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 16 }) 17 } 18 19 func New(name string) *slog.Logger { ··· 46 47 return slog.Default() 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 + }
+62 -11
nix/gomod2nix.toml
··· 29 [mod."github.com/avast/retry-go/v4"] 30 version = "v4.6.1" 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 [mod."github.com/aymerick/douceur"] 33 version = "v0.2.0" 34 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" ··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 63 [mod."github.com/cespare/xxhash/v2"] 64 version = "v2.3.0" 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 66 [mod."github.com/cloudflare/circl"] 67 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 145 [mod."github.com/go-jose/go-jose/v3"] 146 version = "v3.0.4" 147 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 148 [mod."github.com/go-logr/logr"] 149 version = "v1.4.3" 150 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 163 [mod."github.com/gogo/protobuf"] 164 version = "v1.3.2" 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 [mod."github.com/golang-jwt/jwt/v5"] 167 version = "v5.2.3" 168 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 295 [mod."github.com/lestrrat-go/option"] 296 version = "v1.0.1" 297 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 298 [mod."github.com/mattn/go-isatty"] 299 version = "v0.0.20" 300 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 301 [mod."github.com/mattn/go-sqlite3"] 302 version = "v1.14.24" 303 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 325 [mod."github.com/mr-tron/base58"] 326 version = "v1.2.0" 327 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 328 [mod."github.com/multiformats/go-base32"] 329 version = "v0.1.0" 330 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 391 [mod."github.com/resend/resend-go/v2"] 392 version = "v2.15.0" 393 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 394 [mod."github.com/ryanuber/go-glob"] 395 version = "v1.0.0" 396 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 407 [mod."github.com/spaolacci/murmur3"] 408 version = "v1.1.0" 409 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 410 [mod."github.com/stretchr/testify"] 411 version = "v1.10.0" 412 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 431 [mod."github.com/wyatt915/treeblood"] 432 version = "v0.1.15" 433 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 version = "v2.0.0-20230729083705-37449abec8cc" 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 440 [mod."gitlab.com/yawning/secp256k1-voi"] 441 version = "v0.0.0-20230925100816-f2616030848b" 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 479 [mod."golang.org/x/exp"] 480 version = "v0.0.0-20250620022241-b7579e27df2b" 481 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 482 [mod."golang.org/x/net"] 483 version = "v0.42.0" 484 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 488 [mod."golang.org/x/sys"] 489 version = "v0.34.0" 490 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 494 [mod."golang.org/x/time"] 495 version = "v0.12.0" 496 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 [mod."lukechampine.com/blake3"] 528 version = "v1.4.1" 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 - version = "v0.0.0-20250724194903-28e660378cb1" 532 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
··· 29 [mod."github.com/avast/retry-go/v4"] 30 version = "v4.6.1" 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 + [mod."github.com/aymanbagabas/go-osc52/v2"] 33 + version = "v2.0.1" 34 + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 35 [mod."github.com/aymerick/douceur"] 36 version = "v0.2.0" 37 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" ··· 43 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 44 replaced = "tangled.sh/oppi.li/go-gitdiff" 45 [mod."github.com/bluesky-social/indigo"] 46 + version = "v0.0.0-20251003000214-3259b215110e" 47 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 48 [mod."github.com/bluesky-social/jetstream"] 49 version = "v0.0.0-20241210005130-ea96859b93d1" 50 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 66 [mod."github.com/cespare/xxhash/v2"] 67 version = "v2.3.0" 68 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 69 + [mod."github.com/charmbracelet/colorprofile"] 70 + version = "v0.2.3-0.20250311203215-f60798e515dc" 71 + hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" 72 + [mod."github.com/charmbracelet/lipgloss"] 73 + version = "v1.1.0" 74 + hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" 75 + [mod."github.com/charmbracelet/log"] 76 + version = "v0.4.2" 77 + hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=" 78 + [mod."github.com/charmbracelet/x/ansi"] 79 + version = "v0.8.0" 80 + hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas=" 81 + [mod."github.com/charmbracelet/x/cellbuf"] 82 + version = "v0.0.13-0.20250311204145-2c3ea96c31dd" 83 + hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" 84 + [mod."github.com/charmbracelet/x/term"] 85 + version = "v0.2.1" 86 + hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" 87 [mod."github.com/cloudflare/circl"] 88 version = "v1.6.2-0.20250618153321-aa837fd1539d" 89 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 166 [mod."github.com/go-jose/go-jose/v3"] 167 version = "v3.0.4" 168 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 169 + [mod."github.com/go-logfmt/logfmt"] 170 + version = "v0.6.0" 171 + hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 172 [mod."github.com/go-logr/logr"] 173 version = "v1.4.3" 174 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 187 [mod."github.com/gogo/protobuf"] 188 version = "v1.3.2" 189 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 190 + [mod."github.com/goki/freetype"] 191 + version = "v1.0.5" 192 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 193 [mod."github.com/golang-jwt/jwt/v5"] 194 version = "v5.2.3" 195 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 322 [mod."github.com/lestrrat-go/option"] 323 version = "v1.0.1" 324 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 325 + [mod."github.com/lucasb-eyer/go-colorful"] 326 + version = "v1.2.0" 327 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 328 [mod."github.com/mattn/go-isatty"] 329 version = "v0.0.20" 330 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 331 + [mod."github.com/mattn/go-runewidth"] 332 + version = "v0.0.16" 333 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 334 [mod."github.com/mattn/go-sqlite3"] 335 version = "v1.14.24" 336 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 358 [mod."github.com/mr-tron/base58"] 359 version = "v1.2.0" 360 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 361 + [mod."github.com/muesli/termenv"] 362 + version = "v0.16.0" 363 + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 364 [mod."github.com/multiformats/go-base32"] 365 version = "v0.1.0" 366 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 427 [mod."github.com/resend/resend-go/v2"] 428 version = "v2.15.0" 429 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 430 + [mod."github.com/rivo/uniseg"] 431 + version = "v0.4.7" 432 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 433 [mod."github.com/ryanuber/go-glob"] 434 version = "v1.0.0" 435 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 446 [mod."github.com/spaolacci/murmur3"] 447 version = "v1.1.0" 448 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 449 + [mod."github.com/srwiley/oksvg"] 450 + version = "v0.0.0-20221011165216-be6e8873101c" 451 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 452 + [mod."github.com/srwiley/rasterx"] 453 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 454 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 455 [mod."github.com/stretchr/testify"] 456 version = "v1.10.0" 457 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 476 [mod."github.com/wyatt915/treeblood"] 477 version = "v0.1.15" 478 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 479 + [mod."github.com/xo/terminfo"] 480 + version = "v0.0.0-20220910002029-abceb7e1c41e" 481 + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 482 [mod."github.com/yuin/goldmark"] 483 + version = "v1.7.13" 484 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 485 [mod."github.com/yuin/goldmark-highlighting/v2"] 486 version = "v2.0.0-20230729083705-37449abec8cc" 487 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 488 + [mod."gitlab.com/staticnoise/goldmark-callout"] 489 + version = "v0.0.0-20240609120641-6366b799e4ab" 490 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 491 [mod."gitlab.com/yawning/secp256k1-voi"] 492 version = "v0.0.0-20230925100816-f2616030848b" 493 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 530 [mod."golang.org/x/exp"] 531 version = "v0.0.0-20250620022241-b7579e27df2b" 532 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 533 + [mod."golang.org/x/image"] 534 + version = "v0.31.0" 535 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 536 [mod."golang.org/x/net"] 537 version = "v0.42.0" 538 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 539 [mod."golang.org/x/sync"] 540 + version = "v0.17.0" 541 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 542 [mod."golang.org/x/sys"] 543 version = "v0.34.0" 544 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 545 [mod."golang.org/x/text"] 546 + version = "v0.29.0" 547 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 548 [mod."golang.org/x/time"] 549 version = "v0.12.0" 550 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 581 [mod."lukechampine.com/blake3"] 582 version = "v1.4.1" 583 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
+2 -2
nix/modules/knot.nix
··· 22 23 appviewEndpoint = mkOption { 24 type = types.str; 25 - default = "https://tangled.sh"; 26 description = "Appview endpoint"; 27 }; 28 ··· 107 108 hostname = mkOption { 109 type = types.str; 110 - example = "knot.tangled.sh"; 111 description = "Hostname for the server (required)"; 112 }; 113
··· 22 23 appviewEndpoint = mkOption { 24 type = types.str; 25 + default = "https://tangled.org"; 26 description = "Appview endpoint"; 27 }; 28 ··· 107 108 hostname = mkOption { 109 type = types.str; 110 + example = "my.knot.com"; 111 description = "Hostname for the server (required)"; 112 }; 113
+2 -2
nix/modules/spindle.nix
··· 33 34 hostname = mkOption { 35 type = types.str; 36 - example = "spindle.tangled.sh"; 37 description = "Hostname for the server (required)"; 38 }; 39 ··· 92 pipelines = { 93 nixery = mkOption { 94 type = types.str; 95 - default = "nixery.tangled.sh"; 96 description = "Nixery instance to use"; 97 }; 98
··· 33 34 hostname = mkOption { 35 type = types.str; 36 + example = "my.spindle.com"; 37 description = "Hostname for the server (required)"; 38 }; 39 ··· 92 pipelines = { 93 nixery = mkOption { 94 type = types.str; 95 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 description = "Nixery instance to use"; 97 }; 98
+1
nix/pkgs/appview-static-files.nix
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 # for whatever reason (produces broken css), so we are doing this instead
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 # for whatever reason (produces broken css), so we are doing this instead
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 sqlite-lib, 5 src, 6 }: let 7 - version = "1.9.0-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
··· 4 sqlite-lib, 5 src, 6 }: let 7 + version = "1.9.1-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
+18 -7
patchutil/patchutil.go
··· 1 package patchutil 2 3 import ( 4 "fmt" 5 "log" 6 "os" ··· 42 // IsPatchValid checks if the given patch string is valid. 43 // It performs very basic sniffing for either git-diff or git-format-patch 44 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 if len(patch) == 0 { 47 - return false 48 } 49 50 lines := strings.Split(patch, "\n") 51 if len(lines) < 2 { 52 - return false 53 } 54 55 firstLine := strings.TrimSpace(lines[0]) ··· 60 strings.HasPrefix(firstLine, "Index: ") || 61 strings.HasPrefix(firstLine, "+++ ") || 62 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 64 } 65 66 // check if it's format-patch ··· 70 // it's safe to say it's broken. 71 patches, err := ExtractPatches(patch) 72 if err != nil { 73 - return false 74 } 75 - return len(patches) > 0 76 } 77 78 - return false 79 } 80 81 func IsFormatPatch(patch string) bool {
··· 1 package patchutil 2 3 import ( 4 + "errors" 5 "fmt" 6 "log" 7 "os" ··· 43 // IsPatchValid checks if the given patch string is valid. 44 // It performs very basic sniffing for either git-diff or git-format-patch 45 // header lines. For format patches, it attempts to extract and validate each one. 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 53 if len(patch) == 0 { 54 + return EmptyPatchError 55 } 56 57 lines := strings.Split(patch, "\n") 58 if len(lines) < 2 { 59 + return EmptyPatchError 60 } 61 62 firstLine := strings.TrimSpace(lines[0]) ··· 67 strings.HasPrefix(firstLine, "Index: ") || 68 strings.HasPrefix(firstLine, "+++ ") || 69 strings.HasPrefix(firstLine, "@@ ") { 70 + return nil 71 } 72 73 // check if it's format-patch ··· 77 // it's safe to say it's broken. 78 patches, err := ExtractPatches(patch) 79 if err != nil { 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 81 } 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 87 } 88 89 + return GenericPatchError 90 } 91 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 package patchutil 2 3 import ( 4 "reflect" 5 "testing" 6 ) ··· 9 tests := []struct { 10 name string 11 patch string 12 - expected bool 13 }{ 14 { 15 name: `empty patch`, 16 patch: ``, 17 - expected: false, 18 }, 19 { 20 name: `single line patch`, 21 patch: `single line`, 22 - expected: false, 23 }, 24 { 25 name: `valid diff patch`, ··· 31 -old line 32 +new line 33 context`, 34 - expected: true, 35 }, 36 { 37 name: `valid patch starting with ---`, ··· 41 -old line 42 +new line 43 context`, 44 - expected: true, 45 }, 46 { 47 name: `valid patch starting with Index`, ··· 53 -old line 54 +new line 55 context`, 56 - expected: true, 57 }, 58 { 59 name: `valid patch starting with +++`, ··· 63 -old line 64 +new line 65 context`, 66 - expected: true, 67 }, 68 { 69 name: `valid patch starting with @@`, ··· 72 +new line 73 context 74 `, 75 - expected: true, 76 }, 77 { 78 name: `valid format patch`, ··· 90 +new content 91 -- 92 2.48.1`, 93 - expected: true, 94 }, 95 { 96 name: `invalid format patch`, 97 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 From: Author <author@example.com> 99 This is not a valid patch format`, 100 - expected: false, 101 }, 102 { 103 name: `not a patch at all`, ··· 105 just some 106 random text 107 that isn't a patch`, 108 - expected: false, 109 }, 110 } 111 112 for _, tt := range tests { 113 t.Run(tt.name, func(t *testing.T) { 114 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 } 118 })
··· 1 package patchutil 2 3 import ( 4 + "errors" 5 "reflect" 6 "testing" 7 ) ··· 10 tests := []struct { 11 name string 12 patch string 13 + expected error 14 }{ 15 { 16 name: `empty patch`, 17 patch: ``, 18 + expected: EmptyPatchError, 19 }, 20 { 21 name: `single line patch`, 22 patch: `single line`, 23 + expected: EmptyPatchError, 24 }, 25 { 26 name: `valid diff patch`, ··· 32 -old line 33 +new line 34 context`, 35 + expected: nil, 36 }, 37 { 38 name: `valid patch starting with ---`, ··· 42 -old line 43 +new line 44 context`, 45 + expected: nil, 46 }, 47 { 48 name: `valid patch starting with Index`, ··· 54 -old line 55 +new line 56 context`, 57 + expected: nil, 58 }, 59 { 60 name: `valid patch starting with +++`, ··· 64 -old line 65 +new line 66 context`, 67 + expected: nil, 68 }, 69 { 70 name: `valid patch starting with @@`, ··· 73 +new line 74 context 75 `, 76 + expected: nil, 77 }, 78 { 79 name: `valid format patch`, ··· 91 +new content 92 -- 93 2.48.1`, 94 + expected: nil, 95 }, 96 { 97 name: `invalid format patch`, 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 99 From: Author <author@example.com> 100 This is not a valid patch format`, 101 + expected: FormatPatchError, 102 }, 103 { 104 name: `not a patch at all`, ··· 106 just some 107 random text 108 that isn't a patch`, 109 + expected: GenericPatchError, 110 }, 111 } 112 113 for _, tt := range tests { 114 t.Run(tt.name, func(t *testing.T) { 115 result := IsPatchValid(tt.patch) 116 + if !errors.Is(result, tt.expected) { 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 118 } 119 })
+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 + }
+6 -6
spindle/server.go
··· 108 tangled.RepoNSID, 109 tangled.RepoCollaboratorNSID, 110 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 112 if err != nil { 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 } ··· 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 // job in the above registered queue. 173 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 175 ccfg.Dev = cfg.Server.Dev 176 ccfg.ProcessFunc = spindle.processPipeline 177 ccfg.CursorStore = cursorStore ··· 210 } 211 212 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 217 x := xrpc.Xrpc{ 218 - Logger: logger, 219 Db: s.db, 220 Enforcer: s.e, 221 Engines: s.engs, ··· 305 306 ok := s.jq.Enqueue(queue.Job{ 307 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 Workflows: workflows,
··· 108 tangled.RepoNSID, 109 tangled.RepoCollaboratorNSID, 110 } 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 if err != nil { 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 } ··· 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 // job in the above registered queue. 173 ccfg := eventconsumer.NewConsumerConfig() 174 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 ccfg.Dev = cfg.Server.Dev 176 ccfg.ProcessFunc = spindle.processPipeline 177 ccfg.CursorStore = cursorStore ··· 210 } 211 212 func (s *Spindle) XrpcRouter() http.Handler { 213 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 214 215 + l := log.SubLogger(s.l, "xrpc") 216 + 217 x := xrpc.Xrpc{ 218 + Logger: l, 219 Db: s.db, 220 Enforcer: s.e, 221 Engines: s.engs, ··· 305 306 ok := s.jq.Enqueue(queue.Job{ 307 Run: func() error { 308 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 Workflows: workflows,
+3 -3
spindle/stream.go
··· 10 "strconv" 11 "time" 12 13 "tangled.org/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" ··· 23 } 24 25 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 l.Debug("received new connection") 28 29 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 } 83 case <-time.After(30 * time.Second): 84 // send a keep-alive 85 - l.Debug("sent keepalive") 86 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 l.Error("failed to write control", "err", err) 88 } ··· 222 s.l.Debug("err", "err", err) 223 return err 224 } 225 - s.l.Debug("ops", "ops", events) 226 227 for _, event := range events { 228 // first extract the inner json into a map
··· 10 "strconv" 11 "time" 12 13 + "tangled.org/core/log" 14 "tangled.org/core/spindle/models" 15 16 "github.com/go-chi/chi/v5" ··· 24 } 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 27 + l := log.SubLogger(s.l, "eventstream") 28 + 29 l.Debug("received new connection") 30 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 84 } 85 case <-time.After(30 * time.Second): 86 // send a keep-alive 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 88 l.Error("failed to write control", "err", err) 89 } ··· 223 s.l.Debug("err", "err", err) 224 return err 225 } 226 227 for _, event := range events { 228 // first extract the inner json into a map
+14 -10
types/repo.go
··· 1 package types 2 3 import ( 4 "github.com/go-git/go-git/v5/plumbing/object" 5 ) 6 ··· 33 } 34 35 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"` 41 } 42 43 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"` 49 } 50 51 type TagReference struct {
··· 1 package types 2 3 import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 5 "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 ··· 34 } 35 36 type RepoFormatPatchResponse struct { 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 40 + FormatPatchRaw string `json:"patch,omitempty"` 41 + CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 42 + CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 43 } 44 45 type RepoTreeResponse struct { 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"` 53 } 54 55 type TagReference struct {
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 "tangled.org/core/idresolver" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 ··· 22 23 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 return &ServiceAuth{ 25 - logger: logger, 26 resolver: resolver, 27 audienceDid: audienceDid, 28 } ··· 30 31 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 token := r.Header.Get("Authorization") 36 token = strings.TrimPrefix(token, "Bearer ") 37 ··· 42 43 did, err := s.Validate(r.Context(), token, nil) 44 if err != nil { 45 - l.Error("signature verification failed", "err", err) 46 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 return 48 } 49 50 r = r.WithContext( 51 context.WithValue(r.Context(), ActorDid, did),
··· 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 13 xrpcerr "tangled.org/core/xrpc/errors" 14 ) 15 ··· 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 25 return &ServiceAuth{ 26 + logger: log.SubLogger(logger, "serviceauth"), 27 resolver: resolver, 28 audienceDid: audienceDid, 29 } ··· 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 token := r.Header.Get("Authorization") 35 token = strings.TrimPrefix(token, "Bearer ") 36 ··· 41 42 did, err := s.Validate(r.Context(), token, nil) 43 if err != nil { 44 + sa.logger.Error("signature verification failed", "err", err) 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 46 return 47 } 48 + 49 + sa.logger.Debug("valid signature", ActorDid, did) 50 51 r = r.WithContext( 52 context.WithValue(r.Context(), ActorDid, did),