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

Compare changes

Choose any two refs to compare.

Changed files
+8586 -3168
.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 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+30
api/tangled/repodeleteBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+10
api/tangled/repotree.go
··· 31 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 32 // parent: The parent path in the tree 33 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 34 36 // ref: The git reference used 35 37 Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 36 46 } 37 47 38 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 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"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
-1
appview/db/artifact.go
··· 67 67 ) 68 68 69 69 rows, err := e.Query(query, args...) 70 - 71 70 if err != nil { 72 71 return nil, err 73 72 }
+53
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + "time" 6 7 7 8 "tangled.org/core/appview/models" 8 9 ) ··· 59 60 60 61 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 61 62 } 63 + 64 + func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 + var collaborators []models.Collaborator 66 + var conditions []string 67 + var args []any 68 + for _, filter := range filters { 69 + conditions = append(conditions, filter.Condition()) 70 + args = append(args, filter.Arg()...) 71 + } 72 + whereClause := "" 73 + if conditions != nil { 74 + whereClause = " where " + strings.Join(conditions, " and ") 75 + } 76 + query := fmt.Sprintf(`select 77 + id, 78 + did, 79 + rkey, 80 + subject_did, 81 + repo_at, 82 + created 83 + from collaborators %s`, 84 + whereClause, 85 + ) 86 + rows, err := e.Query(query, args...) 87 + if err != nil { 88 + return nil, err 89 + } 90 + defer rows.Close() 91 + for rows.Next() { 92 + var collaborator models.Collaborator 93 + var createdAt string 94 + if err := rows.Scan( 95 + &collaborator.Id, 96 + &collaborator.Did, 97 + &collaborator.Rkey, 98 + &collaborator.SubjectDid, 99 + &collaborator.RepoAt, 100 + &createdAt, 101 + ); err != nil { 102 + return nil, err 103 + } 104 + collaborator.Created, err = time.Parse(time.RFC3339, createdAt) 105 + if err != nil { 106 + collaborator.Created = time.Now() 107 + } 108 + collaborators = append(collaborators, collaborator) 109 + } 110 + if err := rows.Err(); err != nil { 111 + return nil, err 112 + } 113 + return collaborators, nil 114 + }
+214 -34
appview/db/db.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "log" 7 + "log/slog" 8 8 "reflect" 9 9 "strings" 10 10 11 11 _ "github.com/mattn/go-sqlite3" 12 + "tangled.org/core/log" 12 13 ) 13 14 14 15 type DB struct { 15 16 *sql.DB 17 + logger *slog.Logger 16 18 } 17 19 18 20 type Execer interface { ··· 26 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 29 } 28 30 29 - func Make(dbPath string) (*DB, error) { 31 + func Make(ctx context.Context, dbPath string) (*DB, error) { 30 32 // https://github.com/mattn/go-sqlite3#connection-string 31 33 opts := []string{ 32 34 "_foreign_keys=1", ··· 35 37 "_auto_vacuum=incremental", 36 38 } 37 39 40 + logger := log.FromContext(ctx) 41 + logger = log.SubLogger(logger, "db") 42 + 38 43 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 44 if err != nil { 40 45 return nil, err 41 46 } 42 - 43 - ctx := context.Background() 44 47 45 48 conn, err := db.Conn(ctx) 46 49 if err != nil { ··· 530 533 unique (repo_at, label_at) 531 534 ); 532 535 536 + create table if not exists notifications ( 537 + id integer primary key autoincrement, 538 + recipient_did text not null, 539 + actor_did text not null, 540 + type text not null, 541 + entity_type text not null, 542 + entity_id text not null, 543 + read integer not null default 0, 544 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 545 + repo_id integer references repos(id), 546 + issue_id integer references issues(id), 547 + pull_id integer references pulls(id) 548 + ); 549 + 550 + create table if not exists notification_preferences ( 551 + id integer primary key autoincrement, 552 + user_did text not null unique, 553 + repo_starred integer not null default 1, 554 + issue_created integer not null default 1, 555 + issue_commented integer not null default 1, 556 + pull_created integer not null default 1, 557 + pull_commented integer not null default 1, 558 + followed integer not null default 1, 559 + pull_merged integer not null default 1, 560 + issue_closed integer not null default 1, 561 + email_notifications integer not null default 0 562 + ); 563 + 533 564 create table if not exists migrations ( 534 565 id integer primary key autoincrement, 535 566 name text unique 536 567 ); 537 568 538 - -- indexes for better star query performance 569 + -- indexes for better performance 570 + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 539 572 create index if not exists idx_stars_created on stars(created); 540 573 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 541 574 `) ··· 544 577 } 545 578 546 579 // run migrations 547 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 580 + runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 548 581 tx.Exec(` 549 582 alter table repos add column description text check (length(description) <= 200); 550 583 `) 551 584 return nil 552 585 }) 553 586 554 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 587 + runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 555 588 // add unconstrained column 556 589 _, err := tx.Exec(` 557 590 alter table public_keys ··· 574 607 return nil 575 608 }) 576 609 577 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 610 + runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 578 611 _, err := tx.Exec(` 579 612 alter table comments drop column comment_at; 580 613 alter table comments add column rkey text; ··· 582 615 return err 583 616 }) 584 617 585 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 618 + runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 586 619 _, err := tx.Exec(` 587 620 alter table comments add column deleted text; -- timestamp 588 621 alter table comments add column edited text; -- timestamp ··· 590 623 return err 591 624 }) 592 625 593 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 626 + runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 594 627 _, err := tx.Exec(` 595 628 alter table pulls add column source_branch text; 596 629 alter table pulls add column source_repo_at text; ··· 599 632 return err 600 633 }) 601 634 602 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 635 + runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 603 636 _, err := tx.Exec(` 604 637 alter table repos add column source text; 605 638 `) ··· 611 644 // 612 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 613 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 614 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 647 + runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 615 648 _, err := tx.Exec(` 616 649 create table pulls_new ( 617 650 -- identifiers ··· 668 701 }) 669 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 670 703 671 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 704 + runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 672 705 tx.Exec(` 673 706 alter table repos add column spindle text; 674 707 `) ··· 678 711 // drop all knot secrets, add unique constraint to knots 679 712 // 680 713 // knots will henceforth use service auth for signed requests 681 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 714 + runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 682 715 _, err := tx.Exec(` 683 716 create table registrations_new ( 684 717 id integer primary key autoincrement, ··· 701 734 }) 702 735 703 736 // recreate and add rkey + created columns with default constraint 704 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 737 + runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 705 738 // create new table 706 739 // - repo_at instead of repo integer 707 740 // - rkey field ··· 755 788 return err 756 789 }) 757 790 758 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 791 + runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 759 792 _, err := tx.Exec(` 760 793 alter table issues add column rkey text not null default ''; 761 794 ··· 767 800 }) 768 801 769 802 // repurpose the read-only column to "needs-upgrade" 770 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 803 + runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 771 804 _, err := tx.Exec(` 772 805 alter table registrations rename column read_only to needs_upgrade; 773 806 `) ··· 775 808 }) 776 809 777 810 // require all knots to upgrade after the release of total xrpc 778 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 811 + runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 779 812 _, err := tx.Exec(` 780 813 update registrations set needs_upgrade = 1; 781 814 `) ··· 783 816 }) 784 817 785 818 // require all knots to upgrade after the release of total xrpc 786 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 819 + runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 787 820 _, err := tx.Exec(` 788 821 alter table spindles add column needs_upgrade integer not null default 0; 789 - `) 790 - if err != nil { 791 - return err 792 - } 793 - 794 - _, err = tx.Exec(` 795 - update spindles set needs_upgrade = 1; 796 822 `) 797 823 return err 798 824 }) ··· 808 834 // 809 835 // disable foreign-keys for the next migration 810 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 811 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 837 + runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 812 838 _, err := tx.Exec(` 813 839 create table if not exists issues_new ( 814 840 -- identifiers ··· 878 904 // - new columns 879 905 // * column "reply_to" which can be any other comment 880 906 // * column "at-uri" which is a generated column 881 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 907 + runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 882 908 _, err := tx.Exec(` 883 909 create table if not exists issue_comments ( 884 910 -- identifiers ··· 931 957 return err 932 958 }) 933 959 934 - return &DB{db}, nil 960 + // add generated at_uri column to pulls table 961 + // 962 + // this requires a full table recreation because stored columns 963 + // cannot be added via alter 964 + // 965 + // disable foreign-keys for the next migration 966 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 967 + runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 968 + _, err := tx.Exec(` 969 + create table if not exists pulls_new ( 970 + -- identifiers 971 + id integer primary key autoincrement, 972 + pull_id integer not null, 973 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 974 + 975 + -- at identifiers 976 + repo_at text not null, 977 + owner_did text not null, 978 + rkey text not null, 979 + 980 + -- content 981 + title text not null, 982 + body text not null, 983 + target_branch text not null, 984 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 985 + 986 + -- source info 987 + source_branch text, 988 + source_repo_at text, 989 + 990 + -- stacking 991 + stack_id text, 992 + change_id text, 993 + parent_change_id text, 994 + 995 + -- meta 996 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 997 + 998 + -- constraints 999 + unique(repo_at, pull_id), 1000 + unique(at_uri), 1001 + foreign key (repo_at) references repos(at_uri) on delete cascade 1002 + ); 1003 + `) 1004 + if err != nil { 1005 + return err 1006 + } 1007 + 1008 + // transfer data 1009 + _, err = tx.Exec(` 1010 + insert into pulls_new ( 1011 + id, pull_id, repo_at, owner_did, rkey, 1012 + title, body, target_branch, state, 1013 + source_branch, source_repo_at, 1014 + stack_id, change_id, parent_change_id, 1015 + created 1016 + ) 1017 + select 1018 + id, pull_id, repo_at, owner_did, rkey, 1019 + title, body, target_branch, state, 1020 + source_branch, source_repo_at, 1021 + stack_id, change_id, parent_change_id, 1022 + created 1023 + from pulls; 1024 + `) 1025 + if err != nil { 1026 + return err 1027 + } 1028 + 1029 + // drop old table 1030 + _, err = tx.Exec(`drop table pulls`) 1031 + if err != nil { 1032 + return err 1033 + } 1034 + 1035 + // rename new table 1036 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1037 + return err 1038 + }) 1039 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1040 + 1041 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1042 + // 1043 + // this requires a full table recreation because stored columns 1044 + // cannot be added via alter 1045 + // 1046 + // disable foreign-keys for the next migration 1047 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1048 + runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1049 + _, err := tx.Exec(` 1050 + create table if not exists pull_submissions_new ( 1051 + -- identifiers 1052 + id integer primary key autoincrement, 1053 + pull_at text not null, 1054 + 1055 + -- content, these are immutable, and require a resubmission to update 1056 + round_number integer not null default 0, 1057 + patch text, 1058 + source_rev text, 1059 + 1060 + -- meta 1061 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1062 + 1063 + -- constraints 1064 + unique(pull_at, round_number), 1065 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1066 + ); 1067 + `) 1068 + if err != nil { 1069 + return err 1070 + } 1071 + 1072 + // transfer data, constructing pull_at from pulls table 1073 + _, err = tx.Exec(` 1074 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1075 + select 1076 + ps.id, 1077 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1078 + ps.round_number, 1079 + ps.patch, 1080 + ps.created 1081 + from pull_submissions ps 1082 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1083 + `) 1084 + if err != nil { 1085 + return err 1086 + } 1087 + 1088 + // drop old table 1089 + _, err = tx.Exec(`drop table pull_submissions`) 1090 + if err != nil { 1091 + return err 1092 + } 1093 + 1094 + // rename new table 1095 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1096 + return err 1097 + }) 1098 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1099 + 1100 + // knots may report the combined patch for a comparison, we can store that on the appview side 1101 + // (but not on the pds record), because calculating the combined patch requires a git index 1102 + runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1103 + _, err := tx.Exec(` 1104 + alter table pull_submissions add column combined text; 1105 + `) 1106 + return err 1107 + }) 1108 + 1109 + return &DB{ 1110 + db, 1111 + logger, 1112 + }, nil 935 1113 } 936 1114 937 1115 type migrationFn = func(*sql.Tx) error 938 1116 939 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1117 + func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1118 + logger = logger.With("migration", name) 1119 + 940 1120 tx, err := c.BeginTx(context.Background(), nil) 941 1121 if err != nil { 942 1122 return err ··· 953 1133 // run migration 954 1134 err = migrationFn(tx) 955 1135 if err != nil { 956 - log.Printf("Failed to run migration %s: %v", name, err) 1136 + logger.Error("failed to run migration", "err", err) 957 1137 return err 958 1138 } 959 1139 960 1140 // mark migration as complete 961 1141 _, err = tx.Exec("insert into migrations (name) values (?)", name) 962 1142 if err != nil { 963 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 1143 + logger.Error("failed to mark migration as complete", "err", err) 964 1144 return err 965 1145 } 966 1146 ··· 969 1149 return err 970 1150 } 971 1151 972 - log.Printf("migration %s applied successfully", name) 1152 + logger.Info("migration applied successfully") 973 1153 } else { 974 - log.Printf("skipped migration %s, already applied", name) 1154 + logger.Warn("skipped migration, already applied") 975 1155 } 976 1156 977 1157 return nil
+13 -9
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 76 return make(map[string]string), nil 77 77 } 78 78 ··· 80 80 if isVerifiedFilter { 81 81 verifiedFilter = 1 82 82 } 83 + 84 + assoc := make(map[string]string) 83 85 84 86 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 87 89 88 90 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 92 98 } 93 99 94 100 query := ` ··· 104 110 return nil, err 105 111 } 106 112 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 113 110 114 for rows.Next() { 111 115 var email, did string
-20
appview/db/issues.go
··· 247 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 248 } 249 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 250 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 251 result, err := e.Exec( 272 252 `insert into issue_comments (
+34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+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 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 9 + "slices" 7 10 "sort" 8 11 "strings" 9 12 "time" ··· 55 58 parentChangeId = &pull.ParentChangeId 56 59 } 57 60 58 - _, err = tx.Exec( 61 + result, err := tx.Exec( 59 62 ` 60 63 insert into pulls ( 61 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 79 82 return err 80 83 } 81 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 + 82 92 _, err = tx.Exec(` 83 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 93 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 84 94 values (?, ?, ?, ?, ?) 85 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 86 96 return err 87 97 } 88 98 ··· 101 111 } 102 112 103 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 104 - pulls := make(map[int]*models.Pull) 114 + pulls := make(map[syntax.ATURI]*models.Pull) 105 115 106 116 var conditions []string 107 117 var args []any ··· 121 131 122 132 query := fmt.Sprintf(` 123 133 select 134 + id, 124 135 owner_did, 125 136 repo_at, 126 137 pull_id, ··· 154 165 var createdAt string 155 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 156 167 err := rows.Scan( 168 + &pull.ID, 157 169 &pull.OwnerDid, 158 170 &pull.RepoAt, 159 171 &pull.PullId, ··· 202 214 pull.ParentChangeId = parentChangeId.String 203 215 } 204 216 205 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 206 218 } 207 219 208 - // get latest round no. for each pull 209 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 210 - submissionsQuery := fmt.Sprintf(` 211 - select 212 - id, pull_id, round_number, patch, created, source_rev 213 - from 214 - pull_submissions 215 - where 216 - repo_at in (%s) and pull_id in (%s) 217 - `, inClause, inClause) 218 - 219 - args = make([]any, len(pulls)*2) 220 - idx := 0 221 - for _, p := range pulls { 222 - args[idx] = p.RepoAt 223 - idx += 1 224 - } 220 + var pullAts []syntax.ATURI 225 221 for _, p := range pulls { 226 - args[idx] = p.PullId 227 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 228 223 } 229 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 230 225 if err != nil { 231 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 232 227 } 233 - defer submissionsRows.Close() 234 228 235 - for submissionsRows.Next() { 236 - var s models.PullSubmission 237 - var sourceRev sql.NullString 238 - var createdAt string 239 - err := submissionsRows.Scan( 240 - &s.ID, 241 - &s.PullId, 242 - &s.RoundNumber, 243 - &s.Patch, 244 - &createdAt, 245 - &sourceRev, 246 - ) 247 - if err != nil { 248 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 249 232 } 233 + } 250 234 251 - createdTime, err := time.Parse(time.RFC3339, createdAt) 252 - if err != nil { 253 - return nil, err 254 - } 255 - s.Created = createdTime 256 - 257 - if sourceRev.Valid { 258 - s.SourceRev = sourceRev.String 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 259 243 } 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 244 } 269 245 270 - // get comment count on latest submission on each pull 271 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 272 - commentsQuery := fmt.Sprintf(` 273 - select 274 - count(id), pull_id 275 - from 276 - pull_comments 277 - where 278 - submission_id in (%s) 279 - group by 280 - submission_id 281 - `, inClause) 282 - 283 - args = []any{} 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 284 248 for _, p := range pulls { 285 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 286 252 } 287 - commentsRows, err := e.Query(commentsQuery, args...) 288 - if err != nil { 289 - return nil, err 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 290 256 } 291 - defer commentsRows.Close() 292 - 293 - for commentsRows.Next() { 294 - var commentCount, pullId int 295 - err := commentsRows.Scan( 296 - &commentCount, 297 - &pullId, 298 - ) 299 - if err != nil { 300 - return nil, err 301 - } 302 - if p, ok := pulls[pullId]; ok { 303 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 304 - } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 305 260 } 306 - if err := rows.Err(); err != nil { 307 - return nil, err 261 + for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 266 + } 308 267 } 309 268 310 269 orderedByPullId := []*models.Pull{} ··· 323 282 } 324 283 325 284 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 - ) 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 368 286 if err != nil { 369 287 return nil, err 370 288 } 371 - 372 - createdTime, err := time.Parse(time.RFC3339, createdAt) 373 - if err != nil { 374 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 375 291 } 376 - pull.Created = createdTime 377 292 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 - } 293 + return pulls[0], nil 294 + } 391 295 392 - if stackId.Valid { 393 - pull.StackId = stackId.String 394 - } 395 - if changeId.Valid { 396 - pull.ChangeId = changeId.String 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()...) 397 303 } 398 - if parentChangeId.Valid { 399 - pull.ParentChangeId = parentChangeId.String 304 + 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 400 308 } 401 309 402 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 403 311 select 404 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + combined, 317 + created, 318 + source_rev 405 319 from 406 320 pull_submissions 407 - where 408 - repo_at = ? and pull_id = ? 409 - ` 410 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 321 + %s 322 + order by 323 + round_number asc 324 + `, whereClause) 325 + 326 + rows, err := e.Query(query, args...) 411 327 if err != nil { 412 328 return nil, err 413 329 } 414 - defer submissionsRows.Close() 330 + defer rows.Close() 415 331 416 - submissionsMap := make(map[int]*models.PullSubmission) 332 + submissionMap := make(map[int]*models.PullSubmission) 417 333 418 - for submissionsRows.Next() { 334 + for rows.Next() { 419 335 var submission models.PullSubmission 420 336 var submissionCreatedStr string 421 - var submissionSourceRev sql.NullString 422 - err := submissionsRows.Scan( 337 + var submissionSourceRev, submissionCombined sql.NullString 338 + err := rows.Scan( 423 339 &submission.ID, 424 - &submission.PullId, 425 - &submission.RepoAt, 340 + &submission.PullAt, 426 341 &submission.RoundNumber, 427 342 &submission.Patch, 343 + &submissionCombined, 428 344 &submissionCreatedStr, 429 345 &submissionSourceRev, 430 346 ) ··· 432 348 return nil, err 433 349 } 434 350 435 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 436 - if err != nil { 437 - return nil, err 351 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 352 + submission.Created = t 438 353 } 439 - submission.Created = submissionCreatedTime 440 354 441 355 if submissionSourceRev.Valid { 442 356 submission.SourceRev = submissionSourceRev.String 443 357 } 444 358 445 - submissionsMap[submission.ID] = &submission 359 + if submissionCombined.Valid { 360 + submission.Combined = submissionCombined.String 361 + } 362 + 363 + submissionMap[submission.ID] = &submission 446 364 } 447 - if err = submissionsRows.Close(); err != nil { 365 + 366 + if err := rows.Err(); err != nil { 448 367 return nil, err 449 368 } 450 - if len(submissionsMap) == 0 { 451 - return &pull, nil 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 + } 452 380 } 453 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 454 400 var args []any 455 - for k := range submissionsMap { 456 - args = append(args, k) 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 ") 457 409 } 458 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 459 - commentsQuery := fmt.Sprintf(` 410 + 411 + query := fmt.Sprintf(` 460 412 select 461 413 id, 462 414 pull_id, ··· 468 420 created 469 421 from 470 422 pull_comments 471 - where 472 - submission_id IN (%s) 423 + %s 473 424 order by 474 425 created asc 475 - `, inClause) 476 - commentsRows, err := e.Query(commentsQuery, args...) 426 + `, whereClause) 427 + 428 + rows, err := e.Query(query, args...) 477 429 if err != nil { 478 430 return nil, err 479 431 } 480 - defer commentsRows.Close() 432 + defer rows.Close() 481 433 482 - for commentsRows.Next() { 434 + var comments []models.PullComment 435 + for rows.Next() { 483 436 var comment models.PullComment 484 - var commentCreatedStr string 485 - err := commentsRows.Scan( 437 + var createdAt string 438 + err := rows.Scan( 486 439 &comment.ID, 487 440 &comment.PullId, 488 441 &comment.SubmissionId, ··· 490 443 &comment.OwnerDid, 491 444 &comment.CommentAt, 492 445 &comment.Body, 493 - &commentCreatedStr, 446 + &createdAt, 494 447 ) 495 448 if err != nil { 496 449 return nil, err 497 450 } 498 451 499 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 500 - if err != nil { 501 - return nil, err 452 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 453 + comment.Created = t 502 454 } 503 - comment.Created = commentCreatedTime 504 455 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 - } 456 + comments = append(comments, comment) 525 457 } 526 458 527 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 528 - for _, submission := range submissionsMap { 529 - pull.Submissions[submission.RoundNumber] = submission 459 + if err := rows.Err(); err != nil { 460 + return nil, err 530 461 } 531 462 532 - return &pull, nil 463 + return comments, nil 533 464 } 534 465 535 466 // timeframe here is directly passed into the sql query filter, and any ··· 663 594 return err 664 595 } 665 596 666 - func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 667 - newRoundNumber := len(pull.Submissions) 597 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 668 598 _, err := e.Exec(` 669 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 599 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 670 600 values (?, ?, ?, ?, ?) 671 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 601 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 672 602 673 603 return err 674 604 }
+34 -7
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 67 81 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 71 91 } 72 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 73 99 } 74 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 75 102 } 76 103 77 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+36 -6
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 13 15 "tangled.org/core/appview/models" 14 16 ) 15 17 18 + type Repo struct { 19 + Id int64 20 + Did string 21 + Name string 22 + Knot string 23 + Rkey string 24 + Created time.Time 25 + Description string 26 + Spindle string 27 + 28 + // optionally, populate this when querying for reverse mappings 29 + RepoStats *models.RepoStats 30 + 31 + // optional 32 + Source string 33 + } 34 + 35 + func (r Repo) RepoAt() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 + } 38 + 39 + func (r Repo) DidSlashRepo() string { 40 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 + return p 42 + } 43 + 16 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 45 repoMap := make(map[syntax.ATURI]*models.Repo) 18 46 ··· 35 63 36 64 repoQuery := fmt.Sprintf( 37 65 `select 66 + id, 38 67 did, 39 68 name, 40 69 knot, ··· 63 92 var description, source, spindle sql.NullString 64 93 65 94 err := rows.Scan( 95 + &repo.Id, 66 96 &repo.Did, 67 97 &repo.Name, 68 98 &repo.Knot, ··· 327 357 var repo models.Repo 328 358 var nullableDescription sql.NullString 329 359 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 331 361 332 362 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 334 364 return nil, err 335 365 } 336 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 386 416 var repos []models.Repo 387 417 388 418 rows, err := e.Query( 389 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 390 420 from repos r 391 421 left join collaborators c on r.at_uri = c.repo_at 392 422 where (r.did = ? or c.subject_did = ?) ··· 406 436 var nullableDescription sql.NullString 407 437 var nullableSource sql.NullString 408 438 409 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 410 440 if err != nil { 411 441 return nil, err 412 442 } ··· 443 473 var nullableSource sql.NullString 444 474 445 475 row := e.QueryRow( 446 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 447 477 from repos 448 478 where did = ? and name = ? and source is not null and source != ''`, 449 479 did, name, 450 480 ) 451 481 452 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 453 483 if err != nil { 454 484 return nil, err 455 485 }
+38 -10
appview/db/timeline.go
··· 9 9 10 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 13 var events []models.TimelineEvent 14 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 16 29 if err != nil { 17 30 return nil, err 18 31 } 19 32 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 21 34 if err != nil { 22 35 return nil, err 23 36 } 24 37 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 26 39 if err != nil { 27 40 return nil, err 28 41 } ··· 70 83 return isStarred, starCount 71 84 } 72 85 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 86 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 + filters := make([]filter, 0) 88 + if userIsFollowing != nil { 89 + filters = append(filters, FilterIn("did", userIsFollowing)) 90 + } 91 + 92 + repos, err := GetRepos(e, limit, filters...) 75 93 if err != nil { 76 94 return nil, err 77 95 } ··· 125 143 return events, nil 126 144 } 127 145 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 146 + func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 + filters := make([]filter, 0) 148 + if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + } 151 + 152 + stars, err := GetStars(e, limit, filters...) 130 153 if err != nil { 131 154 return nil, err 132 155 } ··· 166 189 return events, nil 167 190 } 168 191 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 192 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 + filters := make([]filter, 0) 194 + if userIsFollowing != nil { 195 + filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 + } 197 + 198 + follows, err := GetFollows(e, limit, filters...) 171 199 if err != nil { 172 200 return nil, err 173 201 }
+4 -4
appview/dns/cloudflare.go
··· 30 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 31 } 32 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 35 Type: record.Type, 36 36 Name: record.Name, 37 37 Content: record.Content, ··· 39 39 Proxied: &record.Proxied, 40 40 }) 41 41 if err != nil { 42 - return fmt.Errorf("failed to create DNS record: %w", err) 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 43 } 44 - return nil 44 + return result.ID, nil 45 45 } 46 46 47 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+2 -2
appview/ingester.go
··· 89 89 } 90 90 91 91 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 92 + l.Warn("refused to ingest record", "err", err) 93 93 } 94 94 95 95 return nil ··· 1008 1008 if !ok { 1009 1009 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 1010 } 1011 - if err := i.Validator.ValidateLabelOp(def, &o); err != nil { 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 1012 return fmt.Errorf("failed to validate labelop: %w", err) 1013 1013 } 1014 1014 }
+54 -40
appview/issues/issues.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "slices" 12 11 "time" 13 12 14 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 17 "github.com/go-chi/chi/v5" ··· 26 26 "tangled.org/core/appview/pagination" 27 27 "tangled.org/core/appview/reporesolver" 28 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 29 "tangled.org/core/idresolver" 31 - tlog "tangled.org/core/log" 32 30 "tangled.org/core/tid" 33 31 ) 34 32 ··· 53 51 config *config.Config, 54 52 notifier notify.Notifier, 55 53 validator *validator.Validator, 54 + logger *slog.Logger, 56 55 ) *Issues { 57 56 return &Issues{ 58 57 oauth: oauth, ··· 62 61 db: db, 63 62 config: config, 64 63 notifier: notifier, 65 - logger: tlog.New("issues"), 64 + logger: logger, 66 65 validator: validator, 67 66 } 68 67 } ··· 72 71 user := rp.oauth.GetUser(r) 73 72 f, err := rp.repoResolver.Resolve(r) 74 73 if err != nil { 75 - log.Println("failed to get repo and knot", err) 74 + l.Error("failed to get repo and knot", "err", err) 76 75 return 77 76 } 78 77 ··· 83 82 return 84 83 } 85 84 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 86 if err != nil { 88 87 l.Error("failed to get issue reactions", "err", err) 89 88 } ··· 99 98 db.FilterContains("scope", tangled.RepoIssueNSID), 100 99 ) 101 100 if err != nil { 102 - log.Println("failed to fetch labels", err) 101 + l.Error("failed to fetch labels", "err", err) 103 102 rp.pages.Error503(w) 104 103 return 105 104 } ··· 115 114 Issue: issue, 116 115 CommentList: issue.CommentList(), 117 116 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 117 + Reactions: reactionMap, 119 118 UserReacted: userReactions, 120 119 LabelDefs: defs, 121 120 }) ··· 126 125 user := rp.oauth.GetUser(r) 127 126 f, err := rp.repoResolver.Resolve(r) 128 127 if err != nil { 129 - log.Println("failed to get repo and knot", err) 128 + l.Error("failed to get repo and knot", "err", err) 130 129 return 131 130 } 132 131 ··· 166 165 return 167 166 } 168 167 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 168 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 169 if err != nil { 171 170 l.Error("failed to get record", "err", err) 172 171 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 172 return 174 173 } 175 174 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 175 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 176 Collection: tangled.RepoIssueNSID, 178 177 Repo: user.Did, 179 178 Rkey: newIssue.Rkey, ··· 199 198 200 199 err = db.PutIssue(tx, newIssue) 201 200 if err != nil { 202 - log.Println("failed to edit issue", err) 201 + l.Error("failed to edit issue", "err", err) 203 202 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 203 return 205 204 } ··· 237 236 // delete from PDS 238 237 client, err := rp.oauth.AuthorizedClient(r) 239 238 if err != nil { 240 - log.Println("failed to get authorized client", err) 239 + l.Error("failed to get authorized client", "err", err) 241 240 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 241 return 243 242 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 243 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 244 Collection: tangled.RepoIssueNSID, 246 245 Repo: issue.Did, 247 246 Rkey: issue.Rkey, ··· 282 281 283 282 collaborators, err := f.Collaborators(r.Context()) 284 283 if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 284 + l.Error("failed to fetch repo collaborators", "err", err) 286 285 } 287 286 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 287 return user.Did == collab.Did ··· 296 295 db.FilterEq("id", issue.Id), 297 296 ) 298 297 if err != nil { 299 - log.Println("failed to close issue", err) 298 + l.Error("failed to close issue", "err", err) 300 299 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 300 return 302 301 } 303 302 303 + // notify about the issue closure 304 + rp.notifier.NewIssueClosed(r.Context(), issue) 305 + 304 306 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 305 307 return 306 308 } else { 307 - log.Println("user is not permitted to close issue") 309 + l.Error("user is not permitted to close issue") 308 310 http.Error(w, "for biden", http.StatusUnauthorized) 309 311 return 310 312 } ··· 315 317 user := rp.oauth.GetUser(r) 316 318 f, err := rp.repoResolver.Resolve(r) 317 319 if err != nil { 318 - log.Println("failed to get repo and knot", err) 320 + l.Error("failed to get repo and knot", "err", err) 319 321 return 320 322 } 321 323 ··· 328 330 329 331 collaborators, err := f.Collaborators(r.Context()) 330 332 if err != nil { 331 - log.Println("failed to fetch repo collaborators: %w", err) 333 + l.Error("failed to fetch repo collaborators", "err", err) 332 334 } 333 335 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 334 336 return user.Did == collab.Did ··· 341 343 db.FilterEq("id", issue.Id), 342 344 ) 343 345 if err != nil { 344 - log.Println("failed to reopen issue", err) 346 + l.Error("failed to reopen issue", "err", err) 345 347 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 346 348 return 347 349 } 348 350 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 349 351 return 350 352 } else { 351 - log.Println("user is not the owner of the repo") 353 + l.Error("user is not the owner of the repo") 352 354 http.Error(w, "forbidden", http.StatusUnauthorized) 353 355 return 354 356 } ··· 405 407 } 406 408 407 409 // create a record first 408 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 410 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 409 411 Collection: tangled.RepoIssueCommentNSID, 410 412 Repo: comment.Did, 411 413 Rkey: comment.Rkey, ··· 434 436 435 437 // reset atUri to make rollback a no-op 436 438 atUri = "" 439 + 440 + // notify about the new comment 441 + comment.Id = commentId 442 + rp.notifier.NewIssueComment(r.Context(), &comment) 443 + 437 444 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 438 445 } 439 446 ··· 530 537 newBody := r.FormValue("body") 531 538 client, err := rp.oauth.AuthorizedClient(r) 532 539 if err != nil { 533 - log.Println("failed to get authorized client", err) 540 + l.Error("failed to get authorized client", "err", err) 534 541 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 535 542 return 536 543 } ··· 543 550 544 551 _, err = db.AddIssueComment(rp.db, newComment) 545 552 if err != nil { 546 - log.Println("failed to perferom update-description query", err) 553 + l.Error("failed to perferom update-description query", "err", err) 547 554 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 548 555 return 549 556 } ··· 551 558 // rkey is optional, it was introduced later 552 559 if newComment.Rkey != "" { 553 560 // update the record on pds 554 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 561 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 555 562 if err != nil { 556 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 563 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 557 564 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 558 565 return 559 566 } 560 567 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 568 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 562 569 Collection: tangled.RepoIssueCommentNSID, 563 570 Repo: user.Did, 564 571 Rkey: newComment.Rkey, ··· 721 728 if comment.Rkey != "" { 722 729 client, err := rp.oauth.AuthorizedClient(r) 723 730 if err != nil { 724 - log.Println("failed to get authorized client", err) 731 + l.Error("failed to get authorized client", "err", err) 725 732 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 726 733 return 727 734 } 728 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 735 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 729 736 Collection: tangled.RepoIssueCommentNSID, 730 737 Repo: user.Did, 731 738 Rkey: comment.Rkey, 732 739 }) 733 740 if err != nil { 734 - log.Println(err) 741 + l.Error("failed to delete from PDS", "err", err) 735 742 } 736 743 } 737 744 ··· 749 756 } 750 757 751 758 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 759 + l := rp.logger.With("handler", "RepoIssues") 760 + 752 761 params := r.URL.Query() 753 762 state := params.Get("state") 754 763 isOpen := true ··· 763 772 764 773 page, ok := r.Context().Value("page").(pagination.Page) 765 774 if !ok { 766 - log.Println("failed to get page") 775 + l.Error("failed to get page") 767 776 page = pagination.FirstPage() 768 777 } 769 778 770 779 user := rp.oauth.GetUser(r) 771 780 f, err := rp.repoResolver.Resolve(r) 772 781 if err != nil { 773 - log.Println("failed to get repo and knot", err) 782 + l.Error("failed to get repo and knot", "err", err) 774 783 return 775 784 } 776 785 ··· 785 794 db.FilterEq("open", openVal), 786 795 ) 787 796 if err != nil { 788 - log.Println("failed to get issues", err) 797 + l.Error("failed to get issues", "err", err) 789 798 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 790 799 return 791 800 } 792 801 793 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 802 + labelDefs, err := db.GetLabelDefinitions( 803 + rp.db, 804 + db.FilterIn("at_uri", f.Repo.Labels), 805 + db.FilterContains("scope", tangled.RepoIssueNSID), 806 + ) 794 807 if err != nil { 795 - log.Println("failed to fetch labels", err) 808 + l.Error("failed to fetch labels", "err", err) 796 809 rp.pages.Error503(w) 797 810 return 798 811 } ··· 836 849 Body: r.FormValue("body"), 837 850 Did: user.Did, 838 851 Created: time.Now(), 852 + Repo: &f.Repo, 839 853 } 840 854 841 855 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 853 867 rp.pages.Notice(w, "issues", "Failed to create issue.") 854 868 return 855 869 } 856 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 870 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 857 871 Collection: tangled.RepoIssueNSID, 858 872 Repo: user.Did, 859 873 Rkey: issue.Rkey, ··· 889 903 890 904 err = db.PutIssue(tx, issue) 891 905 if err != nil { 892 - log.Println("failed to create issue", err) 906 + l.Error("failed to create issue", "err", err) 893 907 rp.pages.Notice(w, "issues", "Failed to create issue.") 894 908 return 895 909 } 896 910 897 911 if err = tx.Commit(); err != nil { 898 - log.Println("failed to create issue", err) 912 + l.Error("failed to create issue", "err", err) 899 913 rp.pages.Notice(w, "issues", "Failed to create issue.") 900 914 return 901 915 } ··· 911 925 // this is used to rollback changes made to the PDS 912 926 // 913 927 // it is a no-op if the provided ATURI is empty 914 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 928 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 915 929 if aturi == "" { 916 930 return nil 917 931 } ··· 922 936 repo := parsed.Authority().String() 923 937 rkey := parsed.RecordKey().String() 924 938 925 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 939 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 926 940 Collection: collection, 927 941 Repo: repo, 928 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 16 r.Route("/{issue}", func(r chi.Router) { 17 17 r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 19 20 20 21 // authenticated routes 21 22 r.Group(func(r chi.Router) {
+6 -6
appview/knots/knots.go
··· 185 185 return 186 186 } 187 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 189 var exCid *string 190 190 if ex != nil { 191 191 exCid = ex.Cid 192 192 } 193 193 194 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 196 Collection: tangled.KnotNSID, 197 197 Repo: user.Did, 198 198 Rkey: domain, ··· 323 323 return 324 324 } 325 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 327 Collection: tangled.KnotNSID, 328 328 Repo: user.Did, 329 329 Rkey: domain, ··· 431 431 return 432 432 } 433 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 435 var exCid *string 436 436 if ex != nil { 437 437 exCid = ex.Cid 438 438 } 439 439 440 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 442 Collection: tangled.KnotNSID, 443 443 Repo: user.Did, 444 444 Rkey: domain, ··· 555 555 556 556 rkey := tid.TID() 557 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 559 Collection: tangled.KnotMemberNSID, 560 560 Repo: user.Did, 561 561 Rkey: rkey,
+24 -16
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 12 "tangled.org/core/api/tangled" 18 13 "tangled.org/core/appview/db" 19 14 "tangled.org/core/appview/middleware" ··· 21 16 "tangled.org/core/appview/oauth" 22 17 "tangled.org/core/appview/pages" 23 18 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 - "tangled.org/core/log" 19 + "tangled.org/core/rbac" 26 20 "tangled.org/core/tid" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + atpclient "github.com/bluesky-social/indigo/atproto/client" 24 + "github.com/bluesky-social/indigo/atproto/syntax" 25 + lexutil "github.com/bluesky-social/indigo/lex/util" 26 + "github.com/go-chi/chi/v5" 27 27 ) 28 28 29 29 type Labels struct { ··· 32 32 db *db.DB 33 33 logger *slog.Logger 34 34 validator *validator.Validator 35 + enforcer *rbac.Enforcer 35 36 } 36 37 37 38 func New( ··· 39 40 pages *pages.Pages, 40 41 db *db.DB, 41 42 validator *validator.Validator, 43 + enforcer *rbac.Enforcer, 44 + logger *slog.Logger, 42 45 ) *Labels { 43 - logger := log.New("labels") 44 - 45 46 return &Labels{ 46 47 oauth: oauth, 47 48 pages: pages, 48 49 db: db, 49 50 logger: logger, 50 51 validator: validator, 52 + enforcer: enforcer, 51 53 } 52 54 } 53 55 ··· 86 88 repoAt := r.Form.Get("repo") 87 89 subjectUri := r.Form.Get("subject") 88 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 + 89 97 // find all the labels that this repo subscribes to 90 98 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 91 99 if err != nil { ··· 152 160 } 153 161 } 154 162 155 - // reduce the opset 156 - labelOps = models.ReduceLabelOps(labelOps) 157 - 158 163 for i := range labelOps { 159 164 def := actx.Defs[labelOps[i].OperandKey] 160 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 165 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 161 166 fail(fmt.Sprintf("Invalid form data: %s", err), err) 162 167 return 163 168 } 164 169 } 170 + 171 + // reduce the opset 172 + labelOps = models.ReduceLabelOps(labelOps) 165 173 166 174 // next, apply all ops introduced in this request and filter out ones that are no-ops 167 175 validLabelOps := labelOps[:0] ··· 186 194 return 187 195 } 188 196 189 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 190 198 Collection: tangled.LabelOpNSID, 191 199 Repo: did, 192 200 Rkey: rkey, ··· 242 250 // this is used to rollback changes made to the PDS 243 251 // 244 252 // it is a no-op if the provided ATURI is empty 245 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 253 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 246 254 if aturi == "" { 247 255 return nil 248 256 } ··· 253 261 repo := parsed.Authority().String() 254 262 rkey := parsed.RecordKey().String() 255 263 256 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 264 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 257 265 Collection: collection, 258 266 Repo: repo, 259 267 Rkey: rkey,
+5 -5
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 49 returnURL := "/" ··· 63 63 } 64 64 } 65 65 66 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 67 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 69 redirectFunc(w, r) 70 70 return 71 71 } 72 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 75 redirectFunc(w, r) 76 76 return 77 77 }
+24
appview/models/issue.go
··· 54 54 Replies []*IssueComment 55 55 } 56 56 57 + func (it *CommentListItem) Participants() []syntax.DID { 58 + participantSet := make(map[syntax.DID]struct{}) 59 + participants := []syntax.DID{} 60 + 61 + addParticipant := func(did syntax.DID) { 62 + if _, exists := participantSet[did]; !exists { 63 + participantSet[did] = struct{}{} 64 + participants = append(participants, did) 65 + } 66 + } 67 + 68 + addParticipant(syntax.DID(it.Self.Did)) 69 + 70 + for _, c := range it.Replies { 71 + addParticipant(syntax.DID(c.Did)) 72 + } 73 + 74 + return participants 75 + } 76 + 57 77 func (i *Issue) CommentList() []CommentListItem { 58 78 // Create a map to quickly find comments by their aturi 59 79 toplevel := make(map[string]*CommentListItem) ··· 167 187 168 188 func (i *IssueComment) IsTopLevel() bool { 169 189 return i.ReplyTo == nil 190 + } 191 + 192 + func (i *IssueComment) IsReply() bool { 193 + return i.ReplyTo != nil 170 194 } 171 195 172 196 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+19 -17
appview/models/label.go
··· 232 232 } 233 233 234 234 var ops []LabelOp 235 - for _, o := range record.Add { 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 236 237 if o != nil { 237 238 op := mkOp(o) 238 - op.Operation = LabelOperationAdd 239 + op.Operation = LabelOperationDel 239 240 ops = append(ops, op) 240 241 } 241 242 } 242 - for _, o := range record.Delete { 243 + for _, o := range record.Add { 243 244 if o != nil { 244 245 op := mkOp(o) 245 - op.Operation = LabelOperationDel 246 + op.Operation = LabelOperationAdd 246 247 ops = append(ops, op) 247 248 } 248 249 } ··· 460 461 return result 461 462 } 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 + 463 472 func DefaultLabelDefs() []string { 464 - rkeys := []string{ 465 - "wontfix", 466 - "duplicate", 467 - "assignee", 468 - "good-first-issue", 469 - "documentation", 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 470 479 } 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 480 } 479 481 480 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 77 PullSource *PullSource 78 78 79 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 80 + Labels LabelState 81 + Repo *Repo 81 82 } 82 83 83 84 func (p Pull) AsRecord() tangled.RepoPull { 84 85 var source *tangled.RepoPull_Source 85 86 if p.PullSource != nil { 86 - s := p.PullSource.AsRecord() 87 - source = &s 87 + source = &tangled.RepoPull_Source{} 88 + source.Branch = p.PullSource.Branch 88 89 source.Sha = p.LatestSha() 90 + if p.PullSource.RepoAt != nil { 91 + s := p.PullSource.RepoAt.String() 92 + source.Repo = &s 93 + } 89 94 } 90 95 91 96 record := tangled.RepoPull{ ··· 110 115 Repo *Repo 111 116 } 112 117 113 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 114 - var repoAt *string 115 - if p.RepoAt != nil { 116 - s := p.RepoAt.String() 117 - repoAt = &s 118 - } 119 - record := tangled.RepoPull_Source{ 120 - Branch: p.Branch, 121 - Repo: repoAt, 122 - } 123 - return record 124 - } 125 - 126 118 type PullSubmission struct { 127 119 // ids 128 - ID int 129 - PullId int 120 + ID int 130 121 131 122 // at ids 132 - RepoAt syntax.ATURI 123 + PullAt syntax.ATURI 133 124 134 125 // content 135 126 RoundNumber int 136 127 Patch string 128 + Combined string 137 129 Comments []PullComment 138 130 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 131 ··· 159 151 Created time.Time 160 152 } 161 153 154 + func (p *Pull) LastRoundNumber() int { 155 + return len(p.Submissions) - 1 156 + } 157 + 158 + func (p *Pull) LatestSubmission() *PullSubmission { 159 + return p.Submissions[p.LastRoundNumber()] 160 + } 161 + 162 162 func (p *Pull) LatestPatch() string { 163 - latestSubmission := p.Submissions[p.LastRoundNumber()] 164 - return latestSubmission.Patch 163 + return p.LatestSubmission().Patch 165 164 } 166 165 167 166 func (p *Pull) LatestSha() string { 168 - latestSubmission := p.Submissions[p.LastRoundNumber()] 169 - return latestSubmission.SourceRev 167 + return p.LatestSubmission().SourceRev 170 168 } 171 169 172 170 func (p *Pull) PullAt() syntax.ATURI { 173 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 - } 175 - 176 - func (p *Pull) LastRoundNumber() int { 177 - return len(p.Submissions) - 1 178 172 } 179 173 180 174 func (p *Pull) IsPatchBased() bool { ··· 207 201 return p.StackId != "" 208 202 } 209 203 204 + func (p *Pull) Participants() []string { 205 + participantSet := make(map[string]struct{}) 206 + participants := []string{} 207 + 208 + addParticipant := func(did string) { 209 + if _, exists := participantSet[did]; !exists { 210 + participantSet[did] = struct{}{} 211 + participants = append(participants, did) 212 + } 213 + } 214 + 215 + addParticipant(p.OwnerDid) 216 + 217 + for _, s := range p.Submissions { 218 + for _, sp := range s.Participants() { 219 + addParticipant(sp) 220 + } 221 + } 222 + 223 + return participants 224 + } 225 + 210 226 func (s PullSubmission) IsFormatPatch() bool { 211 227 return patchutil.IsFormatPatch(s.Patch) 212 228 } ··· 219 235 } 220 236 221 237 return patches 238 + } 239 + 240 + func (s *PullSubmission) Participants() []string { 241 + participantSet := make(map[string]struct{}) 242 + participants := []string{} 243 + 244 + addParticipant := func(did string) { 245 + if _, exists := participantSet[did]; !exists { 246 + participantSet[did] = struct{}{} 247 + participants = append(participants, did) 248 + } 249 + } 250 + 251 + addParticipant(s.PullAt.Authority().String()) 252 + 253 + for _, c := range s.Comments { 254 + addParticipant(c.OwnerDid) 255 + } 256 + 257 + return participants 258 + } 259 + 260 + func (s PullSubmission) CombinedPatch() string { 261 + if s.Combined == "" { 262 + return s.Patch 263 + } 264 + 265 + return s.Combined 222 266 } 223 267 224 268 type Stack []*Pull ··· 308 352 309 353 return mergeable 310 354 } 355 + 356 + type BranchDeleteStatus struct { 357 + Repo *Repo 358 + Branch string 359 + }
+5
appview/models/reaction.go
··· 55 55 Rkey string 56 56 Kind ReactionKind 57 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+6
appview/models/repo.go
··· 10 10 ) 11 11 12 12 type Repo struct { 13 + Id int64 13 14 Did string 14 15 Name string 15 16 Knot string ··· 85 86 RepoAt syntax.ATURI 86 87 LabelAt syntax.ATURI 87 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 2 3 3 import ( 4 4 "context" 5 + "reflect" 6 + "sync" 5 7 6 8 "tangled.org/core/appview/models" 7 9 ) ··· 16 18 17 19 var _ Notifier = &mergedNotifier{} 18 20 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 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) 22 35 } 36 + wg.Wait() 37 + } 38 + 39 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 40 + m.fanout("NewRepo", ctx, repo) 23 41 } 24 42 25 43 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 44 + m.fanout("NewStar", ctx, star) 29 45 } 46 + 30 47 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 48 + m.fanout("DeleteStar", ctx, star) 34 49 } 35 50 36 51 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 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) 40 61 } 41 62 42 63 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 - for _, notifier := range m.notifiers { 44 - notifier.NewFollow(ctx, follow) 45 - } 64 + m.fanout("NewFollow", ctx, follow) 46 65 } 66 + 47 67 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 48 - for _, notifier := range m.notifiers { 49 - notifier.DeleteFollow(ctx, follow) 50 - } 68 + m.fanout("DeleteFollow", ctx, follow) 51 69 } 52 70 53 71 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewPull(ctx, pull) 56 - } 72 + m.fanout("NewPull", ctx, pull) 57 73 } 74 + 58 75 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 - for _, notifier := range m.notifiers { 60 - notifier.NewPullComment(ctx, comment) 61 - } 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) 62 85 } 63 86 64 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 65 - for _, notifier := range m.notifiers { 66 - notifier.UpdateProfile(ctx, profile) 67 - } 88 + m.fanout("UpdateProfile", ctx, profile) 68 89 } 69 90 70 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 71 - for _, notifier := range m.notifiers { 72 - notifier.NewString(ctx, string) 73 - } 91 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 92 + m.fanout("NewString", ctx, s) 74 93 } 75 94 76 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 77 - for _, notifier := range m.notifiers { 78 - notifier.EditString(ctx, string) 79 - } 95 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 96 + m.fanout("EditString", ctx, s) 80 97 } 81 98 82 99 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 - for _, notifier := range m.notifiers { 84 - notifier.DeleteString(ctx, did, rkey) 85 - } 100 + m.fanout("DeleteString", ctx, did, rkey) 86 101 }
+9 -1
appview/notify/notifier.go
··· 13 13 DeleteStar(ctx context.Context, star *models.Star) 14 14 15 15 NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 16 18 17 19 NewFollow(ctx context.Context, follow *models.Follow) 18 20 DeleteFollow(ctx context.Context, follow *models.Follow) 19 21 20 22 NewPull(ctx context.Context, pull *models.Pull) 21 23 NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 22 26 23 27 UpdateProfile(ctx context.Context, profile *models.Profile) 24 28 ··· 37 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 38 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 43 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 41 47 42 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 43 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 50 45 51 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 46 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) {} 47 55 48 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49 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 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/consts" 28 - "tangled.org/core/idresolver" 29 - "tangled.org/core/rbac" 30 - "tangled.org/core/tid" 31 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
+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 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 + "log/slog" 6 7 "net/http" 7 - "net/url" 8 8 "time" 9 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 15 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 16 + "github.com/lestrrat-go/jwx/v2/jwk" 17 + "github.com/posthog/posthog-go" 13 18 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 18 22 ) 19 23 20 24 type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + Posthog posthog.Client 30 + Db *db.DB 31 + Enforcer *rbac.Enforcer 32 + IdResolver *idresolver.Resolver 33 + Logger *slog.Logger 24 34 } 25 35 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, 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"}) 31 50 } 32 - } 51 + 52 + jwksUri := clientUri + "/oauth/jwks.json" 33 53 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 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 36 72 } 37 73 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 74 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 75 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 76 + userSession, err := o.SessStore.Get(r, SessionName) 41 77 if err != nil { 42 78 return err 43 79 } 44 80 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 81 + userSession.Values[SessionDid] = sessData.AccountDID.String() 82 + userSession.Values[SessionPds] = sessData.HostURL 83 + userSession.Values[SessionId] = sessData.SessionID 48 84 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 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) 50 90 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 91 + return nil, fmt.Errorf("error getting user session: %w", err) 52 92 } 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), 93 + if userSession.IsNew { 94 + return nil, fmt.Errorf("no session available for user") 65 95 } 66 96 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) 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) 74 101 } 75 102 76 - did := userSession.Values[SessionDid].(string) 103 + sessId := userSession.Values[SessionId].(string) 77 104 78 - err = o.sess.DeleteSession(r.Context(), did) 105 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 106 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 107 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 108 } 82 109 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 110 + return clientSess, nil 86 111 } 87 112 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) 113 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 114 + userSession, err := o.SessStore.Get(r, SessionName) 98 115 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 116 + return fmt.Errorf("error getting user session: %w", err) 117 + } 118 + if userSession.IsNew { 119 + return fmt.Errorf("no session available for user") 100 120 } 101 121 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 122 + d := userSession.Values[SessionDid].(string) 123 + sessDid, err := syntax.ParseDID(d) 103 124 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 125 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 126 } 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 127 112 - self := o.ClientMetadata() 113 - 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 128 + sessId := userSession.Values[SessionId].(string) 119 129 120 - if err != nil { 121 - return nil, false, err 122 - } 130 + // delete the session 131 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 123 132 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 133 + // remove the cookie 134 + userSession.Options.MaxAge = -1 135 + err2 := o.SessStore.Save(r, w, userSession) 128 136 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 - } 137 + return errors.Join(err1, err2) 138 + } 134 139 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 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 141 + k, err := jwk.ParseKey([]byte(jwks)) 142 + if err != nil { 143 + return nil, err 140 144 } 141 - 142 - return session, auth, nil 145 + pubKey, err := k.PublicKey() 146 + if err != nil { 147 + return nil, err 148 + } 149 + return pubKey, nil 143 150 } 144 151 145 152 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 153 + Did string 154 + Pds string 149 155 } 150 156 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 157 + func (o *OAuth) GetUser(r *http.Request) *User { 158 + sess, err := o.SessStore.Get(r, SessionName) 153 159 154 - if err != nil || clientSession.IsNew { 160 + if err != nil || sess.IsNew { 155 161 return nil 156 162 } 157 163 158 164 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 165 + Did: sess.Values[SessionDid].(string), 166 + Pds: sess.Values[SessionPds].(string), 162 167 } 163 168 } 164 169 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 + func (o *OAuth) GetDid(r *http.Request) string { 171 + if u := o.GetUser(r); u != nil { 172 + return u.Did 170 173 } 171 174 172 - return clientSession.Values[SessionDid].(string) 175 + return "" 173 176 } 174 177 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 178 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 179 + session, err := o.ResumeSession(r) 177 180 if err != nil { 178 181 return nil, fmt.Errorf("error getting session: %w", err) 179 182 } 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 183 + return session.APIClient(), nil 208 184 } 209 185 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 186 // this is a higher level abstraction on ServerGetServiceAuth 213 187 type ServiceClientOpts struct { 214 188 service string ··· 259 233 return scheme + s.service 260 234 } 261 235 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 236 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 237 opts := ServiceClientOpts{} 264 238 for _, o := range os { 265 239 o(&opts) 266 240 } 267 241 268 - authorizedClient, err := o.AuthorizedClient(r) 242 + client, err := o.AuthorizedClient(r) 269 243 if err != nil { 270 244 return nil, err 271 245 } ··· 276 250 opts.exp = sixty 277 251 } 278 252 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 253 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 254 if err != nil { 281 255 return nil, err 282 256 } 283 257 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 258 + return &xrpc.Client{ 259 + Auth: &xrpc.AuthInfo{ 286 260 AccessJwt: resp.Token, 287 261 }, 288 262 Host: opts.Host(), ··· 291 265 }, 292 266 }, nil 293 267 } 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 - }
+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 + }
+7 -7
appview/pages/funcmap.go
··· 265 265 return nil 266 266 }, 267 267 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 268 + data, err := p.icon(name, classes) 269 269 if err != nil { 270 270 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 271 + data, _ = p.icon("airplay", classes) 272 272 } 273 273 return template.HTML(data) 274 274 }, 275 - "cssContentHash": CssContentHash, 275 + "cssContentHash": p.CssContentHash, 276 276 "fileTree": filetree.FileTree, 277 277 "pathEscape": func(s string) string { 278 278 return url.PathEscape(s) ··· 283 283 }, 284 284 285 285 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 286 + return p.AvatarUrl(handle, "tiny") 287 287 }, 288 288 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 289 + return p.AvatarUrl(handle, "") 290 290 }, 291 291 "langColor": enry.GetColor, 292 292 "layoutSide": func() string { ··· 310 310 } 311 311 } 312 312 313 - func (p *Pages) avatarUri(handle, size string) string { 313 + func (p *Pages) AvatarUrl(handle, size string) string { 314 314 handle = strings.TrimPrefix(handle, "@") 315 315 316 316 secret := p.avatar.SharedSecret ··· 325 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 326 } 327 327 328 - func icon(name string, classes []string) (template.HTML, error) { 328 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 329 329 iconPath := filepath.Join("static", "icons", name) 330 330 331 331 if filepath.Ext(name) == "" {
+5 -2
appview/pages/funcmap_test.go
··· 2 2 3 3 import ( 4 4 "html/template" 5 + "log/slog" 6 + "testing" 7 + 5 8 "tangled.org/core/appview/config" 6 9 "tangled.org/core/idresolver" 7 - "testing" 8 10 ) 9 11 10 12 func TestPages_funcMap(t *testing.T) { ··· 13 15 // Named input parameters for receiver constructor. 14 16 config *config.Config 15 17 res *idresolver.Resolver 18 + l *slog.Logger 16 19 want template.FuncMap 17 20 }{ 18 21 // TODO: Add test cases. 19 22 } 20 23 for _, tt := range tests { 21 24 t.Run(tt.name, func(t *testing.T) { 22 - p := NewPages(tt.config, tt.res) 25 + p := NewPages(tt.config, tt.res, tt.l) 23 26 got := p.funcMap() 24 27 // TODO: update the condition below to compare got with tt.want. 25 28 if true {
+156
appview/pages/legal/privacy.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+15 -17
appview/pages/markup/format.go
··· 1 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 16 } 15 17 16 - // ReadmeFilenames contains the list of common README filenames to search for, 17 - // in order of preference. Only includes well-supported formats. 18 - var ReadmeFilenames = []string{ 19 - "README.md", "readme.md", 20 - "README", 21 - "readme", 22 - "README.markdown", 23 - "readme.markdown", 24 - "README.txt", 25 - "readme.txt", 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 26 26 } 27 27 28 28 func GetFormat(filename string) Format { 29 - for format, extensions := range FileTypes { 30 - for _, extension := range extensions { 31 - if strings.HasSuffix(filename, extension) { 32 - return format 33 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 34 32 } 35 33 } 36 34 // default format
+6 -1
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 + "io/fs" 8 9 "net/url" 9 10 "path" 10 11 "strings" ··· 20 21 "github.com/yuin/goldmark/renderer/html" 21 22 "github.com/yuin/goldmark/text" 22 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 23 25 htmlparse "golang.org/x/net/html" 24 26 25 27 "tangled.org/core/api/tangled" ··· 45 47 IsDev bool 46 48 RendererType RendererType 47 49 Sanitizer Sanitizer 50 + Files fs.FS 48 51 } 49 52 50 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 66 ), 64 67 treeblood.MathML(), 68 + callout.CalloutExtention, 65 69 ), 66 70 goldmark.WithParserOptions( 67 71 parser.WithAutoHeadingID(), ··· 140 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 145 switch node.Type { 142 146 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 147 + switch node.Data { 148 + case "img", "source": 144 149 for i, attr := range node.Attr { 145 150 if attr.Key != "src" { 146 151 continue
+3
appview/pages/markup/sanitizer.go
··· 114 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 117 120 return policy 118 121 } 119 122
+126 -39
appview/pages/pages.go
··· 38 38 "github.com/go-git/go-git/v5/plumbing/object" 39 39 ) 40 40 41 - //go:embed templates/* static 41 + //go:embed templates/* static legal 42 42 var Files embed.FS 43 43 44 44 type Pages struct { ··· 54 54 logger *slog.Logger 55 55 } 56 56 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 57 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 58 // initialized with safe defaults, can be overriden per use 59 59 rctx := &markup.RenderContext{ 60 60 IsDev: config.Core.Dev, 61 61 CamoUrl: config.Camo.Host, 62 62 CamoSecret: config.Camo.SharedSecret, 63 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 64 65 } 65 66 66 67 p := &Pages{ ··· 71 72 rctx: rctx, 72 73 resolver: res, 73 74 templateDir: "appview/pages", 74 - logger: slog.Default().With("component", "pages"), 75 + logger: logger, 75 76 } 76 77 77 78 if p.dev { ··· 226 227 return p.executePlain("user/login", w, params) 227 228 } 228 229 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 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) 231 236 } 232 237 233 238 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 242 247 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 248 filename := "terms.md" 244 249 filePath := filepath.Join("legal", filename) 245 - markdownBytes, err := os.ReadFile(filePath) 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) 246 258 if err != nil { 247 259 return fmt.Errorf("failed to read %s: %w", filename, err) 248 260 } ··· 263 275 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 276 filename := "privacy.md" 265 277 filePath := filepath.Join("legal", filename) 266 - markdownBytes, err := os.ReadFile(filePath) 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) 267 286 if err != nil { 268 287 return fmt.Errorf("failed to read %s: %w", filename, err) 269 288 } ··· 276 295 return p.execute("legal/privacy", w, params) 277 296 } 278 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 + 279 306 type TimelineParams struct { 280 307 LoggedInUser *oauth.User 281 308 Timeline []models.TimelineEvent 282 309 Repos []models.Repo 310 + GfiLabel *models.LabelDefinition 283 311 } 284 312 285 313 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 314 return p.execute("timeline/timeline", w, params) 287 315 } 288 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 + 289 330 type UserProfileSettingsParams struct { 290 331 LoggedInUser *oauth.User 291 332 Tabs []map[string]any ··· 294 335 295 336 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 296 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) 297 366 } 298 367 299 368 type UserKeysSettingsParams struct { ··· 318 387 return p.execute("user/settings/emails", w, params) 319 388 } 320 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 + 321 401 type UpgradeBannerParams struct { 322 402 Registrations []models.Registration 323 403 Spindles []models.Spindle ··· 484 564 485 565 type FollowCard struct { 486 566 UserDid string 567 + LoggedInUser *oauth.User 487 568 FollowStatus models.FollowStatus 488 569 FollowersCount int64 489 570 FollowingCount int64 ··· 654 735 } 655 736 656 737 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 738 + LoggedInUser *oauth.User 739 + RepoInfo repoinfo.RepoInfo 740 + Active string 741 + BreadCrumbs [][]string 742 + TreePath string 743 + Raw bool 744 + HTMLReadme template.HTML 666 745 types.RepoTreeResponse 667 746 } 668 747 ··· 690 769 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 770 params.Active = "overview" 692 771 693 - if params.ReadmeFileName != "" { 694 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 772 + p.rctx.RepoInfo = params.RepoInfo 773 + p.rctx.RepoInfo.Ref = params.Ref 774 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 695 775 776 + if params.ReadmeFileName != "" { 696 777 ext := filepath.Ext(params.ReadmeFileName) 697 778 switch ext { 698 779 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 905 986 LabelDefs map[string]*models.LabelDefinition 906 987 907 988 OrderedReactionKinds []models.ReactionKind 908 - Reactions map[models.ReactionKind]int 989 + Reactions map[models.ReactionKind]models.ReactionDisplayData 909 990 UserReacted map[models.ReactionKind]bool 910 991 } 911 992 ··· 930 1011 ThreadAt syntax.ATURI 931 1012 Kind models.ReactionKind 932 1013 Count int 1014 + Users []string 933 1015 IsReacted bool 934 1016 } 935 1017 ··· 1020 1102 FilteringBy models.PullState 1021 1103 Stacks map[string]models.Stack 1022 1104 Pipelines map[string]models.Pipeline 1105 + LabelDefs map[string]*models.LabelDefinition 1023 1106 } 1024 1107 1025 1108 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1046 1129 } 1047 1130 1048 1131 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 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 1058 1142 1059 1143 OrderedReactionKinds []models.ReactionKind 1060 - Reactions map[models.ReactionKind]int 1144 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1061 1145 UserReacted map[models.ReactionKind]bool 1146 + 1147 + LabelDefs map[string]*models.LabelDefinition 1062 1148 } 1063 1149 1064 1150 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1148 1234 } 1149 1235 1150 1236 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 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 1158 1245 } 1159 1246 1160 1247 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1391 1478 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1392 1479 } 1393 1480 1394 - sub, err := fs.Sub(Files, "static") 1481 + sub, err := fs.Sub(p.embedFS, "static") 1395 1482 if err != nil { 1396 1483 p.logger.Error("no static dir found? that's crazy", "err", err) 1397 1484 panic(err) ··· 1414 1501 }) 1415 1502 } 1416 1503 1417 - func CssContentHash() string { 1418 - cssFile, err := Files.Open("static/tw.css") 1504 + func (p *Pages) CssContentHash() string { 1505 + cssFile, err := p.embedFS.Open("static/tw.css") 1419 1506 if err != nil { 1420 1507 slog.Debug("Error opening CSS file", "err", err) 1421 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 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 28 </div>
+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 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </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);"> 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"> 26 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ 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"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 10 13 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 19 46 </div> 20 47 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 26 51 </div> 52 + </div> 27 53 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 33 64 </div> 34 65 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 38 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 39 93 </div> 40 - </div> 41 94 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 44 98 </div> 45 99 </div> 46 100 </div>
+18 -8
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 5 + <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 + <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 + alpha 10 + </span> 7 11 </a> 8 12 </div> 9 13 10 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 11 15 {{ with .LoggedInUser }} 12 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 13 18 {{ block "dropDown" . }} {{ end }} 14 19 {{ else }} 15 20 <a href="/login">login</a> ··· 26 31 {{ define "newButton" }} 27 32 <details class="relative inline-block text-left nav-dropdown"> 28 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 30 35 </summary> 31 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"> 32 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 49 {{ define "dropDown" }} 45 50 <details class="relative inline-block text-left nav-dropdown"> 46 51 <summary 47 - class="cursor-pointer list-none flex items-center" 52 + class="cursor-pointer list-none flex items-center gap-1" 48 53 > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 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> 51 61 </summary> 52 62 <div 53 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 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 + {{ $avatarUrl := fullAvatar .Card.UserHandle }} 4 5 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 6 <meta property="og:type" content="profile" /> 6 7 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 8 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 9 + <meta property="og:image" content="{{ $avatarUrl }}" /> 10 + <meta property="og:image:width" content="512" /> 11 + <meta property="og:image:height" content="512" /> 12 + 13 + <meta name="twitter:card" content="summary" /> 14 + <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 + <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 16 + <meta name="twitter:image" content="{{ $avatarUrl }}" /> 8 17 {{ end }} 9 18 10 19 {{ define "content" }}
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }}privacy policy{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
··· 1 + {{define "notifications/fragments/bell"}} 2 + <div class="relative" 3 + hx-get="/notifications/count" 4 + hx-target="#notification-count" 5 + hx-trigger="load, every 30s"> 6 + <a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group"> 7 + {{ i "bell" "w-5 h-5" }} 8 + <span id="notification-count"></span> 9 + </a> 10 + </div> 11 + {{end}}
+7
appview/pages/templates/notifications/fragments/count.html
··· 1 + {{define "notifications/fragments/count"}} 2 + {{if and .Count (gt .Count 0)}} 3 + <span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"> 4 + {{if gt .Count 99}}99+{{else}}{{.Count}}{{end}} 5 + </span> 6 + {{end}} 7 + {{end}}
+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 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 83 + <header class="col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 90 {{ block "contentLayout" . }} 91 91 {{ block "content" . }}{{ end }} 92 92 {{ end }} ··· 105 105 {{ end }} 106 106 107 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 108 + <footer class="col-span-full mt-12"> 109 109 {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }}
+7
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+9 -1
appview/pages/templates/repo/fragments/og.html
··· 2 2 {{ $title := or .Title .RepoInfo.FullName }} 3 3 {{ $description := or .Description .RepoInfo.Description }} 4 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 6 7 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 8 <meta property="og:type" content="object" /> 9 9 <meta property="og:url" content="{{ $url }}" /> 10 10 <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 11 19 {{ end }}
+26
appview/pages/templates/repo/fragments/participants.html
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-2 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 1 {{ define "repo/fragments/readme" }} 2 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 7 </div> 8 8 {{- end -}} 9 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 34 34 35 35 {{ define "editIssueComment" }} 36 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 39 hx-swap="outerHTML" 40 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 44 45 45 {{ define "deleteIssueComment" }} 46 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 49 hx-confirm="Are you sure you want to delete your comment?" 50 50 hx-swap="outerHTML"
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+19
appview/pages/templates/repo/issues/fragments/og.html
··· 1 + {{ define "repo/issues/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 + {{ $description := or .Issue.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+8 -35
appview/pages/templates/repo/issues/issue.html
··· 2 2 3 3 4 4 {{ define "extrameta" }} 5 - {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 - {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 - 8 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 9 6 {{ end }} 10 7 11 8 {{ define "repoContentLayout" }} ··· 22 19 "Defs" $.LabelDefs 23 20 "Subject" $.Issue.AtUri 24 21 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 22 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 23 </div> 27 24 </div> 28 25 {{ end }} ··· 87 84 88 85 {{ define "editIssue" }} 89 86 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 87 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 91 88 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 89 hx-swap="innerHTML" 93 90 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 94 98 95 {{ define "deleteIssue" }} 99 96 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 97 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 101 98 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 99 hx-confirm="Are you sure you want to delete your issue?" 103 100 hx-swap="none"> ··· 110 107 <div class="flex items-center gap-2"> 111 108 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 109 {{ range $kind := .OrderedReactionKinds }} 110 + {{ $reactionData := index $.Reactions $kind }} 113 111 {{ 114 112 template "repo/fragments/reaction" 115 113 (dict 116 114 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 115 + "Count" $reactionData.Count 118 116 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 117 + "ThreadAt" $.Issue.AtUri 118 + "Users" $reactionData.Users) 120 119 }} 121 120 {{ end }} 122 121 </div> 123 122 {{ end }} 124 123 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 124 152 125 {{ define "repoAfter" }} 153 126 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 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 }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 42 </div> 93 43 {{ block "pagination" . }} {{ end }} 94 44 {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 6 13 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 14 + {{ end }} 19 15 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 29 21 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 41 52 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 71 64 </div> 72 - </form> 73 - </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 74 176 {{ end }}
+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 33 <span>comment</span> 34 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 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 }} 36 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 48 {{ $disabled := "" }} 38 49 {{ if $isConflicted }}
+15 -11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 42 {{ if not .Pull.IsPatchBased }} 43 43 from 44 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - {{ if .Pull.IsForkBased }} 46 - {{ if .Pull.PullSource.Repo }} 47 - {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 - <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 - {{- else -}} 50 - <span class="italic">[deleted fork]</span> 51 - {{- end -}} 52 - {{- end -}} 53 - {{- .Pull.PullSource.Branch -}} 45 + {{ if not .Pull.IsForkBased }} 46 + {{ $repoPath := .RepoInfo.FullName }} 47 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 + {{ else if .Pull.PullSource.Repo }} 49 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 + <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 + {{ else }} 53 + <span class="italic">[deleted fork]</span>: 54 + {{ .Pull.PullSource.Branch }} 55 + {{ end }} 54 56 </span> 55 57 {{ end }} 56 58 </span> ··· 66 68 <div class="flex items-center gap-2 mt-2"> 67 69 {{ template "repo/fragments/reactionsPopUp" . }} 68 70 {{ range $kind := . }} 71 + {{ $reactionData := index $.Reactions $kind }} 69 72 {{ 70 73 template "repo/fragments/reaction" 71 74 (dict 72 75 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 76 + "Count" $reactionData.Count 74 77 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 78 + "ThreadAt" $.Pull.PullAt 79 + "Users" $reactionData.Users) 76 80 }} 77 81 {{ end }} 78 82 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 28 29 29 {{ end }} 30 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 31 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 33 {{ block "contentLayout" . }} 40 34 {{ block "content" . }}{{ end }} 41 35 {{ end }} ··· 52 46 {{ end }} 53 47 </div> 54 48 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 49 63 50 {{ define "contentAfter" }} 64 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
appview/pages/templates/repo/pulls/patch.html
··· 34 34 </section> 35 35 {{ end }} 36 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/fragments/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 37 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 45 39 {{ block "contentLayout" . }} 46 40 {{ block "content" . }}{{ end }} 47 41 {{ end }} ··· 57 51 </div> 58 52 {{ end }} 59 53 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 54 {{ end }} 67 55 68 56 {{ define "contentAfter" }}
+48 -20
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "extrameta" }} 6 - {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 10 7 {{ end }} 11 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 }} 12 27 13 28 {{ define "repoContent" }} 14 29 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 54 {{ with $item }} 40 55 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 56 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 57 + <div class="flex flex-wrap gap-2 items-stretch"> 43 58 <!-- round number --> 44 59 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 60 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 61 </div> 47 62 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 63 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 64 <span class="gap-1 flex items-center"> 50 65 {{ $owner := resolve $.Pull.OwnerDid }} 51 66 {{ $re := "re" }} ··· 72 87 <span class="hidden md:inline">diff</span> 73 88 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 89 </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> 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 }} 83 99 <span id="interdiff-error-{{.RoundNumber}}"></span> 84 - {{ end }} 85 100 </div> 86 101 </summary> 87 102 ··· 146 161 147 162 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 163 {{ 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"> 164 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 165 {{ if gt $cidx 0 }} 151 166 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 167 {{ end }} ··· 169 184 {{ end }} 170 185 171 186 {{ if $.LoggedInUser }} 172 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 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) }} 173 197 {{ 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 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 177 205 </div> 178 206 {{ end }} 179 207 </div>
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 108 <span class="before:content-['·']"></span> 109 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 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 }} 111 118 </div> 112 119 </div> 113 120 {{ if .StackId }}
+2
appview/pages/templates/repo/settings/access.html
··· 83 83 </label> 84 84 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 85 <input 86 + autocapitalize="none" 87 + autocorrect="off" 86 88 type="text" 87 89 id="add-collaborator" 88 90 name="collaborator"
+1 -1
appview/pages/templates/repo/tree.html
··· 91 91 92 92 {{ define "repoAfter" }} 93 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 94 + {{ template "repo/fragments/readme" . }} 95 95 {{- end -}} 96 96 {{ end }}
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 30 30 </label> 31 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 32 <input 33 + autocapitalize="none" 34 + autocorrect="off" 33 35 type="text" 34 36 id="member-did-{{ .Id }}" 35 37 name="member"
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 5 {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 - <p class="">Store and share code snippets with ease.</p> 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 26 {{ end }} 27 27 28 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 29 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 - <div class="font-medium dark:text-white flex gap-2 items-center"> 31 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 32 35 </div> 33 36 {{ with .Description }} 34 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 45 43 46 {{ define "stringCardInfo" }} 44 47 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 - <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 - {{ template "user/fragments/picHandle" $resolved }} 49 - </a> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 50 <span class="select-none [&:before]:content-['·']"></span> 53 51 {{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 82 {{ $event := index . 1 }} 83 83 {{ $follow := $event.Follow }} 84 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 86 87 87 88 {{ $userHandle := resolve $follow.UserDid }} 88 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 95 </div> 95 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4"> 96 - <div class="flex items-center gap-4 flex-1"> 97 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 98 - <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 99 - </div> 100 - 101 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 102 - <a href="/{{ $subjectHandle }}"> 103 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 104 - </a> 105 - {{ with $profile }} 106 - {{ with .Description }} 107 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 108 - {{ end }} 109 - {{ end }} 110 - {{ with $stat }} 111 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 112 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 113 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 114 - <span class="select-none after:content-['·']"></span> 115 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 116 - </div> 117 - {{ end }} 118 - </div> 119 - </div> 120 - 121 - {{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }} 122 - <div class="flex-shrink-0 w-fit ml-auto"> 123 - {{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }} 124 - </div> 125 - {{ end }} 126 - </div> 96 + {{ template "user/fragments/followCard" 97 + (dict 98 + "LoggedInUser" $root.LoggedInUser 99 + "UserDid" $follow.SubjectDid 100 + "Profile" $profile 101 + "FollowStatus" $followStatus 102 + "FollowersCount" $followStats.Followers 103 + "FollowingCount" $followStats.Following) }} 127 104 {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }} 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 + {{ i "user-round-plus" "w-4 h-4" }} follow 17 + {{ else }} 18 + {{ i "user-round-minus" "w-4 h-4" }} unfollow 19 + {{ end }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['·']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 + <a href="/{{ $userIdent }}"> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 + </a> 14 + {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 + {{ end }} 17 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span class="select-none after:content-['·']"></span> 21 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 + </div> 19 23 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 + {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 25 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 24 26 {{ template "user/fragments/follow" . }} 25 27 </div> 26 - {{ end }} 28 + {{ end }} 29 + </div> 27 30 </div> 28 31 </div> 29 - {{ end }} 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
··· 14 14 {{ with $repo }} 15 15 <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 16 16 <div class="font-medium dark:text-white flex items-center justify-between"> 17 - <div class="flex items-center"> 18 - {{ if .Source }} 19 - {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 - {{ else }} 21 - {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 - {{ end }} 23 - 17 + <div class="flex items-center min-w-0 flex-1 mr-2"> 18 + {{ if .Source }} 19 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 + {{ else }} 21 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 + {{ end }} 24 23 {{ $repoOwner := resolve .Did }} 25 24 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 27 26 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 29 28 {{- end -}} 30 29 </div> 31 - 32 30 {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 33 32 {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 34 34 {{ end }} 35 35 </div> 36 36 {{ with .Description }}
+5 -1
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> ··· 28 29 <div class="flex flex-col"> 29 30 <label for="handle">handle</label> 30 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 31 35 type="text" 32 36 id="handle" 33 37 name="handle" ··· 36 40 placeholder="akshay.tngl.sh" 37 41 /> 38 42 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 43 + Use your <a href="https://atproto.com">AT Protocol</a> 40 44 handle to log in. If you're unsure, this is likely 41 45 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 46 </span>
+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 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+3 -4
appview/pipelines/pipelines.go
··· 16 16 "tangled.org/core/appview/reporesolver" 17 17 "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/idresolver" 19 - "tangled.org/core/log" 20 19 "tangled.org/core/rbac" 21 20 spindlemodel "tangled.org/core/spindle/models" 22 21 ··· 45 44 db *db.DB, 46 45 config *config.Config, 47 46 enforcer *rbac.Enforcer, 47 + logger *slog.Logger, 48 48 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 - return &Pipelines{oauth: oauth, 49 + return &Pipelines{ 50 + oauth: oauth, 52 51 repoResolver: repoResolver, 53 52 pages: pages, 54 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 6 "errors" 7 7 "fmt" 8 8 "log" 9 + "log/slog" 9 10 "net/http" 11 + "slices" 10 12 "sort" 11 13 "strconv" 12 14 "strings" ··· 21 23 "tangled.org/core/appview/pages" 22 24 "tangled.org/core/appview/pages/markup" 23 25 "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/validator" 24 27 "tangled.org/core/appview/xrpcclient" 25 28 "tangled.org/core/idresolver" 26 29 "tangled.org/core/patchutil" 30 + "tangled.org/core/rbac" 27 31 "tangled.org/core/tid" 28 32 "tangled.org/core/types" 29 33 30 - "github.com/bluekeyes/go-gitdiff/gitdiff" 31 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 35 lexutil "github.com/bluesky-social/indigo/lex/util" 33 36 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 43 46 db *db.DB 44 47 config *config.Config 45 48 notifier notify.Notifier 49 + enforcer *rbac.Enforcer 50 + logger *slog.Logger 51 + validator *validator.Validator 46 52 } 47 53 48 54 func New( ··· 53 59 db *db.DB, 54 60 config *config.Config, 55 61 notifier notify.Notifier, 62 + enforcer *rbac.Enforcer, 63 + validator *validator.Validator, 64 + logger *slog.Logger, 56 65 ) *Pulls { 57 66 return &Pulls{ 58 67 oauth: oauth, ··· 62 71 db: db, 63 72 config: config, 64 73 notifier: notifier, 74 + enforcer: enforcer, 75 + logger: logger, 76 + validator: validator, 65 77 } 66 78 } 67 79 ··· 98 110 } 99 111 100 112 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 113 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 101 114 resubmitResult := pages.Unknown 102 115 if user.Did == pull.OwnerDid { 103 116 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 117 } 105 118 106 119 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, 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, 114 128 }) 115 129 return 116 130 } ··· 135 149 stack, _ := r.Context().Value("stack").(models.Stack) 136 150 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 151 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 152 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 153 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 156 154 resubmitResult := pages.Unknown 157 155 if user != nil && user.Did == pull.OwnerDid { 158 156 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 189 187 m[p.Sha] = p 190 188 } 191 189 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 190 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 193 191 if err != nil { 194 192 log.Println("failed to get pull reactions") 195 193 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 198 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 199 } 202 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 + 203 217 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, 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, 212 227 213 228 OrderedReactionKinds: models.OrderedReactionKinds, 214 - Reactions: reactionCountMap, 229 + Reactions: reactionMap, 215 230 UserReacted: userReactions, 231 + 232 + LabelDefs: defs, 216 233 }) 217 234 } 218 235 ··· 283 300 return result 284 301 } 285 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 + 286 358 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 287 359 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 288 360 return pages.Unknown ··· 330 402 331 403 targetBranch := branchResp 332 404 333 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 405 + latestSourceRev := pull.LatestSha() 334 406 335 407 if pull.IsStacked() && stack != nil { 336 408 top := stack[0] 337 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 409 + latestSourceRev = top.LatestSha() 338 410 } 339 411 340 412 if latestSourceRev != targetBranch.Hash { ··· 374 446 return 375 447 } 376 448 377 - patch := pull.Submissions[roundIdInt].Patch 449 + patch := pull.Submissions[roundIdInt].CombinedPatch() 378 450 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 379 451 380 452 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 425 497 return 426 498 } 427 499 428 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 500 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 429 501 if err != nil { 430 502 log.Println("failed to interdiff; current patch malformed") 431 503 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 432 504 return 433 505 } 434 506 435 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 507 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 436 508 if err != nil { 437 509 log.Println("failed to interdiff; previous patch malformed") 438 510 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 557 629 m[p.Sha] = p 558 630 } 559 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 + 560 648 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 649 LoggedInUser: s.oauth.GetUser(r), 562 650 RepoInfo: f.RepoInfo(user), 563 651 Pulls: pulls, 652 + LabelDefs: defs, 564 653 FilteringBy: state, 565 654 Stacks: stacks, 566 655 Pipelines: m, ··· 617 706 618 707 createdAt := time.Now().Format(time.RFC3339) 619 708 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 709 client, err := s.oauth.AuthorizedClient(r) 628 710 if err != nil { 629 711 log.Println("failed to get authorized client", err) 630 712 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 631 713 return 632 714 } 633 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 715 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 634 716 Collection: tangled.RepoPullCommentNSID, 635 717 Repo: user.Did, 636 718 Rkey: tid.TID(), 637 719 Record: &lexutil.LexiconTypeDecoder{ 638 720 Val: &tangled.RepoPullComment{ 639 - Pull: string(pullAt), 721 + Pull: pull.PullAt().String(), 640 722 Body: body, 641 723 CreatedAt: createdAt, 642 724 }, ··· 884 966 } 885 967 886 968 sourceRev := comparison.Rev2 887 - patch := comparison.Patch 969 + patch := comparison.FormatPatchRaw 970 + combined := comparison.CombinedPatchRaw 888 971 889 - if !patchutil.IsPatchValid(patch) { 972 + if err := s.validator.ValidatePatch(&patch); err != nil { 973 + s.logger.Error("failed to validate patch", "err", err) 890 974 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 891 975 return 892 976 } ··· 899 983 Sha: comparison.Rev2, 900 984 } 901 985 902 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 986 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 903 987 } 904 988 905 989 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) { 990 + if err := s.validator.ValidatePatch(&patch); err != nil { 991 + s.logger.Error("patch validation failed", "err", err) 907 992 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 908 993 return 909 994 } 910 995 911 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 996 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 912 997 } 913 998 914 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) { ··· 991 1076 } 992 1077 993 1078 sourceRev := comparison.Rev2 994 - patch := comparison.Patch 1079 + patch := comparison.FormatPatchRaw 1080 + combined := comparison.CombinedPatchRaw 995 1081 996 - if !patchutil.IsPatchValid(patch) { 1082 + if err := s.validator.ValidatePatch(&patch); err != nil { 1083 + s.logger.Error("failed to validate patch", "err", err) 997 1084 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 998 1085 return 999 1086 } ··· 1011 1098 Sha: sourceRev, 1012 1099 } 1013 1100 1014 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1101 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1015 1102 } 1016 1103 1017 1104 func (s *Pulls) createPullRequest( ··· 1021 1108 user *oauth.User, 1022 1109 title, body, targetBranch string, 1023 1110 patch string, 1111 + combined string, 1024 1112 sourceRev string, 1025 1113 pullSource *models.PullSource, 1026 1114 recordPullSource *tangled.RepoPull_Source, ··· 1058 1146 1059 1147 // We've already checked earlier if it's diff-based and title is empty, 1060 1148 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1149 + if title == "" || body == "" { 1062 1150 formatPatches, err := patchutil.ExtractPatches(patch) 1063 1151 if err != nil { 1064 1152 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 1157 return 1070 1158 } 1071 1159 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1160 + if title == "" { 1161 + title = formatPatches[0].Title 1162 + } 1163 + if body == "" { 1164 + body = formatPatches[0].Body 1165 + } 1074 1166 } 1075 1167 1076 1168 rkey := tid.TID() 1077 1169 initialSubmission := models.PullSubmission{ 1078 1170 Patch: patch, 1171 + Combined: combined, 1079 1172 SourceRev: sourceRev, 1080 1173 } 1081 1174 pull := &models.Pull{ ··· 1103 1196 return 1104 1197 } 1105 1198 1106 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1107 1200 Collection: tangled.RepoPullNSID, 1108 1201 Repo: user.Did, 1109 1202 Rkey: rkey, ··· 1114 1207 Repo: string(f.RepoAt()), 1115 1208 Branch: targetBranch, 1116 1209 }, 1117 - Patch: patch, 1118 - Source: recordPullSource, 1210 + Patch: patch, 1211 + Source: recordPullSource, 1212 + CreatedAt: time.Now().Format(time.RFC3339), 1119 1213 }, 1120 1214 }, 1121 1215 }) ··· 1200 1294 } 1201 1295 writes = append(writes, &write) 1202 1296 } 1203 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1297 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1204 1298 Repo: user.Did, 1205 1299 Writes: writes, 1206 1300 }) ··· 1250 1344 return 1251 1345 } 1252 1346 1253 - if patch == "" || !patchutil.IsPatchValid(patch) { 1347 + if err := s.validator.ValidatePatch(&patch); err != nil { 1348 + s.logger.Error("faield to validate patch", "err", err) 1254 1349 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1255 1350 return 1256 1351 } ··· 1504 1599 1505 1600 patch := r.FormValue("patch") 1506 1601 1507 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1602 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1508 1603 } 1509 1604 1510 1605 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1565 1660 } 1566 1661 1567 1662 sourceRev := comparison.Rev2 1568 - patch := comparison.Patch 1663 + patch := comparison.FormatPatchRaw 1664 + combined := comparison.CombinedPatchRaw 1569 1665 1570 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1666 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1571 1667 } 1572 1668 1573 1669 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1599 1695 return 1600 1696 } 1601 1697 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 1698 // update the hidden tracking branch to latest 1629 1699 client, err := s.oauth.ServiceClient( 1630 1700 r, ··· 1656 1726 return 1657 1727 } 1658 1728 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 - } 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 + } 1667 1748 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.") 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 1672 1754 } 1673 1755 1674 - if patch == pull.LatestPatch() { 1675 - return fmt.Errorf("Patch is identical to previous submission.") 1676 - } 1756 + // Use the fork comparison we already made 1757 + comparison := forkComparison 1677 1758 1678 - if !patchutil.IsPatchValid(patch) { 1679 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1680 - } 1759 + sourceRev := comparison.Rev2 1760 + patch := comparison.FormatPatchRaw 1761 + combined := comparison.CombinedPatchRaw 1681 1762 1682 - return nil 1763 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1683 1764 } 1684 1765 1685 1766 func (s *Pulls) resubmitPullHelper( ··· 1689 1770 user *oauth.User, 1690 1771 pull *models.Pull, 1691 1772 patch string, 1773 + combined string, 1692 1774 sourceRev string, 1693 1775 ) { 1694 1776 if pull.IsStacked() { ··· 1697 1779 return 1698 1780 } 1699 1781 1700 - if err := validateResubmittedPatch(pull, patch); err != nil { 1782 + if err := s.validator.ValidatePatch(&patch); err != nil { 1701 1783 s.pages.Notice(w, "resubmit-error", err.Error()) 1702 1784 return 1703 1785 } 1704 1786 1787 + if patch == pull.LatestPatch() { 1788 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1789 + return 1790 + } 1791 + 1705 1792 // validate sourceRev if branch/fork based 1706 1793 if pull.IsBranchBased() || pull.IsForkBased() { 1707 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1794 + if sourceRev == pull.LatestSha() { 1708 1795 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1709 1796 return 1710 1797 } ··· 1718 1805 } 1719 1806 defer tx.Rollback() 1720 1807 1721 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 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) 1722 1814 if err != nil { 1723 1815 log.Println("failed to create pull request", err) 1724 1816 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1731 1823 return 1732 1824 } 1733 1825 1734 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1826 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1735 1827 if err != nil { 1736 1828 // failed to get record 1737 1829 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1754 1846 } 1755 1847 } 1756 1848 1757 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1849 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1758 1850 Collection: tangled.RepoPullNSID, 1759 1851 Repo: user.Did, 1760 1852 Rkey: pull.Rkey, ··· 1766 1858 Repo: string(f.RepoAt()), 1767 1859 Branch: pull.TargetBranch, 1768 1860 }, 1769 - Patch: patch, // new patch 1770 - Source: recordPullSource, 1861 + Patch: patch, // new patch 1862 + Source: recordPullSource, 1863 + CreatedAt: time.Now().Format(time.RFC3339), 1771 1864 }, 1772 1865 }, 1773 1866 }) ··· 1818 1911 // commits that got deleted: corresponding pull is closed 1819 1912 // commits that got added: new pull is created 1820 1913 // 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 1914 additions := make(map[string]*models.Pull) 1824 1915 deletions := make(map[string]*models.Pull) 1825 - unchanged := make(map[string]struct{}) 1826 1916 updated := make(map[string]struct{}) 1827 1917 1828 1918 // pulls in orignal stack but not in new one ··· 1844 1934 for _, np := range newStack { 1845 1935 if op, ok := origById[np.ChangeId]; ok { 1846 1936 // 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 - } 1937 + updated[op.ChangeId] = struct{}{} 1867 1938 } 1868 1939 } 1869 1940 ··· 1930 2001 continue 1931 2002 } 1932 2003 1933 - submission := np.Submissions[np.LastRoundNumber()] 1934 - 1935 - // resubmit the old pull 1936 - err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 1937 - 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) 1938 2011 if err != nil { 1939 2012 log.Println("failed to update pull", err, op.PullId) 1940 2013 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1941 2014 return 1942 2015 } 1943 2016 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 2017 + record := np.AsRecord() 1985 2018 1986 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1987 2020 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2026 2059 return 2027 2060 } 2028 2061 2029 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2062 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2030 2063 Repo: user.Did, 2031 2064 Writes: writes, 2032 2065 }) ··· 2147 2180 return 2148 2181 } 2149 2182 2183 + // notify about the pull merge 2184 + for _, p := range pullsToMerge { 2185 + s.notifier.NewPullMerged(r.Context(), p) 2186 + } 2187 + 2150 2188 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2151 2189 } 2152 2190 ··· 2212 2250 log.Println("failed to commit transaction", err) 2213 2251 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2214 2252 return 2253 + } 2254 + 2255 + for _, p := range pullsToClose { 2256 + s.notifier.NewPullClosed(r.Context(), p) 2215 2257 } 2216 2258 2217 2259 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2313 2355 initialSubmission := models.PullSubmission{ 2314 2356 Patch: fp.Raw, 2315 2357 SourceRev: fp.SHA, 2358 + Combined: fp.Raw, 2316 2359 } 2317 2360 pull := models.Pull{ 2318 2361 Title: title,
+1
appview/pulls/router.go
··· 23 23 r.Route("/{pull}", func(r chi.Router) { 24 24 r.Use(mw.ResolvePull()) 25 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 26 27 27 28 r.Route("/round/{round}", func(r chi.Router) { 28 29 r.Get("/", s.RepoPullPatch)
+11 -10
appview/repo/artifact.go
··· 10 10 "net/url" 11 11 "time" 12 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview/db" 22 15 "tangled.org/core/appview/models" ··· 25 18 "tangled.org/core/appview/xrpcclient" 26 19 "tangled.org/core/tid" 27 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 28 29 ) 29 30 30 31 // TODO: proper statuses here on early exit ··· 60 61 return 61 62 } 62 63 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 64 65 if err != nil { 65 66 log.Println("failed to upload blob", err) 66 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 73 rkey := tid.TID() 73 74 createdAt := time.Now() 74 75 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 76 77 Collection: tangled.RepoArtifactNSID, 77 78 Repo: user.Did, 78 79 Rkey: rkey, ··· 249 250 return 250 251 } 251 252 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 253 254 Collection: tangled.RepoArtifactNSID, 254 255 Repo: user.Did, 255 256 Rkey: artifact.Rkey,
+31 -33
appview/repo/index.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 6 + "log/slog" 7 7 "net/http" 8 8 "net/url" 9 9 "slices" ··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 25 "tangled.org/core/appview/reporesolver" 27 26 "tangled.org/core/appview/xrpcclient" 28 27 "tangled.org/core/types" ··· 32 31 ) 33 32 34 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 35 36 ref := chi.URLParam(r, "ref") 36 37 ref, _ = url.PathUnescape(ref) 37 38 38 39 f, err := rp.repoResolver.Resolve(r) 39 40 if err != nil { 40 - log.Println("failed to fully resolve repo", err) 41 + l.Error("failed to fully resolve repo", "err", err) 41 42 return 42 43 } 43 44 ··· 57 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 58 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 59 60 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 60 - log.Println("failed to call XRPC repo.index", err) 61 + l.Error("failed to call XRPC repo.index", "err", err) 61 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 62 63 LoggedInUser: user, 63 64 NeedsKnotUpgrade: true, ··· 67 68 } 68 69 69 70 rp.pages.Error503(w) 70 - log.Println("failed to build index response", err) 71 + l.Error("failed to build index response", "err", err) 71 72 return 72 73 } 73 74 ··· 120 121 emails := uniqueEmails(commitsTrunc) 121 122 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 122 123 if err != nil { 123 - log.Println("failed to get email to did map", err) 124 + l.Error("failed to get email to did map", "err", err) 124 125 } 125 126 126 127 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 127 128 if err != nil { 128 - log.Println(err) 129 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 129 130 } 130 131 131 132 // TODO: a bit dirty 132 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 133 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 133 134 if err != nil { 134 - log.Printf("failed to compute language percentages: %s", err) 135 + l.Warn("failed to compute language percentages", "err", err) 135 136 // non-fatal 136 137 } 137 138 ··· 141 142 } 142 143 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 143 144 if err != nil { 144 - log.Printf("failed to fetch pipeline statuses: %s", err) 145 + l.Error("failed to fetch pipeline statuses", "err", err) 145 146 // non-fatal 146 147 } 147 148 ··· 163 164 164 165 func (rp *Repo) getLanguageInfo( 165 166 ctx context.Context, 167 + l *slog.Logger, 166 168 f *reporesolver.ResolvedRepo, 167 169 xrpcc *indigoxrpc.Client, 168 170 currentRef string, ··· 181 183 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 182 184 if err != nil { 183 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 184 - log.Println("failed to call XRPC repo.languages", xrpcerr) 186 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 185 187 return nil, xrpcerr 186 188 } 187 189 return nil, err ··· 201 203 }) 202 204 } 203 205 206 + tx, err := rp.db.Begin() 207 + if err != nil { 208 + return nil, err 209 + } 210 + defer tx.Rollback() 211 + 204 212 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 213 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 206 214 if err != nil { 207 215 // non-fatal 208 - log.Println("failed to cache lang results", err) 216 + l.Error("failed to cache lang results", "err", err) 217 + } 218 + 219 + err = tx.Commit() 220 + if err != nil { 221 + return nil, err 209 222 } 210 223 } 211 224 ··· 328 341 } 329 342 }() 330 343 331 - // readme content 332 - wg.Add(1) 333 - go func() { 334 - defer wg.Done() 335 - for _, filename := range markup.ReadmeFilenames { 336 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 - if err != nil { 338 - continue 339 - } 340 - 341 - if blobResp == nil { 342 - continue 343 - } 344 - 345 - readmeContent = blobResp.Content 346 - readmeFileName = filename 347 - break 348 - } 349 - }() 350 - 351 344 wg.Wait() 352 345 353 346 if errs != nil { ··· 374 367 } 375 368 files = append(files, niceFile) 376 369 } 370 + } 371 + 372 + if treeResp != nil && treeResp.Readme != nil { 373 + readmeFileName = treeResp.Readme.Filename 374 + readmeContent = treeResp.Readme.Contents 377 375 } 378 376 379 377 result := &types.RepoIndexResponse{
+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 7 "errors" 8 8 "fmt" 9 9 "io" 10 - "log" 11 10 "log/slog" 12 11 "net/http" 13 12 "net/url" ··· 17 16 "strings" 18 17 "time" 19 18 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 19 "tangled.org/core/api/tangled" 24 20 "tangled.org/core/appview/commitverify" 25 21 "tangled.org/core/appview/config" ··· 40 36 "tangled.org/core/types" 41 37 "tangled.org/core/xrpc/serviceauth" 42 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" 43 44 securejoin "github.com/cyphar/filepath-securejoin" 44 45 "github.com/go-chi/chi/v5" 45 46 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 47 ) 49 48 50 49 type Repo struct { ··· 90 89 } 91 90 92 91 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 + l := rp.logger.With("handler", "DownloadArchive") 93 + 93 94 ref := chi.URLParam(r, "ref") 94 95 ref, _ = url.PathUnescape(ref) 95 96 96 97 f, err := rp.repoResolver.Resolve(r) 97 98 if err != nil { 98 - log.Println("failed to get repo and knot", err) 99 + l.Error("failed to get repo and knot", "err", err) 99 100 return 100 101 } 101 102 ··· 111 112 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 112 113 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 113 114 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 114 - log.Println("failed to call XRPC repo.archive", xrpcerr) 115 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 115 116 rp.pages.Error503(w) 116 117 return 117 118 } ··· 128 129 } 129 130 130 131 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 + l := rp.logger.With("handler", "RepoLog") 133 + 131 134 f, err := rp.repoResolver.Resolve(r) 132 135 if err != nil { 133 - log.Println("failed to fully resolve repo", err) 136 + l.Error("failed to fully resolve repo", "err", err) 134 137 return 135 138 } 136 139 ··· 165 168 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 166 169 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 167 170 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 - log.Println("failed to call XRPC repo.log", xrpcerr) 171 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 169 172 rp.pages.Error503(w) 170 173 return 171 174 } 172 175 173 176 var xrpcResp types.RepoLogResponse 174 177 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 - log.Println("failed to decode XRPC response", err) 178 + l.Error("failed to decode XRPC response", "err", err) 176 179 rp.pages.Error503(w) 177 180 return 178 181 } 179 182 180 183 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 181 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 - log.Println("failed to call XRPC repo.tags", xrpcerr) 185 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 183 186 rp.pages.Error503(w) 184 187 return 185 188 } ··· 189 192 var tagResp types.RepoTagsResponse 190 193 if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 191 194 for _, tag := range tagResp.Tags { 192 - tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 195 + hash := tag.Hash 196 + if tag.Tag != nil { 197 + hash = tag.Tag.Target.String() 198 + } 199 + tagMap[hash] = append(tagMap[hash], tag.Name) 193 200 } 194 201 } 195 202 } 196 203 197 204 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 205 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 - log.Println("failed to call XRPC repo.branches", xrpcerr) 206 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 200 207 rp.pages.Error503(w) 201 208 return 202 209 } ··· 214 221 215 222 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 216 223 if err != nil { 217 - log.Println("failed to fetch email to did mapping", err) 224 + l.Error("failed to fetch email to did mapping", "err", err) 218 225 } 219 226 220 227 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 221 228 if err != nil { 222 - log.Println(err) 229 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 223 230 } 224 231 225 232 repoInfo := f.RepoInfo(user) ··· 230 237 } 231 238 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 232 239 if err != nil { 233 - log.Println(err) 240 + l.Error("failed to getPipelineStatuses", "err", err) 234 241 // non-fatal 235 242 } 236 243 ··· 246 253 } 247 254 248 255 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 + l := rp.logger.With("handler", "RepoDescriptionEdit") 257 + 249 258 f, err := rp.repoResolver.Resolve(r) 250 259 if err != nil { 251 - log.Println("failed to get repo and knot", err) 260 + l.Error("failed to get repo and knot", "err", err) 252 261 w.WriteHeader(http.StatusBadRequest) 253 262 return 254 263 } ··· 260 269 } 261 270 262 271 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 + l := rp.logger.With("handler", "RepoDescription") 273 + 263 274 f, err := rp.repoResolver.Resolve(r) 264 275 if err != nil { 265 - log.Println("failed to get repo and knot", err) 276 + l.Error("failed to get repo and knot", "err", err) 266 277 w.WriteHeader(http.StatusBadRequest) 267 278 return 268 279 } ··· 270 281 repoAt := f.RepoAt() 271 282 rkey := repoAt.RecordKey().String() 272 283 if rkey == "" { 273 - log.Println("invalid aturi for repo", err) 284 + l.Error("invalid aturi for repo", "err", err) 274 285 w.WriteHeader(http.StatusInternalServerError) 275 286 return 276 287 } ··· 287 298 newDescription := r.FormValue("description") 288 299 client, err := rp.oauth.AuthorizedClient(r) 289 300 if err != nil { 290 - log.Println("failed to get client") 301 + l.Error("failed to get client") 291 302 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 292 303 return 293 304 } ··· 295 306 // optimistic update 296 307 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 297 308 if err != nil { 298 - log.Println("failed to perferom update-description query", err) 309 + l.Error("failed to perform update-description query", "err", err) 299 310 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 300 311 return 301 312 } ··· 307 318 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 319 // 309 320 // 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) 321 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 322 if err != nil { 312 323 // failed to get record 313 324 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 325 return 315 326 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 327 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 328 Collection: tangled.RepoNSID, 318 329 Repo: newRepo.Did, 319 330 Rkey: newRepo.Rkey, ··· 324 335 }) 325 336 326 337 if err != nil { 327 - log.Println("failed to perferom update-description query", err) 338 + l.Error("failed to perferom update-description query", "err", err) 328 339 // failed to get record 329 340 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 330 341 return ··· 341 352 } 342 353 343 354 func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 + l := rp.logger.With("handler", "RepoCommit") 356 + 344 357 f, err := rp.repoResolver.Resolve(r) 345 358 if err != nil { 346 - log.Println("failed to fully resolve repo", err) 359 + l.Error("failed to fully resolve repo", "err", err) 347 360 return 348 361 } 349 362 ref := chi.URLParam(r, "ref") ··· 371 384 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 372 385 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 373 386 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 374 - log.Println("failed to call XRPC repo.diff", xrpcerr) 387 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 375 388 rp.pages.Error503(w) 376 389 return 377 390 } 378 391 379 392 var result types.RepoCommitResponse 380 393 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 381 - log.Println("failed to decode XRPC response", err) 394 + l.Error("failed to decode XRPC response", "err", err) 382 395 rp.pages.Error503(w) 383 396 return 384 397 } 385 398 386 399 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 387 400 if err != nil { 388 - log.Println("failed to get email to did mapping:", err) 401 + l.Error("failed to get email to did mapping", "err", err) 389 402 } 390 403 391 404 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 392 405 if err != nil { 393 - log.Println(err) 406 + l.Error("failed to GetVerifiedCommits", "err", err) 394 407 } 395 408 396 409 user := rp.oauth.GetUser(r) 397 410 repoInfo := f.RepoInfo(user) 398 411 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 399 412 if err != nil { 400 - log.Println(err) 413 + l.Error("failed to getPipelineStatuses", "err", err) 401 414 // non-fatal 402 415 } 403 416 var pipeline *models.Pipeline ··· 417 430 } 418 431 419 432 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 + l := rp.logger.With("handler", "RepoTree") 434 + 420 435 f, err := rp.repoResolver.Resolve(r) 421 436 if err != nil { 422 - log.Println("failed to fully resolve repo", err) 437 + l.Error("failed to fully resolve repo", "err", err) 423 438 return 424 439 } 425 440 ··· 444 459 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 445 460 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 446 461 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 - log.Println("failed to call XRPC repo.tree", xrpcerr) 462 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 448 463 rp.pages.Error503(w) 449 464 return 450 465 } 451 466 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 467 // Convert XRPC response to internal types.RepoTreeResponse 475 468 files := make([]types.NiceTree, len(xrpcResp.Files)) 476 469 for i, xrpcFile := range xrpcResp.Files { ··· 506 499 if xrpcResp.Dotdot != nil { 507 500 result.DotDot = *xrpcResp.Dotdot 508 501 } 502 + if xrpcResp.Readme != nil { 503 + result.ReadmeFileName = xrpcResp.Readme.Filename 504 + result.Readme = xrpcResp.Readme.Contents 505 + } 509 506 510 507 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 511 508 // so we can safely redirect to the "parent" (which is the same file). ··· 532 529 BreadCrumbs: breadcrumbs, 533 530 TreePath: treePath, 534 531 RepoInfo: f.RepoInfo(user), 535 - Readme: readmeContent, 536 - ReadmeFileName: readmeFileName, 537 532 RepoTreeResponse: result, 538 533 }) 539 534 } 540 535 541 536 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 + l := rp.logger.With("handler", "RepoTags") 538 + 542 539 f, err := rp.repoResolver.Resolve(r) 543 540 if err != nil { 544 - log.Println("failed to get repo and knot", err) 541 + l.Error("failed to get repo and knot", "err", err) 545 542 return 546 543 } 547 544 ··· 557 554 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 558 555 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 559 556 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 560 - log.Println("failed to call XRPC repo.tags", xrpcerr) 557 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 561 558 rp.pages.Error503(w) 562 559 return 563 560 } 564 561 565 562 var result types.RepoTagsResponse 566 563 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 567 - log.Println("failed to decode XRPC response", err) 564 + l.Error("failed to decode XRPC response", "err", err) 568 565 rp.pages.Error503(w) 569 566 return 570 567 } 571 568 572 569 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 573 570 if err != nil { 574 - log.Println("failed grab artifacts", err) 571 + l.Error("failed grab artifacts", "err", err) 575 572 return 576 573 } 577 574 ··· 608 605 } 609 606 610 607 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 + l := rp.logger.With("handler", "RepoBranches") 609 + 611 610 f, err := rp.repoResolver.Resolve(r) 612 611 if err != nil { 613 - log.Println("failed to get repo and knot", err) 612 + l.Error("failed to get repo and knot", "err", err) 614 613 return 615 614 } 616 615 ··· 626 625 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 627 626 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 628 627 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 629 - log.Println("failed to call XRPC repo.branches", xrpcerr) 628 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 630 629 rp.pages.Error503(w) 631 630 return 632 631 } 633 632 634 633 var result types.RepoBranchesResponse 635 634 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 636 - log.Println("failed to decode XRPC response", err) 635 + l.Error("failed to decode XRPC response", "err", err) 637 636 rp.pages.Error503(w) 638 637 return 639 638 } ··· 648 647 }) 649 648 } 650 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 + 651 699 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 + l := rp.logger.With("handler", "RepoBlob") 701 + 652 702 f, err := rp.repoResolver.Resolve(r) 653 703 if err != nil { 654 - log.Println("failed to get repo and knot", err) 704 + l.Error("failed to get repo and knot", "err", err) 655 705 return 656 706 } 657 707 ··· 673 723 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 674 724 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 675 725 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 676 - log.Println("failed to call XRPC repo.blob", xrpcerr) 726 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 677 727 rp.pages.Error503(w) 678 728 return 679 729 } ··· 773 823 } 774 824 775 825 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 + l := rp.logger.With("handler", "RepoBlobRaw") 827 + 776 828 f, err := rp.repoResolver.Resolve(r) 777 829 if err != nil { 778 - log.Println("failed to get repo and knot", err) 830 + l.Error("failed to get repo and knot", "err", err) 779 831 w.WriteHeader(http.StatusBadRequest) 780 832 return 781 833 } ··· 807 859 808 860 req, err := http.NewRequest("GET", blobURL, nil) 809 861 if err != nil { 810 - log.Println("failed to create request", err) 862 + l.Error("failed to create request", "err", err) 811 863 return 812 864 } 813 865 ··· 819 871 client := &http.Client{} 820 872 resp, err := client.Do(req) 821 873 if err != nil { 822 - log.Println("failed to reach knotserver", err) 874 + l.Error("failed to reach knotserver", "err", err) 823 875 rp.pages.Error503(w) 824 876 return 825 877 } ··· 832 884 } 833 885 834 886 if resp.StatusCode != http.StatusOK { 835 - log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 887 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 836 888 w.WriteHeader(resp.StatusCode) 837 889 _, _ = io.Copy(w, resp.Body) 838 890 return ··· 841 893 contentType := resp.Header.Get("Content-Type") 842 894 body, err := io.ReadAll(resp.Body) 843 895 if err != nil { 844 - log.Printf("error reading response body from knotserver: %v", err) 896 + l.Error("error reading response body from knotserver", "err", err) 845 897 w.WriteHeader(http.StatusInternalServerError) 846 898 return 847 899 } ··· 883 935 user := rp.oauth.GetUser(r) 884 936 l := rp.logger.With("handler", "EditSpindle") 885 937 l = l.With("did", user.Did) 886 - l = l.With("handle", user.Handle) 887 938 888 939 errorId := "operation-error" 889 940 fail := func(msg string, err error) { ··· 936 987 return 937 988 } 938 989 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 990 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 991 if err != nil { 941 992 fail("Failed to update spindle, no record found on PDS.", err) 942 993 return 943 994 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 995 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 945 996 Collection: tangled.RepoNSID, 946 997 Repo: newRepo.Did, 947 998 Rkey: newRepo.Rkey, ··· 971 1022 user := rp.oauth.GetUser(r) 972 1023 l := rp.logger.With("handler", "AddLabel") 973 1024 l = l.With("did", user.Did) 974 - l = l.With("handle", user.Handle) 975 1025 976 1026 f, err := rp.repoResolver.Resolve(r) 977 1027 if err != nil { ··· 1040 1090 1041 1091 // emit a labelRecord 1042 1092 labelRecord := label.AsRecord() 1043 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1093 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1044 1094 Collection: tangled.LabelDefinitionNSID, 1045 1095 Repo: label.Did, 1046 1096 Rkey: label.Rkey, ··· 1063 1113 newRepo.Labels = append(newRepo.Labels, aturi) 1064 1114 repoRecord := newRepo.AsRecord() 1065 1115 1066 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1116 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1067 1117 if err != nil { 1068 1118 fail("Failed to update labels, no record found on PDS.", err) 1069 1119 return 1070 1120 } 1071 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1121 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1072 1122 Collection: tangled.RepoNSID, 1073 1123 Repo: newRepo.Did, 1074 1124 Rkey: newRepo.Rkey, ··· 1131 1181 user := rp.oauth.GetUser(r) 1132 1182 l := rp.logger.With("handler", "DeleteLabel") 1133 1183 l = l.With("did", user.Did) 1134 - l = l.With("handle", user.Handle) 1135 1184 1136 1185 f, err := rp.repoResolver.Resolve(r) 1137 1186 if err != nil { ··· 1161 1210 } 1162 1211 1163 1212 // delete label record from PDS 1164 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1213 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1165 1214 Collection: tangled.LabelDefinitionNSID, 1166 1215 Repo: label.Did, 1167 1216 Rkey: label.Rkey, ··· 1183 1232 newRepo.Labels = updated 1184 1233 repoRecord := newRepo.AsRecord() 1185 1234 1186 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1235 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1187 1236 if err != nil { 1188 1237 fail("Failed to update labels, no record found on PDS.", err) 1189 1238 return 1190 1239 } 1191 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1240 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1192 1241 Collection: tangled.RepoNSID, 1193 1242 Repo: newRepo.Did, 1194 1243 Rkey: newRepo.Rkey, ··· 1240 1289 user := rp.oauth.GetUser(r) 1241 1290 l := rp.logger.With("handler", "SubscribeLabel") 1242 1291 l = l.With("did", user.Did) 1243 - l = l.With("handle", user.Handle) 1244 1292 1245 1293 f, err := rp.repoResolver.Resolve(r) 1246 1294 if err != nil { ··· 1281 1329 return 1282 1330 } 1283 1331 1284 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1332 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1285 1333 if err != nil { 1286 1334 fail("Failed to update labels, no record found on PDS.", err) 1287 1335 return 1288 1336 } 1289 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1337 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1290 1338 Collection: tangled.RepoNSID, 1291 1339 Repo: newRepo.Did, 1292 1340 Rkey: newRepo.Rkey, ··· 1327 1375 user := rp.oauth.GetUser(r) 1328 1376 l := rp.logger.With("handler", "UnsubscribeLabel") 1329 1377 l = l.With("did", user.Did) 1330 - l = l.With("handle", user.Handle) 1331 1378 1332 1379 f, err := rp.repoResolver.Resolve(r) 1333 1380 if err != nil { ··· 1370 1417 return 1371 1418 } 1372 1419 1373 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1420 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1374 1421 if err != nil { 1375 1422 fail("Failed to update labels, no record found on PDS.", err) 1376 1423 return 1377 1424 } 1378 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1425 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1379 1426 Collection: tangled.RepoNSID, 1380 1427 Repo: newRepo.Did, 1381 1428 Rkey: newRepo.Rkey, ··· 1421 1468 db.FilterContains("scope", subject.Collection().String()), 1422 1469 ) 1423 1470 if err != nil { 1424 - log.Println("failed to fetch label defs", err) 1471 + l.Error("failed to fetch label defs", "err", err) 1425 1472 return 1426 1473 } 1427 1474 ··· 1432 1479 1433 1480 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1434 1481 if err != nil { 1435 - log.Println("failed to build label state", err) 1482 + l.Error("failed to build label state", "err", err) 1436 1483 return 1437 1484 } 1438 1485 state := states[subject] ··· 1469 1516 db.FilterContains("scope", subject.Collection().String()), 1470 1517 ) 1471 1518 if err != nil { 1472 - log.Println("failed to fetch labels", err) 1519 + l.Error("failed to fetch labels", "err", err) 1473 1520 return 1474 1521 } 1475 1522 ··· 1480 1527 1481 1528 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1482 1529 if err != nil { 1483 - log.Println("failed to build label state", err) 1530 + l.Error("failed to build label state", "err", err) 1484 1531 return 1485 1532 } 1486 1533 state := states[subject] ··· 1499 1546 user := rp.oauth.GetUser(r) 1500 1547 l := rp.logger.With("handler", "AddCollaborator") 1501 1548 l = l.With("did", user.Did) 1502 - l = l.With("handle", user.Handle) 1503 1549 1504 1550 f, err := rp.repoResolver.Resolve(r) 1505 1551 if err != nil { ··· 1546 1592 currentUser := rp.oauth.GetUser(r) 1547 1593 rkey := tid.TID() 1548 1594 createdAt := time.Now() 1549 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1595 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1550 1596 Collection: tangled.RepoCollaboratorNSID, 1551 1597 Repo: currentUser.Did, 1552 1598 Rkey: rkey, ··· 1628 1674 1629 1675 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1630 1676 user := rp.oauth.GetUser(r) 1677 + l := rp.logger.With("handler", "DeleteRepo") 1631 1678 1632 1679 noticeId := "operation-error" 1633 1680 f, err := rp.repoResolver.Resolve(r) 1634 1681 if err != nil { 1635 - log.Println("failed to get repo and knot", err) 1682 + l.Error("failed to get repo and knot", "err", err) 1636 1683 return 1637 1684 } 1638 1685 1639 1686 // remove record from pds 1640 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1687 + atpClient, err := rp.oauth.AuthorizedClient(r) 1641 1688 if err != nil { 1642 - log.Println("failed to get authorized client", err) 1689 + l.Error("failed to get authorized client", "err", err) 1643 1690 return 1644 1691 } 1645 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1692 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1646 1693 Collection: tangled.RepoNSID, 1647 1694 Repo: user.Did, 1648 1695 Rkey: f.Rkey, 1649 1696 }) 1650 1697 if err != nil { 1651 - log.Printf("failed to delete record: %s", err) 1698 + l.Error("failed to delete record", "err", err) 1652 1699 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1653 1700 return 1654 1701 } 1655 - log.Println("removed repo record ", f.RepoAt().String()) 1702 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1656 1703 1657 1704 client, err := rp.oauth.ServiceClient( 1658 1705 r, ··· 1661 1708 oauth.WithDev(rp.config.Core.Dev), 1662 1709 ) 1663 1710 if err != nil { 1664 - log.Println("failed to connect to knot server:", err) 1711 + l.Error("failed to connect to knot server", "err", err) 1665 1712 return 1666 1713 } 1667 1714 ··· 1678 1725 rp.pages.Notice(w, noticeId, err.Error()) 1679 1726 return 1680 1727 } 1681 - log.Println("deleted repo from knot") 1728 + l.Info("deleted repo from knot") 1682 1729 1683 1730 tx, err := rp.db.BeginTx(r.Context(), nil) 1684 1731 if err != nil { 1685 - log.Println("failed to start tx") 1732 + l.Error("failed to start tx") 1686 1733 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1687 1734 return 1688 1735 } ··· 1690 1737 tx.Rollback() 1691 1738 err = rp.enforcer.E.LoadPolicy() 1692 1739 if err != nil { 1693 - log.Println("failed to rollback policies") 1740 + l.Error("failed to rollback policies") 1694 1741 } 1695 1742 }() 1696 1743 ··· 1704 1751 did := c[0] 1705 1752 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1706 1753 } 1707 - log.Println("removed collaborators") 1754 + l.Info("removed collaborators") 1708 1755 1709 1756 // remove repo RBAC 1710 1757 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1719 1766 rp.pages.Notice(w, noticeId, "Failed to update appview") 1720 1767 return 1721 1768 } 1722 - log.Println("removed repo from db") 1769 + l.Info("removed repo from db") 1723 1770 1724 1771 err = tx.Commit() 1725 1772 if err != nil { 1726 - log.Println("failed to commit changes", err) 1773 + l.Error("failed to commit changes", "err", err) 1727 1774 http.Error(w, err.Error(), http.StatusInternalServerError) 1728 1775 return 1729 1776 } 1730 1777 1731 1778 err = rp.enforcer.E.SavePolicy() 1732 1779 if err != nil { 1733 - log.Println("failed to update ACLs", err) 1780 + l.Error("failed to update ACLs", "err", err) 1734 1781 http.Error(w, err.Error(), http.StatusInternalServerError) 1735 1782 return 1736 1783 } ··· 1739 1786 } 1740 1787 1741 1788 func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 + l := rp.logger.With("handler", "SetDefaultBranch") 1790 + 1742 1791 f, err := rp.repoResolver.Resolve(r) 1743 1792 if err != nil { 1744 - log.Println("failed to get repo and knot", err) 1793 + l.Error("failed to get repo and knot", "err", err) 1745 1794 return 1746 1795 } 1747 1796 ··· 1759 1808 oauth.WithDev(rp.config.Core.Dev), 1760 1809 ) 1761 1810 if err != nil { 1762 - log.Println("failed to connect to knot server:", err) 1811 + l.Error("failed to connect to knot server", "err", err) 1763 1812 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1764 1813 return 1765 1814 } ··· 1773 1822 }, 1774 1823 ) 1775 1824 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1776 - log.Println("xrpc failed", "err", xe) 1825 + l.Error("xrpc failed", "err", xe) 1777 1826 rp.pages.Notice(w, noticeId, err.Error()) 1778 1827 return 1779 1828 } ··· 1784 1833 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1785 1834 user := rp.oauth.GetUser(r) 1786 1835 l := rp.logger.With("handler", "Secrets") 1787 - l = l.With("handle", user.Handle) 1788 1836 l = l.With("did", user.Did) 1789 1837 1790 1838 f, err := rp.repoResolver.Resolve(r) 1791 1839 if err != nil { 1792 - log.Println("failed to get repo and knot", err) 1840 + l.Error("failed to get repo and knot", "err", err) 1793 1841 return 1794 1842 } 1795 1843 1796 1844 if f.Spindle == "" { 1797 - log.Println("empty spindle cannot add/rm secret", err) 1845 + l.Error("empty spindle cannot add/rm secret", "err", err) 1798 1846 return 1799 1847 } 1800 1848 ··· 1811 1859 oauth.WithDev(rp.config.Core.Dev), 1812 1860 ) 1813 1861 if err != nil { 1814 - log.Println("failed to create spindle client", err) 1862 + l.Error("failed to create spindle client", "err", err) 1815 1863 return 1816 1864 } 1817 1865 ··· 1897 1945 } 1898 1946 1899 1947 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 + l := rp.logger.With("handler", "generalSettings") 1949 + 1900 1950 f, err := rp.repoResolver.Resolve(r) 1901 1951 user := rp.oauth.GetUser(r) 1902 1952 ··· 1912 1962 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1913 1963 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1914 1964 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1915 - log.Println("failed to call XRPC repo.branches", xrpcerr) 1965 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1916 1966 rp.pages.Error503(w) 1917 1967 return 1918 1968 } 1919 1969 1920 1970 var result types.RepoBranchesResponse 1921 1971 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1922 - log.Println("failed to decode XRPC response", err) 1972 + l.Error("failed to decode XRPC response", "err", err) 1923 1973 rp.pages.Error503(w) 1924 1974 return 1925 1975 } 1926 1976 1927 1977 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1928 1978 if err != nil { 1929 - log.Println("failed to fetch labels", err) 1979 + l.Error("failed to fetch labels", "err", err) 1930 1980 rp.pages.Error503(w) 1931 1981 return 1932 1982 } 1933 1983 1934 1984 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1935 1985 if err != nil { 1936 - log.Println("failed to fetch labels", err) 1986 + l.Error("failed to fetch labels", "err", err) 1937 1987 rp.pages.Error503(w) 1938 1988 return 1939 1989 } ··· 1981 2031 } 1982 2032 1983 2033 func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 + l := rp.logger.With("handler", "accessSettings") 2035 + 1984 2036 f, err := rp.repoResolver.Resolve(r) 1985 2037 user := rp.oauth.GetUser(r) 1986 2038 1987 2039 repoCollaborators, err := f.Collaborators(r.Context()) 1988 2040 if err != nil { 1989 - log.Println("failed to get collaborators", err) 2041 + l.Error("failed to get collaborators", "err", err) 1990 2042 } 1991 2043 1992 2044 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ ··· 1999 2051 } 2000 2052 2001 2053 func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 + l := rp.logger.With("handler", "pipelineSettings") 2055 + 2002 2056 f, err := rp.repoResolver.Resolve(r) 2003 2057 user := rp.oauth.GetUser(r) 2004 2058 2005 2059 // all spindles that the repo owner is a member of 2006 2060 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2007 2061 if err != nil { 2008 - log.Println("failed to fetch spindles", err) 2062 + l.Error("failed to fetch spindles", "err", err) 2009 2063 return 2010 2064 } 2011 2065 ··· 2018 2072 oauth.WithExp(60), 2019 2073 oauth.WithDev(rp.config.Core.Dev), 2020 2074 ); err != nil { 2021 - log.Println("failed to create spindle client", err) 2075 + l.Error("failed to create spindle client", "err", err) 2022 2076 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2023 - log.Println("failed to fetch secrets", err) 2077 + l.Error("failed to fetch secrets", "err", err) 2024 2078 } else { 2025 2079 secrets = resp.Secrets 2026 2080 } ··· 2060 2114 } 2061 2115 2062 2116 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 + l := rp.logger.With("handler", "SyncRepoFork") 2118 + 2063 2119 ref := chi.URLParam(r, "ref") 2064 2120 ref, _ = url.PathUnescape(ref) 2065 2121 2066 2122 user := rp.oauth.GetUser(r) 2067 2123 f, err := rp.repoResolver.Resolve(r) 2068 2124 if err != nil { 2069 - log.Printf("failed to resolve source repo: %v", err) 2125 + l.Error("failed to resolve source repo", "err", err) 2070 2126 return 2071 2127 } 2072 2128 ··· 2110 2166 } 2111 2167 2112 2168 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2169 + l := rp.logger.With("handler", "ForkRepo") 2170 + 2113 2171 user := rp.oauth.GetUser(r) 2114 2172 f, err := rp.repoResolver.Resolve(r) 2115 2173 if err != nil { 2116 - log.Printf("failed to resolve source repo: %v", err) 2174 + l.Error("failed to resolve source repo", "err", err) 2117 2175 return 2118 2176 } 2119 2177 ··· 2149 2207 } 2150 2208 2151 2209 // choose a name for a fork 2152 - forkName := f.Name 2210 + forkName := r.FormValue("repo_name") 2211 + if forkName == "" { 2212 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2213 + return 2214 + } 2215 + 2153 2216 // this check is *only* to see if the forked repo name already exists 2154 2217 // in the user's account. 2155 2218 existingRepo, err := db.GetRepo( 2156 2219 rp.db, 2157 2220 db.FilterEq("did", user.Did), 2158 - db.FilterEq("name", f.Name), 2221 + db.FilterEq("name", forkName), 2159 2222 ) 2160 2223 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) 2224 + if !errors.Is(err, sql.ErrNoRows) { 2225 + l.Error("error fetching existing repo from db", "err", err) 2165 2226 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2166 2227 return 2167 2228 } 2168 2229 } else if existingRepo != nil { 2169 - // repo with this name already exists, append random string 2170 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2230 + // repo with this name already exists 2231 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2232 + return 2171 2233 } 2172 2234 l = l.With("forkName", forkName) 2173 2235 ··· 2189 2251 Knot: targetKnot, 2190 2252 Rkey: rkey, 2191 2253 Source: sourceAt, 2192 - Description: existingRepo.Description, 2254 + Description: f.Repo.Description, 2193 2255 Created: time.Now(), 2194 2256 Labels: models.DefaultLabelDefs(), 2195 2257 } 2196 2258 record := repo.AsRecord() 2197 2259 2198 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2260 + atpClient, err := rp.oauth.AuthorizedClient(r) 2199 2261 if err != nil { 2200 2262 l.Error("failed to create xrpcclient", "err", err) 2201 2263 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2202 2264 return 2203 2265 } 2204 2266 2205 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2267 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2206 2268 Collection: tangled.RepoNSID, 2207 2269 Repo: user.Did, 2208 2270 Rkey: rkey, ··· 2234 2296 rollback := func() { 2235 2297 err1 := tx.Rollback() 2236 2298 err2 := rp.enforcer.E.LoadPolicy() 2237 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2299 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2238 2300 2239 2301 // ignore txn complete errors, this is okay 2240 2302 if errors.Is(err1, sql.ErrTxDone) { ··· 2275 2337 2276 2338 err = db.AddRepo(tx, repo) 2277 2339 if err != nil { 2278 - log.Println(err) 2340 + l.Error("failed to AddRepo", "err", err) 2279 2341 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2280 2342 return 2281 2343 } ··· 2284 2346 p, _ := securejoin.SecureJoin(user.Did, forkName) 2285 2347 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2286 2348 if err != nil { 2287 - log.Println(err) 2349 + l.Error("failed to add ACLs", "err", err) 2288 2350 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2289 2351 return 2290 2352 } 2291 2353 2292 2354 err = tx.Commit() 2293 2355 if err != nil { 2294 - log.Println("failed to commit changes", err) 2356 + l.Error("failed to commit changes", "err", err) 2295 2357 http.Error(w, err.Error(), http.StatusInternalServerError) 2296 2358 return 2297 2359 } 2298 2360 2299 2361 err = rp.enforcer.E.SavePolicy() 2300 2362 if err != nil { 2301 - log.Println("failed to update ACLs", err) 2363 + l.Error("failed to update ACLs", "err", err) 2302 2364 http.Error(w, err.Error(), http.StatusInternalServerError) 2303 2365 return 2304 2366 } ··· 2307 2369 aturi = "" 2308 2370 2309 2371 rp.notifier.NewRepo(r.Context(), repo) 2310 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2372 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2311 2373 } 2312 2374 } 2313 2375 2314 2376 // this is used to rollback changes made to the PDS 2315 2377 // 2316 2378 // it is a no-op if the provided ATURI is empty 2317 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2379 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2318 2380 if aturi == "" { 2319 2381 return nil 2320 2382 } ··· 2325 2387 repo := parsed.Authority().String() 2326 2388 rkey := parsed.RecordKey().String() 2327 2389 2328 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2390 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2329 2391 Collection: collection, 2330 2392 Repo: repo, 2331 2393 Rkey: rkey, ··· 2334 2396 } 2335 2397 2336 2398 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 + l := rp.logger.With("handler", "RepoCompareNew") 2400 + 2337 2401 user := rp.oauth.GetUser(r) 2338 2402 f, err := rp.repoResolver.Resolve(r) 2339 2403 if err != nil { 2340 - log.Println("failed to get repo and knot", err) 2404 + l.Error("failed to get repo and knot", "err", err) 2341 2405 return 2342 2406 } 2343 2407 ··· 2353 2417 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2354 2418 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2355 2419 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2356 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2420 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2357 2421 rp.pages.Error503(w) 2358 2422 return 2359 2423 } 2360 2424 2361 2425 var branchResult types.RepoBranchesResponse 2362 2426 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2363 - log.Println("failed to decode XRPC branches response", err) 2427 + l.Error("failed to decode XRPC branches response", "err", err) 2364 2428 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2365 2429 return 2366 2430 } ··· 2390 2454 2391 2455 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2392 2456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2393 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2457 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2394 2458 rp.pages.Error503(w) 2395 2459 return 2396 2460 } 2397 2461 2398 2462 var tags types.RepoTagsResponse 2399 2463 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2400 - log.Println("failed to decode XRPC tags response", err) 2464 + l.Error("failed to decode XRPC tags response", "err", err) 2401 2465 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2402 2466 return 2403 2467 } ··· 2415 2479 } 2416 2480 2417 2481 func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 + l := rp.logger.With("handler", "RepoCompare") 2483 + 2418 2484 user := rp.oauth.GetUser(r) 2419 2485 f, err := rp.repoResolver.Resolve(r) 2420 2486 if err != nil { 2421 - log.Println("failed to get repo and knot", err) 2487 + l.Error("failed to get repo and knot", "err", err) 2422 2488 return 2423 2489 } 2424 2490 ··· 2445 2511 head, _ = url.PathUnescape(head) 2446 2512 2447 2513 if base == "" || head == "" { 2448 - log.Printf("invalid comparison") 2514 + l.Error("invalid comparison") 2449 2515 rp.pages.Error404(w) 2450 2516 return 2451 2517 } ··· 2463 2529 2464 2530 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2465 2531 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2466 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2532 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2467 2533 rp.pages.Error503(w) 2468 2534 return 2469 2535 } 2470 2536 2471 2537 var branches types.RepoBranchesResponse 2472 2538 if err := json.Unmarshal(branchBytes, &branches); err != nil { 2473 - log.Println("failed to decode XRPC branches response", err) 2539 + l.Error("failed to decode XRPC branches response", "err", err) 2474 2540 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2475 2541 return 2476 2542 } 2477 2543 2478 2544 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2479 2545 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2480 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2546 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2481 2547 rp.pages.Error503(w) 2482 2548 return 2483 2549 } 2484 2550 2485 2551 var tags types.RepoTagsResponse 2486 2552 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2487 - log.Println("failed to decode XRPC tags response", err) 2553 + l.Error("failed to decode XRPC tags response", "err", err) 2488 2554 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2489 2555 return 2490 2556 } 2491 2557 2492 2558 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2493 2559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2494 - log.Println("failed to call XRPC repo.compare", xrpcerr) 2560 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2495 2561 rp.pages.Error503(w) 2496 2562 return 2497 2563 } 2498 2564 2499 2565 var formatPatch types.RepoFormatPatchResponse 2500 2566 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2501 - log.Println("failed to decode XRPC compare response", err) 2567 + l.Error("failed to decode XRPC compare response", "err", err) 2502 2568 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2503 2569 return 2504 2570 } 2505 2571 2506 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 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 + } 2507 2578 2508 2579 repoinfo := f.RepoInfo(user) 2509 2580
+2
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 13 14 r.Get("/feed.atom", rp.RepoAtomFeed) 14 15 r.Get("/commits/{ref}", rp.RepoLog) 15 16 r.Route("/tree/{ref}", func(r chi.Router) { ··· 18 19 }) 19 20 r.Get("/commit/{ref}", rp.RepoCommit) 20 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 21 23 r.Route("/tags", func(r chi.Router) { 22 24 r.Get("/", rp.RepoTags) 23 25 r.Route("/{tag}", func(r chi.Router) {
+54 -2
appview/settings/settings.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 25 26 lexutil "github.com/bluesky-social/indigo/lex/util" 26 27 "github.com/gliderlabs/ssh" 27 28 "github.com/google/uuid" ··· 41 42 {"Name": "profile", "Icon": "user"}, 42 43 {"Name": "keys", "Icon": "key"}, 43 44 {"Name": "emails", "Icon": "mail"}, 45 + {"Name": "notifications", "Icon": "bell"}, 44 46 } 45 47 ) 46 48 ··· 66 68 r.Get("/verify", s.emailsVerify) 67 69 r.Post("/verify/resend", s.emailsVerifyResend) 68 70 r.Post("/primary", s.emailsPrimary) 71 + }) 72 + 73 + r.Route("/notifications", func(r chi.Router) { 74 + r.Get("/", s.notificationsSettings) 75 + r.Put("/", s.updateNotificationPreferences) 69 76 }) 70 77 71 78 return r ··· 79 86 Tabs: settingsTabs, 80 87 Tab: "profile", 81 88 }) 89 + } 90 + 91 + func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 92 + user := s.OAuth.GetUser(r) 93 + did := s.OAuth.GetDid(r) 94 + 95 + prefs, err := db.GetNotificationPreference(s.Db, did) 96 + if err != nil { 97 + log.Printf("failed to get notification preferences: %s", err) 98 + s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 99 + return 100 + } 101 + 102 + s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 103 + LoggedInUser: user, 104 + Preferences: prefs, 105 + Tabs: settingsTabs, 106 + Tab: "notifications", 107 + }) 108 + } 109 + 110 + func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) { 111 + did := s.OAuth.GetDid(r) 112 + 113 + prefs := &models.NotificationPreferences{ 114 + UserDid: syntax.DID(did), 115 + RepoStarred: r.FormValue("repo_starred") == "on", 116 + IssueCreated: r.FormValue("issue_created") == "on", 117 + IssueCommented: r.FormValue("issue_commented") == "on", 118 + IssueClosed: r.FormValue("issue_closed") == "on", 119 + PullCreated: r.FormValue("pull_created") == "on", 120 + PullCommented: r.FormValue("pull_commented") == "on", 121 + PullMerged: r.FormValue("pull_merged") == "on", 122 + Followed: r.FormValue("followed") == "on", 123 + 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.") 82 134 } 83 135 84 136 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 419 471 } 420 472 421 473 // store in pds too 422 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 474 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 423 475 Collection: tangled.PublicKeyNSID, 424 476 Repo: did, 425 477 Rkey: rkey, ··· 476 528 477 529 if rkey != "" { 478 530 // remove from pds too 479 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 531 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 480 532 Collection: tangled.PublicKeyNSID, 481 533 Repo: did, 482 534 Rkey: rkey,
+18
appview/signup/requests.go
··· 102 102 103 103 return result.DID, nil 104 104 } 105 + 106 + func (s *Signup) deleteAccountRequest(did string) error { 107 + body := map[string]string{ 108 + "did": did, 109 + } 110 + 111 + resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true) 112 + if err != nil { 113 + return err 114 + } 115 + defer resp.Body.Close() 116 + 117 + if resp.StatusCode != http.StatusOK { 118 + return s.handlePdsError(resp, "delete account") 119 + } 120 + 121 + return nil 122 + }
+159 -40
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 6 + "encoding/json" 7 + "errors" 5 8 "fmt" 6 9 "log/slog" 7 10 "net/http" 11 + "net/url" 8 12 "os" 9 13 "strings" 10 14 ··· 17 21 "tangled.org/core/appview/models" 18 22 "tangled.org/core/appview/pages" 19 23 "tangled.org/core/appview/state/userutil" 20 - "tangled.org/core/appview/xrpcclient" 21 24 "tangled.org/core/idresolver" 22 25 ) 23 26 ··· 26 29 db *db.DB 27 30 cf *dns.Cloudflare 28 31 posthog posthog.Client 29 - xrpc *xrpcclient.Client 30 32 idResolver *idresolver.Resolver 31 33 pages *pages.Pages 32 34 l *slog.Logger ··· 61 63 disallowed := make(map[string]bool) 62 64 63 65 if filepath == "" { 64 - logger.Debug("no disallowed nicknames file configured") 66 + logger.Warn("no disallowed nicknames file configured") 65 67 return disallowed 66 68 } 67 69 ··· 116 118 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 119 switch r.Method { 118 120 case http.MethodGet: 119 - s.pages.Signup(w) 121 + s.pages.Signup(w, pages.SignupParams{ 122 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 123 + }) 120 124 case http.MethodPost: 121 125 if s.cf == nil { 122 126 http.Error(w, "signup is disabled", http.StatusFailedDependency) 127 + return 123 128 } 124 129 emailId := r.FormValue("email") 130 + cfToken := r.FormValue("cf-turnstile-response") 125 131 126 132 noticeId := "signup-msg" 133 + 134 + if err := s.validateCaptcha(cfToken, r); err != nil { 135 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 136 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 137 + return 138 + } 139 + 127 140 if !email.IsValidEmail(emailId) { 128 141 s.pages.Notice(w, noticeId, "Invalid email address.") 129 142 return ··· 204 217 return 205 218 } 206 219 207 - did, err := s.createAccountRequest(username, password, email, code) 208 - if err != nil { 209 - s.l.Error("failed to create account", "error", err) 210 - s.pages.Notice(w, "signup-error", err.Error()) 211 - return 212 - } 213 - 214 220 if s.cf == nil { 215 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 216 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 217 223 return 218 224 } 219 225 220 - err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 221 - Type: "TXT", 222 - Name: "_atproto." + username, 223 - Content: fmt.Sprintf(`"did=%s"`, did), 224 - TTL: 6400, 225 - Proxied: false, 226 - }) 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 227 228 if err != nil { 228 - s.l.Error("failed to create DNS record", "error", err) 229 - s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + // Error already logged and notice already sent 230 230 return 231 231 } 232 + } 233 + } 232 234 233 - err = db.AddEmail(s.db, models.Email{ 234 - Did: did, 235 - Address: email, 236 - Verified: true, 237 - Primary: true, 238 - }) 239 - if err != nil { 240 - s.l.Error("failed to add email", "error", err) 241 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 242 - return 235 + // executeSignupTransaction performs the signup process transactionally with rollback 236 + func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 237 + var recordID string 238 + var did string 239 + var emailAdded bool 240 + 241 + success := false 242 + defer func() { 243 + if !success { 244 + s.l.Info("rolling back signup transaction", "username", username, "did", did) 245 + 246 + // Rollback DNS record 247 + if recordID != "" { 248 + if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 249 + s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 250 + } else { 251 + s.l.Info("successfully rolled back DNS record", "recordID", recordID) 252 + } 253 + } 254 + 255 + // Rollback PDS account 256 + if did != "" { 257 + if err := s.deleteAccountRequest(did); err != nil { 258 + s.l.Error("failed to rollback PDS account", "error", err, "did", did) 259 + } else { 260 + s.l.Info("successfully rolled back PDS account", "did", did) 261 + } 262 + } 263 + 264 + // Rollback email from database 265 + if emailAdded { 266 + if err := db.DeleteEmail(s.db, did, email); err != nil { 267 + s.l.Error("failed to rollback email from database", "error", err, "email", email) 268 + } else { 269 + s.l.Info("successfully rolled back email from database", "email", email) 270 + } 271 + } 272 + } 273 + }() 274 + 275 + // step 1: create account in PDS 276 + did, err := s.createAccountRequest(username, password, email, code) 277 + if err != nil { 278 + s.l.Error("failed to create account", "error", err) 279 + s.pages.Notice(w, "signup-error", err.Error()) 280 + return err 281 + } 282 + 283 + // step 2: create DNS record with actual DID 284 + recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 + Type: "TXT", 286 + Name: "_atproto." + username, 287 + Content: fmt.Sprintf(`"did=%s"`, did), 288 + TTL: 6400, 289 + Proxied: false, 290 + }) 291 + if err != nil { 292 + s.l.Error("failed to create DNS record", "error", err) 293 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 + return err 295 + } 296 + 297 + // step 3: add email to database 298 + err = db.AddEmail(s.db, models.Email{ 299 + Did: did, 300 + Address: email, 301 + Verified: true, 302 + Primary: true, 303 + }) 304 + if err != nil { 305 + s.l.Error("failed to add email", "error", err) 306 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 307 + return err 308 + } 309 + emailAdded = true 310 + 311 + // if we get here, we've successfully created the account and added the email 312 + success = true 313 + 314 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 315 + <a class="underline text-black dark:text-white" href="/login">login</a> 316 + with <code>%s.tngl.sh</code>.`, username)) 317 + 318 + // clean up inflight signup asynchronously 319 + go func() { 320 + if err := db.DeleteInflightSignup(s.db, email); err != nil { 321 + s.l.Error("failed to delete inflight signup", "error", err) 322 + } 323 + }() 324 + 325 + return nil 326 + } 327 + 328 + type turnstileResponse struct { 329 + Success bool `json:"success"` 330 + ErrorCodes []string `json:"error-codes,omitempty"` 331 + ChallengeTs string `json:"challenge_ts,omitempty"` 332 + Hostname string `json:"hostname,omitempty"` 333 + } 334 + 335 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 336 + if cfToken == "" { 337 + return errors.New("captcha token is empty") 338 + } 339 + 340 + if s.config.Cloudflare.TurnstileSecretKey == "" { 341 + return errors.New("turnstile secret key not configured") 342 + } 343 + 344 + data := url.Values{} 345 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 346 + data.Set("response", cfToken) 347 + 348 + // include the client IP if we have it 349 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 350 + data.Set("remoteip", remoteIP) 351 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 352 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 353 + data.Set("remoteip", strings.TrimSpace(ips[0])) 243 354 } 355 + } else { 356 + data.Set("remoteip", r.RemoteAddr) 357 + } 244 358 245 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 246 - <a class="underline text-black dark:text-white" href="/login">login</a> 247 - with <code>%s.tngl.sh</code>.`, username)) 359 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 360 + if err != nil { 361 + return fmt.Errorf("failed to verify turnstile token: %w", err) 362 + } 363 + defer resp.Body.Close() 248 364 249 - go func() { 250 - err := db.DeleteInflightSignup(s.db, email) 251 - if err != nil { 252 - s.l.Error("failed to delete inflight signup", "error", err) 253 - } 254 - }() 255 - return 365 + var turnstileResp turnstileResponse 366 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 367 + return fmt.Errorf("failed to decode turnstile response: %w", err) 256 368 } 369 + 370 + if !turnstileResp.Success { 371 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 372 + return errors.New("turnstile validation failed") 373 + } 374 + 375 + return nil 257 376 }
+5 -5
appview/spindles/spindles.go
··· 189 189 return 190 190 } 191 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 193 var exCid *string 194 194 if ex != nil { 195 195 exCid = ex.Cid 196 196 } 197 197 198 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.SpindleNSID, 201 201 Repo: user.Did, 202 202 Rkey: instance, ··· 332 332 return 333 333 } 334 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 336 Collection: tangled.SpindleNSID, 337 337 Repo: user.Did, 338 338 Rkey: instance, ··· 542 542 return 543 543 } 544 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 546 Collection: tangled.SpindleMemberNSID, 547 547 Repo: user.Did, 548 548 Rkey: rkey, ··· 683 683 } 684 684 685 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 687 Collection: tangled.SpindleMemberNSID, 688 688 Repo: user.Did, 689 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 43 case http.MethodPost: 44 44 createdAt := time.Now().Format(time.RFC3339) 45 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 47 Collection: tangled.GraphFollowNSID, 48 48 Repo: currentUser.Did, 49 49 Rkey: rkey, ··· 88 88 return 89 89 } 90 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 92 Collection: tangled.GraphFollowNSID, 93 93 Repo: currentUser.Did, 94 94 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 25 ) 26 26 27 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 + logger := log.FromContext(ctx) 29 + logger = log.SubLogger(logger, "knotstream") 30 + 28 31 knots, err := db.GetRegistrations( 29 32 d, 30 33 db.FilterIsNot("registered", "null"), ··· 39 42 srcs[s] = struct{}{} 40 43 } 41 44 42 - logger := log.New("knotstream") 43 45 cache := cache.New(c.Redis.Addr) 44 46 cursorStore := cursor.NewRedisCursorStore(cache) 45 47 ··· 172 174 }) 173 175 } 174 176 175 - return db.InsertRepoLanguages(d, langs) 177 + tx, err := d.Begin() 178 + if err != nil { 179 + return err 180 + } 181 + defer tx.Rollback() 182 + 183 + // update appview's cache 184 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 185 + if err != nil { 186 + fmt.Printf("failed; %s\n", err) 187 + // non-fatal 188 + } 189 + 190 + return tx.Commit() 176 191 } 177 192 178 193 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+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 336 profile.Did = did 337 337 } 338 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 339 340 UserDid: did, 340 341 FollowStatus: followStatus, 341 342 FollowersCount: followStats.Followers, ··· 633 634 vanityStats = append(vanityStats, string(v.Kind)) 634 635 } 635 636 636 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 637 638 var cid *string 638 639 if ex != nil { 639 640 cid = ex.Cid 640 641 } 641 642 642 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 643 644 Collection: tangled.ActorProfileNSID, 644 645 Repo: user.Did, 645 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" ··· 47 47 case http.MethodPost: 48 48 createdAt := time.Now().Format(time.RFC3339) 49 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 52 Repo: currentUser.Did, 53 53 Rkey: rkey, ··· 70 70 return 71 71 } 72 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 76 76 } 77 77 78 78 log.Println("created atproto record: ", resp.Uri) ··· 80 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 81 ThreadAt: subjectUri, 82 82 Kind: reactionKind, 83 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 84 85 IsReacted: true, 85 86 }) 86 87 ··· 92 93 return 93 94 } 94 95 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 97 Collection: tangled.FeedReactionNSID, 97 98 Repo: currentUser.Did, 98 99 Rkey: reaction.Rkey, ··· 109 110 // this is not an issue, the firehose event might have already done this 110 111 } 111 112 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 113 114 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 115 116 return 116 117 } 117 118 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 120 ThreadAt: subjectUri, 120 121 Kind: reactionKind, 121 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 122 124 IsReacted: false, 123 125 }) 124 126
+79 -23
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 11 10 "tangled.org/core/appview/labels" 12 11 "tangled.org/core/appview/middleware" 13 - oauthhandler "tangled.org/core/appview/oauth/handler" 12 + "tangled.org/core/appview/notifications" 14 13 "tangled.org/core/appview/pipelines" 15 14 "tangled.org/core/appview/pulls" 16 15 "tangled.org/core/appview/repo" ··· 35 34 36 35 router.Get("/favicon.svg", s.Favicon) 37 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 38 39 39 40 userRouter := s.UserRouter(&middleware) 40 41 standardRouter := s.StandardRouter(&middleware) ··· 115 116 116 117 r.Get("/", s.HomeOrTimeline) 117 118 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 119 + r.Get("/upgradeBanner", s.UpgradeBanner) 119 120 120 121 // special-case handler for serving tangled.org/core 121 122 r.Get("/core", s.Core()) 122 123 124 + r.Get("/login", s.Login) 125 + r.Post("/login", s.Login) 126 + r.Post("/logout", s.Logout) 127 + 123 128 r.Route("/repo", func(r chi.Router) { 124 129 r.Route("/new", func(r chi.Router) { 125 130 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 128 133 }) 129 134 // r.Post("/import", s.ImportRepo) 130 135 }) 136 + 137 + r.Get("/goodfirstissues", s.GoodFirstIssues) 131 138 132 139 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 133 140 r.Post("/", s.Follow) ··· 156 163 r.Mount("/strings", s.StringsRouter(mw)) 157 164 r.Mount("/knots", s.KnotsRouter()) 158 165 r.Mount("/spindles", s.SpindlesRouter()) 166 + r.Mount("/notifications", s.NotificationsRouter(mw)) 167 + 159 168 r.Mount("/signup", s.SignupRouter()) 160 - r.Mount("/", s.OAuthRouter()) 169 + r.Mount("/", s.oauth.Router()) 161 170 162 171 r.Get("/keys/{user}", s.Keys) 163 172 r.Get("/terms", s.TermsOfService) 164 173 r.Get("/privacy", s.PrivacyPolicy) 174 + r.Get("/brand", s.Brand) 165 175 166 176 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 167 177 s.pages.Error404(w) ··· 175 185 return func(w http.ResponseWriter, r *http.Request) { 176 186 if r.URL.Query().Get("go-get") == "1" { 177 187 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">`)) 188 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 179 189 return 180 190 } 181 191 182 192 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 183 193 } 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 194 } 191 195 192 196 func (s *State) SettingsRouter() http.Handler { ··· 201 205 } 202 206 203 207 func (s *State) SpindlesRouter() http.Handler { 204 - logger := log.New("spindles") 208 + logger := log.SubLogger(s.logger, "spindles") 205 209 206 210 spindles := &spindles.Spindles{ 207 211 Db: s.db, ··· 217 221 } 218 222 219 223 func (s *State) KnotsRouter() http.Handler { 220 - logger := log.New("knots") 224 + logger := log.SubLogger(s.logger, "knots") 221 225 222 226 knots := &knots.Knots{ 223 227 Db: s.db, ··· 234 238 } 235 239 236 240 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 237 - logger := log.New("strings") 241 + logger := log.SubLogger(s.logger, "strings") 238 242 239 243 strs := &avstrings.Strings{ 240 244 Db: s.db, ··· 249 253 } 250 254 251 255 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) 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 + ) 253 267 return issues.Router(mw) 254 268 } 255 269 256 270 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) 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 + ) 258 283 return pulls.Router(mw) 259 284 } 260 285 261 286 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) 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 + ) 264 300 return repo.Router(mw) 265 301 } 266 302 267 303 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) 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 + ) 269 315 return pipes.Router(mw) 270 316 } 271 317 272 318 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 273 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 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 + ) 274 327 return ls.Router(mw) 275 328 } 276 329 277 - func (s *State) SignupRouter() http.Handler { 278 - logger := log.New("signup") 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 + } 279 334 280 - sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 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")) 281 337 return sig.Router() 282 338 }
+3 -1
appview/state/spindlestream.go
··· 22 22 ) 23 23 24 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 + logger := log.FromContext(ctx) 26 + logger = log.SubLogger(logger, "spindlestream") 27 + 25 28 spindles, err := db.GetSpindles( 26 29 d, 27 30 db.FilterIsNot("verified", "null"), ··· 36 39 srcs[src] = struct{}{} 37 40 } 38 41 39 - logger := log.New("spindlestream") 40 42 cache := cache.New(c.Redis.Addr) 41 43 cursorStore := cursor.NewRedisCursorStore(cache) 42 44
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+101 -40
appview/state/state.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "strings" 12 11 "time" 13 12 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview" 22 - "tangled.org/core/appview/cache" 23 - "tangled.org/core/appview/cache/session" 24 15 "tangled.org/core/appview/config" 25 16 "tangled.org/core/appview/db" 26 17 "tangled.org/core/appview/models" 27 18 "tangled.org/core/appview/notify" 19 + dbnotify "tangled.org/core/appview/notify/db" 20 + phnotify "tangled.org/core/appview/notify/posthog" 28 21 "tangled.org/core/appview/oauth" 29 22 "tangled.org/core/appview/pages" 30 - posthogService "tangled.org/core/appview/posthog" 31 23 "tangled.org/core/appview/reporesolver" 32 24 "tangled.org/core/appview/validator" 33 25 xrpcclient "tangled.org/core/appview/xrpcclient" 34 26 "tangled.org/core/eventconsumer" 35 27 "tangled.org/core/idresolver" 36 28 "tangled.org/core/jetstream" 29 + "tangled.org/core/log" 37 30 tlog "tangled.org/core/log" 38 31 "tangled.org/core/rbac" 39 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" 40 41 ) 41 42 42 43 type State struct { ··· 45 46 oauth *oauth.OAuth 46 47 enforcer *rbac.Enforcer 47 48 pages *pages.Pages 48 - sess *session.SessionStore 49 49 idResolver *idresolver.Resolver 50 50 posthog posthog.Client 51 51 jc *jetstream.JetstreamClient ··· 58 58 } 59 59 60 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 61 - d, err := db.Make(config.Core.DbPath) 61 + logger := tlog.FromContext(ctx) 62 + 63 + d, err := db.Make(ctx, config.Core.DbPath) 62 64 if err != nil { 63 65 return nil, fmt.Errorf("failed to create db: %w", err) 64 66 } ··· 70 72 71 73 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 72 74 if err != nil { 73 - log.Printf("failed to create redis resolver: %v", err) 75 + logger.Error("failed to create redis resolver", "err", err) 74 76 res = idresolver.DefaultResolver() 75 77 } 76 78 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 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 80 if err != nil { 85 81 return nil, fmt.Errorf("failed to create posthog client: %w", err) 86 82 } 87 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 + 88 91 repoResolver := reporesolver.New(config, enforcer, res, d) 89 92 90 93 wrapper := db.DbWrapper{Execer: d} ··· 106 109 tangled.LabelOpNSID, 107 110 }, 108 111 nil, 109 - slog.Default(), 112 + tlog.SubLogger(logger, "jetstream"), 110 113 wrapper, 111 114 false, 112 115 ··· 118 121 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 119 122 } 120 123 121 - if err := db.BackfillDefaultDefs(d, res); err != nil { 124 + if err := BackfillDefaultDefs(d, res); err != nil { 122 125 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 123 126 } 124 127 ··· 127 130 Enforcer: enforcer, 128 131 IdResolver: res, 129 132 Config: config, 130 - Logger: tlog.New("ingester"), 133 + Logger: log.SubLogger(logger, "ingester"), 131 134 Validator: validator, 132 135 } 133 136 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 148 151 spindlestream.Start(ctx) 149 152 150 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 151 159 if !config.Core.Dev { 152 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 160 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 153 161 } 154 162 notifier := notify.NewMergedNotifier(notifiers...) 155 163 ··· 158 166 notifier, 159 167 oauth, 160 168 enforcer, 161 - pgs, 162 - sess, 169 + pages, 163 170 res, 164 171 posthog, 165 172 jc, ··· 167 174 repoResolver, 168 175 knotstream, 169 176 spindlestream, 170 - slog.Default(), 177 + logger, 171 178 validator, 172 179 } 173 180 ··· 192 199 s.pages.Favicon(w) 193 200 } 194 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 + 195 235 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 196 236 user := s.oauth.GetUser(r) 197 237 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 206 246 }) 207 247 } 208 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 + 209 256 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 210 257 if s.oauth.GetUser(r) != nil { 211 258 s.Timeline(w, r) ··· 217 264 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 218 265 user := s.oauth.GetUser(r) 219 266 267 + // TODO: set this flag based on the UI 268 + filtered := false 269 + 220 270 var userDid string 221 271 if user != nil { 222 272 userDid = user.Did 223 273 } 224 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 274 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 225 275 if err != nil { 226 - log.Println(err) 276 + s.logger.Error("failed to make timeline", "err", err) 227 277 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 228 278 } 229 279 230 280 repos, err := db.GetTopStarredReposLastWeek(s.db) 231 281 if err != nil { 232 - log.Println(err) 282 + s.logger.Error("failed to get top starred repos", "err", err) 233 283 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 234 284 return 285 + } 286 + 287 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 288 + if err != nil { 289 + // non-fatal 235 290 } 236 291 237 292 s.pages.Timeline(w, pages.TimelineParams{ 238 293 LoggedInUser: user, 239 294 Timeline: timeline, 240 295 Repos: repos, 296 + GfiLabel: gfiLabel, 241 297 }) 242 298 } 243 299 244 300 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 245 301 user := s.oauth.GetUser(r) 302 + if user == nil { 303 + return 304 + } 305 + 246 306 l := s.logger.With("handler", "UpgradeBanner") 247 307 l = l.With("did", user.Did) 248 - l = l.With("handle", user.Handle) 249 308 250 309 regs, err := db.GetRegistrations( 251 310 s.db, ··· 276 335 } 277 336 278 337 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 279 - timeline, err := db.MakeTimeline(s.db, 5, "") 338 + // TODO: set this flag based on the UI 339 + filtered := false 340 + 341 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 280 342 if err != nil { 281 - log.Println(err) 343 + s.logger.Error("failed to make timeline", "err", err) 282 344 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 283 345 return 284 346 } 285 347 286 348 repos, err := db.GetTopStarredReposLastWeek(s.db) 287 349 if err != nil { 288 - log.Println(err) 350 + s.logger.Error("failed to get top starred repos", "err", err) 289 351 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 290 352 return 291 353 } ··· 385 447 386 448 user := s.oauth.GetUser(r) 387 449 l = l.With("did", user.Did) 388 - l = l.With("handle", user.Handle) 389 450 390 451 // form validation 391 452 domain := r.FormValue("domain") ··· 449 510 } 450 511 record := repo.AsRecord() 451 512 452 - xrpcClient, err := s.oauth.AuthorizedClient(r) 513 + atpClient, err := s.oauth.AuthorizedClient(r) 453 514 if err != nil { 454 515 l.Info("PDS write failed", "err", err) 455 516 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 456 517 return 457 518 } 458 519 459 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 520 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 460 521 Collection: tangled.RepoNSID, 461 522 Repo: user.Did, 462 523 Rkey: rkey, ··· 488 549 rollback := func() { 489 550 err1 := tx.Rollback() 490 551 err2 := s.enforcer.E.LoadPolicy() 491 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 552 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 492 553 493 554 // ignore txn complete errors, this is okay 494 555 if errors.Is(err1, sql.ErrTxDone) { ··· 561 622 aturi = "" 562 623 563 624 s.notifier.NewRepo(r.Context(), repo) 564 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 625 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 565 626 } 566 627 } 567 628 568 629 // this is used to rollback changes made to the PDS 569 630 // 570 631 // it is a no-op if the provided ATURI is empty 571 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 632 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 572 633 if aturi == "" { 573 634 return nil 574 635 } ··· 579 640 repo := parsed.Authority().String() 580 641 rkey := parsed.RecordKey().String() 581 642 582 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 643 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 583 644 Collection: collection, 584 645 Repo: repo, 585 646 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 22 "github.com/bluesky-social/indigo/api/atproto" 23 23 "github.com/bluesky-social/indigo/atproto/identity" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 29 ) 28 30 29 31 type Strings struct { ··· 254 256 } 255 257 256 258 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 260 if err != nil { 259 261 fail("Failed to updated existing record.", err) 260 262 return 261 263 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 263 265 Collection: tangled.StringNSID, 264 266 Repo: entry.Did.String(), 265 267 Rkey: entry.Rkey, ··· 284 286 s.Notifier.EditString(r.Context(), &entry) 285 287 286 288 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 288 290 } 289 291 290 292 } ··· 336 338 return 337 339 } 338 340 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 340 342 Collection: tangled.StringNSID, 341 343 Repo: user.Did, 342 344 Rkey: string.Rkey, ··· 360 362 s.Notifier.NewString(r.Context(), &string) 361 363 362 364 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 364 366 } 365 367 } 366 368 ··· 403 405 404 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 407 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 407 409 } 408 410 409 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+15 -1
appview/validator/label.go
··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 102 105 if labelOp == nil { 103 106 return fmt.Errorf("label operation is required") 107 + } 108 + 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 104 118 } 105 119 106 120 expectedKey := labelDef.AtUri().String()
+25
appview/validator/patch.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/patchutil" 8 + ) 9 + 10 + func (v *Validator) ValidatePatch(patch *string) error { 11 + if patch == nil || *patch == "" { 12 + return fmt.Errorf("patch is empty") 13 + } 14 + 15 + // add newline if not present to diff style patches 16 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 + *patch = *patch + "\n" 18 + } 19 + 20 + if err := patchutil.IsPatchValid(*patch); err != nil { 21 + return err 22 + } 23 + 24 + return nil 25 + }
+4 -1
appview/validator/validator.go
··· 4 4 "tangled.org/core/appview/db" 5 5 "tangled.org/core/appview/pages/markup" 6 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 7 8 ) 8 9 9 10 type Validator struct { 10 11 db *db.DB 11 12 sanitizer markup.Sanitizer 12 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 13 15 } 14 16 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 16 18 return &Validator{ 17 19 db: db, 18 20 sanitizer: markup.NewSanitizer(), 19 21 resolver: res, 22 + enforcer: enforcer, 20 23 } 21 24 }
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+14 -9
cmd/appview/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 - "log/slog" 7 5 "net/http" 8 6 "os" 9 7 10 8 "tangled.org/core/appview/config" 11 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 12 11 ) 13 12 14 13 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 18 17 19 18 c, err := config.LoadConfig(ctx) 20 19 if err != nil { 21 - log.Println("failed to load config", "error", err) 20 + logger.Error("failed to load config", "error", err) 22 21 return 23 22 } 24 23 25 24 state, err := state.Make(ctx, c) 26 25 defer func() { 27 - log.Println(state.Close()) 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 28 29 }() 29 30 30 31 if err != nil { 31 - log.Fatal(err) 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 32 34 } 33 35 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 36 41 }
+62
cmd/cborgen/cborgen.go
··· 1 + package main 2 + 3 + import ( 4 + cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.org/core/api/tangled" 6 + ) 7 + 8 + func main() { 9 + 10 + genCfg := cbg.Gen{ 11 + MaxStringLength: 1_000_000, 12 + } 13 + 14 + if err := genCfg.WriteMapEncodersToFile( 15 + "api/tangled/cbor_gen.go", 16 + "tangled", 17 + tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 19 + tangled.FeedStar{}, 20 + tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 + tangled.GitRefUpdate_Meta{}, 26 + tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 + tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 33 + tangled.Pipeline{}, 34 + tangled.Pipeline_CloneOpts{}, 35 + tangled.Pipeline_ManualTriggerData{}, 36 + tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_PullRequestTriggerData{}, 38 + tangled.Pipeline_PushTriggerData{}, 39 + tangled.PipelineStatus{}, 40 + tangled.Pipeline_TriggerMetadata{}, 41 + tangled.Pipeline_TriggerRepo{}, 42 + tangled.Pipeline_Workflow{}, 43 + tangled.PublicKey{}, 44 + tangled.Repo{}, 45 + tangled.RepoArtifact{}, 46 + tangled.RepoCollaborator{}, 47 + tangled.RepoIssue{}, 48 + tangled.RepoIssueComment{}, 49 + tangled.RepoIssueState{}, 50 + tangled.RepoPull{}, 51 + tangled.RepoPullComment{}, 52 + tangled.RepoPull_Source{}, 53 + tangled.RepoPullStatus{}, 54 + tangled.RepoPull_Target{}, 55 + tangled.Spindle{}, 56 + tangled.SpindleMember{}, 57 + tangled.String{}, 58 + ); err != nil { 59 + panic(err) 60 + } 61 + 62 + }
-62
cmd/gen.go
··· 1 - package main 2 - 3 - import ( 4 - cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.org/core/api/tangled" 6 - ) 7 - 8 - func main() { 9 - 10 - genCfg := cbg.Gen{ 11 - MaxStringLength: 1_000_000, 12 - } 13 - 14 - if err := genCfg.WriteMapEncodersToFile( 15 - "api/tangled/cbor_gen.go", 16 - "tangled", 17 - tangled.ActorProfile{}, 18 - tangled.FeedReaction{}, 19 - tangled.FeedStar{}, 20 - tangled.GitRefUpdate{}, 21 - tangled.GitRefUpdate_CommitCountBreakdown{}, 22 - tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_IndividualLanguageSize{}, 24 - tangled.GitRefUpdate_LangBreakdown{}, 25 - tangled.GitRefUpdate_Meta{}, 26 - tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 - tangled.KnotMember{}, 29 - tangled.LabelDefinition{}, 30 - tangled.LabelDefinition_ValueType{}, 31 - tangled.LabelOp{}, 32 - tangled.LabelOp_Operand{}, 33 - tangled.Pipeline{}, 34 - tangled.Pipeline_CloneOpts{}, 35 - tangled.Pipeline_ManualTriggerData{}, 36 - tangled.Pipeline_Pair{}, 37 - tangled.Pipeline_PullRequestTriggerData{}, 38 - tangled.Pipeline_PushTriggerData{}, 39 - tangled.PipelineStatus{}, 40 - tangled.Pipeline_TriggerMetadata{}, 41 - tangled.Pipeline_TriggerRepo{}, 42 - tangled.Pipeline_Workflow{}, 43 - tangled.PublicKey{}, 44 - tangled.Repo{}, 45 - tangled.RepoArtifact{}, 46 - tangled.RepoCollaborator{}, 47 - tangled.RepoIssue{}, 48 - tangled.RepoIssueComment{}, 49 - tangled.RepoIssueState{}, 50 - tangled.RepoPull{}, 51 - tangled.RepoPullComment{}, 52 - tangled.RepoPull_Source{}, 53 - tangled.RepoPullStatus{}, 54 - tangled.RepoPull_Target{}, 55 - tangled.Spindle{}, 56 - tangled.SpindleMember{}, 57 - tangled.String{}, 58 - ); err != nil { 59 - panic(err) 60 - } 61 - 62 - }
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+6 -3
cmd/knot/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 8 "github.com/urfave/cli/v3" ··· 9 10 "tangled.org/core/hook" 10 11 "tangled.org/core/keyfetch" 11 12 "tangled.org/core/knotserver" 12 - "tangled.org/core/log" 13 + tlog "tangled.org/core/log" 13 14 ) 14 15 15 16 func main() { ··· 24 25 }, 25 26 } 26 27 28 + logger := tlog.New("knot") 29 + slog.SetDefault(logger) 30 + 27 31 ctx := context.Background() 28 - logger := log.New("knot") 29 - ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 32 + ctx = tlog.IntoContext(ctx, logger) 30 33 31 34 if err := cmd.Run(ctx, os.Args); err != nil { 32 35 logger.Error(err.Error())
-49
cmd/punchcardPopulate/main.go
··· 1 - package main 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "log" 7 - "math/rand" 8 - "time" 9 - 10 - _ "github.com/mattn/go-sqlite3" 11 - ) 12 - 13 - func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 - if err != nil { 16 - log.Fatal("Failed to open database:", err) 17 - } 18 - defer db.Close() 19 - 20 - const did = "did:plc:qfpnj4og54vl56wngdriaxug" 21 - 22 - now := time.Now() 23 - start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 24 - 25 - tx, err := db.Begin() 26 - if err != nil { 27 - log.Fatal(err) 28 - } 29 - stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)") 30 - if err != nil { 31 - log.Fatal(err) 32 - } 33 - defer stmt.Close() 34 - 35 - for day := start; !day.After(now); day = day.AddDate(0, 0, 1) { 36 - count := rand.Intn(16) // 0–5 37 - dateStr := day.Format("2006-01-02") 38 - _, err := stmt.Exec(did, dateStr, count) 39 - if err != nil { 40 - log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 - } 42 - } 43 - 44 - if err := tx.Commit(); err != nil { 45 - log.Fatal("Failed to commit:", err) 46 - } 47 - 48 - fmt.Println("Done populating punchcard.") 49 - }
+9 -4
cmd/spindle/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 - "tangled.org/core/log" 8 + tlog "tangled.org/core/log" 8 9 "tangled.org/core/spindle" 9 - _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() { 13 - ctx := log.NewContext(context.Background(), "spindle") 13 + logger := tlog.New("spindle") 14 + slog.SetDefault(logger) 15 + 16 + ctx := context.Background() 17 + ctx = tlog.IntoContext(ctx, logger) 18 + 14 19 err := spindle.Run(ctx) 15 20 if err != nil { 16 - log.FromContext(ctx).Error("error running spindle", "error", err) 21 + logger.Error("error running spindle", "error", err) 17 22 os.Exit(-1) 18 23 } 19 24 }
+2 -1
docs/knot-hosting.md
··· 39 39 ``` 40 40 41 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/knot` is a good choice: 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 43 44 44 ``` 45 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 46 47 ``` 47 48 48 49 This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 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 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: 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 25 26 26 ```yaml 27 27 when:
+1 -1
flake.nix
··· 262 262 lexgen --build-file lexicon-build-config.json lexicons 263 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 265 + go run ./cmd/cborgen/ 266 266 lexgen --build-file lexicon-build-config.json lexicons 267 267 rm api/tangled/*.bak 268 268 '';
+23 -6
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 21 github.com/go-chi/chi/v5 v5.2.0 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 24 25 github.com/google/uuid v1.6.0 25 26 github.com/gorilla/feeds v1.2.0 26 27 github.com/gorilla/sessions v1.4.0 ··· 36 37 github.com/redis/go-redis/v9 v9.7.3 37 38 github.com/resend/resend-go/v2 v2.15.0 38 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 39 42 github.com/stretchr/testify v1.10.0 40 43 github.com/urfave/cli/v3 v3.3.3 41 44 github.com/whyrusleeping/cbor-gen v0.3.1 42 45 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 46 + github.com/yuin/goldmark v1.7.13 44 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 48 golang.org/x/crypto v0.40.0 49 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 46 51 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 52 + golang.org/x/sync v0.17.0 48 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 54 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 55 ) 52 56 53 57 require ( ··· 56 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 57 61 github.com/alecthomas/repr v0.4.0 // indirect 58 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 59 64 github.com/aymerick/douceur v0.2.0 // indirect 60 65 github.com/beorn7/perks v1.0.1 // indirect 61 66 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 62 67 github.com/casbin/govaluate v1.3.0 // indirect 63 68 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 64 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 65 76 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 66 77 github.com/containerd/errdefs v1.0.0 // indirect 67 78 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 80 91 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 81 92 github.com/go-git/go-billy/v5 v5.6.2 // indirect 82 93 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 94 + github.com/go-logfmt/logfmt v0.6.0 // indirect 83 95 github.com/go-logr/logr v1.4.3 // indirect 84 96 github.com/go-logr/stdr v1.2.2 // indirect 85 97 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 122 134 github.com/lestrrat-go/httprc v1.0.6 // indirect 123 135 github.com/lestrrat-go/iter v1.0.2 // indirect 124 136 github.com/lestrrat-go/option v1.0.1 // indirect 137 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 125 138 github.com/mattn/go-isatty v0.0.20 // indirect 139 + github.com/mattn/go-runewidth v0.0.16 // indirect 126 140 github.com/minio/sha256-simd v1.0.1 // indirect 127 141 github.com/mitchellh/mapstructure v1.5.0 // indirect 128 142 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 130 144 github.com/moby/term v0.5.2 // indirect 131 145 github.com/morikuni/aec v1.0.0 // indirect 132 146 github.com/mr-tron/base58 v1.2.0 // indirect 147 + github.com/muesli/termenv v0.16.0 // indirect 133 148 github.com/multiformats/go-base32 v0.1.0 // indirect 134 149 github.com/multiformats/go-base36 v0.2.0 // indirect 135 150 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 148 163 github.com/prometheus/client_model v0.6.2 // indirect 149 164 github.com/prometheus/common v0.64.0 // indirect 150 165 github.com/prometheus/procfs v0.16.1 // indirect 166 + github.com/rivo/uniseg v0.4.7 // indirect 151 167 github.com/ryanuber/go-glob v1.0.0 // indirect 152 168 github.com/segmentio/asm v1.2.0 // indirect 153 169 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 156 172 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 173 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 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 159 177 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 178 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 179 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 168 186 go.uber.org/atomic v1.11.0 // indirect 169 187 go.uber.org/multierr v1.11.0 // indirect 170 188 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 189 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 190 + golang.org/x/text v0.29.0 // indirect 174 191 golang.org/x/time v0.12.0 // indirect 175 192 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 193 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+45 -12
go.sum
··· 19 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 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= 22 24 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 25 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 26 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 27 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/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 48 50 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 51 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 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= 51 65 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 66 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 67 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 120 134 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 135 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 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= 123 139 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 140 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 141 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 136 152 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 153 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 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= 139 157 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 158 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 159 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 243 261 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 262 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 263 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 264 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 265 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 266 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 276 292 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 293 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 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= 279 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 298 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 299 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 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= 283 303 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 304 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 305 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 300 320 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 321 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 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= 303 325 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 326 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 327 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 399 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 400 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 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= 380 405 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 406 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 407 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 399 424 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 425 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 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= 402 431 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 432 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 433 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 430 459 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 460 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 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= 433 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 465 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 466 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 467 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 468 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 469 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= 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= 441 472 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 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= 443 476 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 477 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 478 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 489 522 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 523 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 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= 492 527 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 528 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 529 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 563 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 564 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 565 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= 566 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 567 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 533 568 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 569 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 570 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 618 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 619 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 620 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= 621 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 622 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 588 623 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 624 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 625 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 652 687 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 688 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 689 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 690 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 691 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71 -12
input.css
··· 134 134 } 135 135 136 136 .prose hr { 137 - @apply my-2; 137 + @apply my-2; 138 138 } 139 139 140 140 .prose li:has(input) { 141 - @apply list-none; 141 + @apply list-none; 142 142 } 143 143 144 144 .prose ul:has(input) { 145 - @apply pl-2; 145 + @apply pl-2; 146 146 } 147 147 148 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 149 + @apply no-underline mx-2 opacity-0; 150 150 } 151 151 152 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 153 + @apply opacity-70; 154 154 } 155 155 156 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 157 + @apply opacity-70; 158 158 } 159 159 160 160 .prose a.footnote-backref { 161 - @apply no-underline; 161 + @apply no-underline; 162 162 } 163 163 164 164 .prose li { 165 - @apply my-0 py-0; 165 + @apply my-0 py-0; 166 166 } 167 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 170 171 } 171 172 172 173 .prose img { ··· 176 177 } 177 178 178 179 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 + @apply inline-block my-0 mb-1 mx-1; 180 181 } 181 182 182 183 .prose input[type="checkbox"] { 183 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 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 + } 185 244 } 186 245 @layer utilities { 187 246 .error { ··· 228 287 } 229 288 /* LineHighlight */ 230 289 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 291 } 233 292 234 293 /* LineNumbersTable */
+1 -1
jetstream/jetstream.go
··· 114 114 115 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 116 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 118 if err != nil { 119 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 120 }
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
+2 -3
knotserver/events.go
··· 8 8 "time" 9 9 10 10 "github.com/gorilla/websocket" 11 + "tangled.org/core/log" 11 12 ) 12 13 13 14 var upgrader = websocket.Upgrader{ ··· 16 17 } 17 18 18 19 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 - l := h.l.With("handler", "OpLog") 20 + l := log.SubLogger(h.l, "eventstream") 20 21 l.Debug("received new connection") 21 22 22 23 conn, err := upgrader.Upgrade(w, r, nil) ··· 75 76 } 76 77 case <-time.After(30 * time.Second): 77 78 // send a keep-alive 78 - l.Debug("sent keepalive") 79 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 80 l.Error("failed to write control", "err", err) 81 81 } ··· 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 90 return err 91 91 } 92 - h.l.Debug("ops", "ops", events) 93 92 94 93 for _, event := range events { 95 94 // first extract the inner json into a map
+5
knotserver/git/branch.go
··· 110 110 slices.Reverse(branches) 111 111 return branches, nil 112 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+11 -103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 29 30 - type TagList struct { 31 - refs []*TagReference 32 - r *git.Repository 33 - } 34 - 35 - // TagReference is used to list both tag and non-annotated tags. 36 - // Non-annotated tags should only contains a reference. 37 - // Annotated tags should contain its reference and its tag information. 38 - type TagReference struct { 39 - ref *plumbing.Reference 40 - tag *object.Tag 41 - } 42 - 43 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 50 37 isDir bool 51 38 } 52 39 53 - func (self *TagList) Len() int { 54 - return len(self.refs) 55 - } 56 - 57 - func (self *TagList) Swap(i, j int) { 58 - self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 59 - } 60 - 61 - // sorting tags in reverse chronological order 62 - func (self *TagList) Less(i, j int) bool { 63 - var dateI time.Time 64 - var dateJ time.Time 65 - 66 - if self.refs[i].tag != nil { 67 - dateI = self.refs[i].tag.Tagger.When 68 - } else { 69 - c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 70 - if err != nil { 71 - dateI = time.Now() 72 - } else { 73 - dateI = c.Committer.When 74 - } 75 - } 76 - 77 - if self.refs[j].tag != nil { 78 - dateJ = self.refs[j].tag.Tagger.When 79 - } else { 80 - c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 81 - if err != nil { 82 - dateJ = time.Now() 83 - } else { 84 - dateJ = c.Committer.When 85 - } 86 - } 87 - 88 - return dateI.After(dateJ) 89 - } 90 - 91 40 func Open(path string, ref string) (*GitRepo, error) { 92 41 var err error 93 42 g := GitRepo{path: path} ··· 122 71 return &g, nil 123 72 } 124 73 74 + // re-open a repository and update references 75 + func (g *GitRepo) Refresh() error { 76 + refreshed, err := PlainOpen(g.path) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + *g = *refreshed 82 + return nil 83 + } 84 + 125 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 126 86 commits := []*object.Commit{} 127 87 ··· 171 131 return g.r.CommitObject(h) 172 132 } 173 133 174 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 175 - c, err := g.r.CommitObject(g.h) 176 - if err != nil { 177 - return nil, fmt.Errorf("last commit: %w", err) 178 - } 179 - return c, nil 180 - } 181 - 182 134 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 135 c, err := g.r.CommitObject(g.h) 184 136 if err != nil { ··· 211 163 } 212 164 213 165 return buf.Bytes(), nil 214 - } 215 - 216 - func (g *GitRepo) FileContent(path string) (string, error) { 217 - c, err := g.r.CommitObject(g.h) 218 - if err != nil { 219 - return "", fmt.Errorf("commit object: %w", err) 220 - } 221 - 222 - tree, err := c.Tree() 223 - if err != nil { 224 - return "", fmt.Errorf("file tree: %w", err) 225 - } 226 - 227 - file, err := tree.File(path) 228 - if err != nil { 229 - return "", err 230 - } 231 - 232 - isbin, _ := file.IsBinary() 233 - 234 - if !isbin { 235 - return file.Contents() 236 - } else { 237 - return "", ErrBinaryFile 238 - } 239 166 } 240 167 241 168 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 337 func (i *infoWrapper) Sys() any { 411 338 return nil 412 339 } 413 - 414 - func (t *TagReference) Name() string { 415 - return t.ref.Name().Short() 416 - } 417 - 418 - func (t *TagReference) Message() string { 419 - if t.tag != nil { 420 - return t.tag.Message 421 - } 422 - return "" 423 - } 424 - 425 - func (t *TagReference) TagObject() *object.Tag { 426 - return t.tag 427 - } 428 - 429 - func (t *TagReference) Hash() plumbing.Hash { 430 - return t.ref.Hash() 431 - }
+21 -2
knotserver/git/last_commit.go
··· 30 30 commitCache = cache 31 31 } 32 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 33 + // processReader wraps a reader and ensures the associated process is cleaned up 34 + type processReader struct { 35 + io.Reader 36 + cmd *exec.Cmd 37 + stdout io.ReadCloser 38 + } 39 + 40 + func (pr *processReader) Close() error { 41 + if err := pr.stdout.Close(); err != nil { 42 + return err 43 + } 44 + return pr.cmd.Wait() 45 + } 46 + 47 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 34 48 args := []string{} 35 49 args = append(args, "log") 36 50 args = append(args, g.h.String()) ··· 48 62 return nil, err 49 63 } 50 64 51 - return stdout, nil 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 52 70 } 53 71 54 72 type commit struct { ··· 104 122 if err != nil { 105 123 return nil, err 106 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 107 126 108 127 reader := bufio.NewReader(output) 109 128 var current commit
+150 -37
knotserver/git/merge.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "fmt" 7 + "log" 7 8 "os" 8 9 "os/exec" 9 10 "regexp" ··· 12 13 "github.com/dgraph-io/ristretto" 13 14 "github.com/go-git/go-git/v5" 14 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 15 18 ) 16 19 17 20 type MergeCheckCache struct { ··· 32 35 mergeCheckCache = MergeCheckCache{cache} 33 36 } 34 37 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 36 39 sep := byte(':') 37 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 41 return fmt.Sprintf("%x", hash) ··· 49 52 } 50 53 } 51 54 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 53 56 key := m.cacheKey(g, patch, targetBranch) 54 57 val := m.cacheVal(mergeCheck) 55 58 m.cache.Set(key, val, 0) 56 59 } 57 60 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 59 62 key := m.cacheKey(g, patch, targetBranch) 60 63 if val, ok := m.cache.Get(key); ok { 61 64 if val == struct{}{} { ··· 104 107 return fmt.Sprintf("merge failed: %s", e.Message) 105 108 } 106 109 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 108 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 112 if err != nil { 110 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 114 } 112 115 113 - if _, err := tmpFile.Write(patchData); err != nil { 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 114 117 tmpFile.Close() 115 118 os.Remove(tmpFile.Name()) 116 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 165 return nil 163 166 } 164 167 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 166 169 var stderr bytes.Buffer 167 170 var cmd *exec.Cmd 168 171 169 172 // configure default git user before merge 170 - exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 - exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 173 176 174 177 // if patch is a format-patch, apply using 'git am' 175 178 if opts.FormatPatch { 176 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 - } else { 178 - // else, apply using 'git apply' and commit it manually 179 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 - applyCmd.Stderr = &stderr 181 - if err := applyCmd.Run(); err != nil { 182 - return fmt.Errorf("patch application failed: %s", stderr.String()) 183 - } 179 + return g.applyMailbox(patchData) 180 + } 184 181 185 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 - if err := stageCmd.Run(); err != nil { 187 - return fmt.Errorf("failed to stage changes: %w", err) 188 - } 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 189 188 190 - commitArgs := []string{"-C", tmpDir, "commit"} 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 191 193 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 194 + commitArgs := []string{"-C", g.path, "commit"} 195 195 196 - if authorName != "" && authorEmail != "" { 197 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 - } 199 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 200 199 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 202 204 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 206 207 - cmd = exec.Command("git", commitArgs...) 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 208 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 209 212 210 213 cmd.Stderr = &stderr 211 214 ··· 216 219 return nil 217 220 } 218 221 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 227 + 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 235 + } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 + } 239 + 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 + cmd.Stderr = &stderr 252 + 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 259 + if err := cmd.Run(); err != nil { 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 + } 321 + } 322 + 323 + // new hash of commit 324 + return newHash, nil 325 + } 326 + 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 220 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 329 return val 222 330 } ··· 244 352 return result 245 353 } 246 354 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 248 356 patchFile, err := g.createTempFileWithPatch(patchData) 249 357 if err != nil { 250 358 return &ErrMerge{ ··· 263 371 } 264 372 defer os.RemoveAll(tmpDir) 265 373 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 267 380 return err 268 381 } 269 382
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
+18 -18
knotserver/git.go
··· 13 13 "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) 20 20 if err != nil { 21 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 23 return 24 24 } 25 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 27 if err != nil { 28 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 30 return 31 31 } 32 32 ··· 46 46 47 47 if err := cmd.InfoRefs(); err != nil { 48 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 50 return 51 51 } 52 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 53 + h.RejectPush(w, r, name) 54 54 default: 55 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 56 } 57 57 } 58 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 63 if err != nil { 64 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 66 return 67 67 } 68 68 ··· 77 77 gzipReader, err := gzip.NewReader(r.Body) 78 78 if err != nil { 79 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 81 return 82 82 } 83 83 defer gzipReader.Close() ··· 88 88 w.Header().Set("Connection", "Keep-Alive") 89 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 90 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 91 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 92 93 93 cmd := service.ServiceCommand{ 94 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 100 w.WriteHeader(http.StatusOK) 101 101 102 102 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 104 return 105 105 } 106 106 } 107 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 112 if err != nil { 113 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 115 return 116 116 } 117 117 118 - d.RejectPush(w, r, name) 118 + h.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 134 + hostname := h.c.Server.Hostname 135 135 if strings.Contains(hostname, ":") { 136 136 hostname = strings.Split(hostname, ":")[0] 137 137 }
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+50 -1
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 16 17 "tangled.org/core/api/tangled" 17 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 18 20 "tangled.org/core/knotserver/config" 19 21 "tangled.org/core/knotserver/db" 20 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 21 24 "tangled.org/core/notifier" 22 25 "tangled.org/core/rbac" 23 26 "tangled.org/core/workflow" ··· 118 121 // non-fatal 119 122 } 120 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 + 121 136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 137 if err != nil { 123 138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 128 143 writeJSON(w, resp) 129 144 } 130 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 + 131 178 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 132 179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 133 180 if err != nil { ··· 268 315 return h.db.InsertEvent(event, h.n) 269 316 } 270 317 271 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 318 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 272 319 r := chi.NewRouter() 320 + l := log.FromContext(ctx) 321 + l = log.SubLogger(l, "internal") 273 322 274 323 h := InternalHandle{ 275 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 12 "tangled.org/core/knotserver/config" 13 13 "tangled.org/core/knotserver/db" 14 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 15 + "tangled.org/core/log" 16 16 "tangled.org/core/notifier" 17 17 "tangled.org/core/rbac" 18 18 "tangled.org/core/xrpc/serviceauth" ··· 28 28 resolver *idresolver.Resolver 29 29 } 30 30 31 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 - r := chi.NewRouter() 33 - 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 34 32 h := Knot{ 35 33 c: c, 36 34 db: db, 37 35 e: e, 38 - l: l, 36 + l: log.FromContext(ctx), 39 37 jc: jc, 40 38 n: n, 41 39 resolver: idresolver.DefaultResolver(), ··· 67 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 66 } 69 67 68 + return h.Router(), nil 69 + } 70 + 71 + func (h *Knot) Router() http.Handler { 72 + r := chi.NewRouter() 73 + 74 + r.Use(h.RequestLogger) 75 + 70 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 77 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 78 }) ··· 86 92 // Socket that streams git oplogs 87 93 r.Get("/events", h.Events) 88 94 89 - return r, nil 95 + return r 90 96 } 91 97 92 98 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 99 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 94 100 95 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 101 + l := log.SubLogger(h.l, "xrpc") 96 102 97 103 xrpc := &xrpc.Xrpc{ 98 104 Config: h.c, 99 105 Db: h.db, 100 106 Ingester: h.jc, 101 107 Enforcer: h.e, 102 - Logger: logger, 108 + Logger: l, 103 109 Notifier: h.n, 104 110 Resolver: h.resolver, 105 111 ServiceAuth: serviceAuth, 106 112 } 113 + 107 114 return xrpc.Router() 108 115 } 109 116
+5 -4
knotserver/server.go
··· 43 43 44 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 47 48 48 49 c, err := config.Load(ctx) 49 50 if err != nil { ··· 80 81 tangled.KnotMemberNSID, 81 82 tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil { 85 86 logger.Error("failed to setup jetstream", "error", err) 86 87 } 87 88 88 89 notifier := notifier.New() 89 90 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 91 92 if err != nil { 92 93 return fmt.Errorf("failed to setup server: %w", err) 93 94 } 94 95 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 + imux := Internal(ctx, c, db, e, &notifier) 96 97 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 99 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+87
knotserver/xrpc/delete_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDeleteBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(xrpcerr.GenericError(err)) 76 + return 77 + } 78 + 79 + err = gr.DeleteBranch(data.Branch) 80 + if err != nil { 81 + l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
+1 -1
knotserver/xrpc/merge.go
··· 85 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 87 88 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 89 if err != nil { 90 90 var mergeErr *git.ErrMerge 91 91 if errors.As(err, &mergeErr) {
+3 -1
knotserver/xrpc/merge_check.go
··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 55 56 56 response := tangled.RepoMergeCheck_Output{ 57 57 Is_conflicted: false, ··· 80 80 response.Error = &errMsg 81 81 } 82 82 } 83 + 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 83 85 84 86 w.Header().Set("Content-Type", "application/json") 85 87 w.WriteHeader(http.StatusOK)
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 8 "tangled.org/core/knotserver/git" 8 9 "tangled.org/core/types" 9 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 72 return 72 73 } 73 74 75 + var combinedPatch []*gitdiff.File 76 + var combinedPatchRaw string 77 + // we need the combined patch 78 + if len(formatPatch) >= 2 { 79 + diffTree, err := gr.DiffTree(commit1, commit2) 80 + if err != nil { 81 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 + } else { 83 + combinedPatch = diffTree.Diff 84 + combinedPatchRaw = diffTree.Patch 85 + } 86 + } 87 + 74 88 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 79 95 } 80 96 81 97 writeJson(w, response)
+24
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 9 11 "tangled.org/core/knotserver/git" 10 12 xrpcerr "tangled.org/core/xrpc/errors" 11 13 ) ··· 43 45 return 44 46 } 45 47 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 46 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 68 for i, file := range files { ··· 83 103 Parent: parentPtr, 84 104 Dotdot: dotdotPtr, 85 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 86 110 } 87 111 88 112 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 38 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 39 40 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 41 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
-158
legal/privacy.md
··· 1 - # Privacy Policy 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - This Privacy Policy describes how Tangled ("we," "us," or "our") 6 - collects, uses, and shares your personal information when you use our 7 - platform and services (the "Service"). 8 - 9 - ## 1. Information We Collect 10 - 11 - ### Account Information 12 - 13 - When you create an account, we collect: 14 - 15 - - Your chosen username 16 - - Email address 17 - - Profile information you choose to provide 18 - - Authentication data 19 - 20 - ### Content and Activity 21 - 22 - We store: 23 - 24 - - Code repositories and associated metadata 25 - - Issues, pull requests, and comments 26 - - Activity logs and usage patterns 27 - - Public keys for authentication 28 - 29 - ## 2. Data Location and Hosting 30 - 31 - ### EU Data Hosting 32 - 33 - **All Tangled service data is hosted within the European Union.** 34 - Specifically: 35 - 36 - - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 - (*.tngl.sh) are located in Finland 38 - - **Application Data:** All other service data is stored on EU-based 39 - servers 40 - - **Data Processing:** All data processing occurs within EU 41 - jurisdiction 42 - 43 - ### External PDS Notice 44 - 45 - **Important:** If your account is hosted on Bluesky's PDS or other 46 - self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 - that data. The data protection, storage location, and privacy 48 - practices for such accounts are governed by the respective PDS 49 - provider's policies, not this Privacy Policy. We only control data 50 - processing within our own services and infrastructure. 51 - 52 - ## 3. Third-Party Data Processors 53 - 54 - We only share your data with the following third-party processors: 55 - 56 - ### Resend (Email Services) 57 - 58 - - **Purpose:** Sending transactional emails (account verification, 59 - notifications) 60 - - **Data Shared:** Email address and necessary message content 61 - 62 - ### Cloudflare (Image Caching) 63 - 64 - - **Purpose:** Caching and optimizing image delivery 65 - - **Data Shared:** Public images and associated metadata for caching 66 - purposes 67 - 68 - ### Posthog (Usage Metrics Tracking) 69 - 70 - - **Purpose:** Tracking usage and platform metrics 71 - - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 - information 73 - 74 - ## 4. How We Use Your Information 75 - 76 - We use your information to: 77 - 78 - - Provide and maintain the Service 79 - - Process your transactions and requests 80 - - Send you technical notices and support messages 81 - - Improve and develop new features 82 - - Ensure security and prevent fraud 83 - - Comply with legal obligations 84 - 85 - ## 5. Data Sharing and Disclosure 86 - 87 - We do not sell, trade, or rent your personal information. We may share 88 - your information only in the following circumstances: 89 - 90 - - With the third-party processors listed above 91 - - When required by law or legal process 92 - - To protect our rights, property, or safety, or that of our users 93 - - In connection with a merger, acquisition, or sale of assets (with 94 - appropriate protections) 95 - 96 - ## 6. Data Security 97 - 98 - We implement appropriate technical and organizational measures to 99 - protect your personal information against unauthorized access, 100 - alteration, disclosure, or destruction. However, no method of 101 - transmission over the Internet is 100% secure. 102 - 103 - ## 7. Data Retention 104 - 105 - We retain your personal information for as long as necessary to provide 106 - the Service and fulfill the purposes outlined in this Privacy Policy, 107 - unless a longer retention period is required by law. 108 - 109 - ## 8. Your Rights 110 - 111 - Under applicable data protection laws, you have the right to: 112 - 113 - - Access your personal information 114 - - Correct inaccurate information 115 - - Request deletion of your information 116 - - Object to processing of your information 117 - - Data portability 118 - - Withdraw consent (where applicable) 119 - 120 - ## 9. Cookies and Tracking 121 - 122 - We use cookies and similar technologies to: 123 - 124 - - Maintain your login session 125 - - Remember your preferences 126 - - Analyze usage patterns to improve the Service 127 - 128 - You can control cookie settings through your browser preferences. 129 - 130 - ## 10. Children's Privacy 131 - 132 - The Service is not intended for children under 16 years of age. We do 133 - not knowingly collect personal information from children under 16. If 134 - we become aware that we have collected such information, we will take 135 - steps to delete it. 136 - 137 - ## 11. International Data Transfers 138 - 139 - While all our primary data processing occurs within the EU, some of our 140 - third-party processors may process data outside the EU. When this 141 - occurs, we ensure appropriate safeguards are in place, such as Standard 142 - Contractual Clauses or adequacy decisions. 143 - 144 - ## 12. Changes to This Privacy Policy 145 - 146 - We may update this Privacy Policy from time to time. We will notify you 147 - of any changes by posting the new Privacy Policy on this page and 148 - updating the "Last updated" date. 149 - 150 - ## 13. Contact Information 151 - 152 - If you have any questions about this Privacy Policy or wish to exercise 153 - your rights, please contact us through our platform or via email. 154 - 155 - --- 156 - 157 - This Privacy Policy complies with the EU General Data Protection 158 - Regulation (GDPR) and other applicable data protection laws.
-109
legal/terms.md
··· 1 - # Terms of Service 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 - to and use of the Tangled platform and services (the "Service") 7 - operated by us ("Tangled," "we," "us," or "our"). 8 - 9 - ## 1. Acceptance of Terms 10 - 11 - By accessing or using our Service, you agree to be bound by these Terms. 12 - If you disagree with any part of these terms, then you may not access 13 - the Service. 14 - 15 - ## 2. Account Registration 16 - 17 - To use certain features of the Service, you must register for an 18 - account. You agree to provide accurate, current, and complete 19 - information during the registration process and to update such 20 - information to keep it accurate, current, and complete. 21 - 22 - ## 3. Account Termination 23 - 24 - > **Important Notice** 25 - > 26 - > **We reserve the right to terminate, suspend, or restrict access to 27 - > your account at any time, for any reason, or for no reason at all, at 28 - > our sole discretion.** This includes, but is not limited to, 29 - > termination for violation of these Terms, inappropriate conduct, spam, 30 - > abuse, or any other behavior we deem harmful to the Service or other 31 - > users. 32 - > 33 - > Account termination may result in the loss of access to your 34 - > repositories, data, and other content associated with your account. We 35 - > are not obligated to provide advance notice of termination, though we 36 - > may do so in our discretion. 37 - 38 - ## 4. Acceptable Use 39 - 40 - You agree not to use the Service to: 41 - 42 - - Violate any applicable laws or regulations 43 - - Infringe upon the rights of others 44 - - Upload, store, or share content that is illegal, harmful, threatening, 45 - abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 - objectionable 47 - - Engage in spam, phishing, or other deceptive practices 48 - - Attempt to gain unauthorized access to the Service or other users' 49 - accounts 50 - - Interfere with or disrupt the Service or servers connected to the 51 - Service 52 - 53 - ## 5. Content and Intellectual Property 54 - 55 - You retain ownership of the content you upload to the Service. By 56 - uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 - license to use, reproduce, modify, and distribute your content as 58 - necessary to provide the Service. 59 - 60 - ## 6. Privacy 61 - 62 - Your privacy is important to us. Please review our [Privacy 63 - Policy](/privacy), which also governs your use of the Service. 64 - 65 - ## 7. Disclaimers 66 - 67 - The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 - no warranties, expressed or implied, and hereby disclaim and negate all 69 - other warranties including without limitation, implied warranties or 70 - conditions of merchantability, fitness for a particular purpose, or 71 - non-infringement of intellectual property or other violation of rights. 72 - 73 - ## 8. Limitation of Liability 74 - 75 - In no event shall Tangled, nor its directors, employees, partners, 76 - agents, suppliers, or affiliates, be liable for any indirect, 77 - incidental, special, consequential, or punitive damages, including 78 - without limitation, loss of profits, data, use, goodwill, or other 79 - intangible losses, resulting from your use of the Service. 80 - 81 - ## 9. Indemnification 82 - 83 - You agree to defend, indemnify, and hold harmless Tangled and its 84 - affiliates, officers, directors, employees, and agents from and against 85 - any and all claims, damages, obligations, losses, liabilities, costs, 86 - or debt, and expenses (including attorney's fees). 87 - 88 - ## 10. Governing Law 89 - 90 - These Terms shall be interpreted and governed by the laws of Finland, 91 - without regard to its conflict of law provisions. 92 - 93 - ## 11. Changes to Terms 94 - 95 - We reserve the right to modify or replace these Terms at any time. If a 96 - revision is material, we will try to provide at least 30 days notice 97 - prior to any new terms taking effect. 98 - 99 - ## 12. Contact Information 100 - 101 - If you have any questions about these Terms of Service, please contact 102 - us through our platform or via email. 103 - 104 - --- 105 - 106 - These terms are effective as of the last updated date shown above and 107 - will remain in effect except with respect to any changes in their 108 - provisions in the future, which will be in effect immediately after 109 - being posted on this page.
+6
lexicons/actor/profile.json
··· 55 55 "maxGraphemes": 40, 56 56 "maxLength": 400 57 57 }, 58 + "pronouns": { 59 + "type": "string", 60 + "description": "Free-form preferred pronouns text.", 61 + "maxGraphemes": 40, 62 + "maxLength": 400 63 + }, 58 64 "pinnedRepositories": { 59 65 "type": "array", 60 66 "description": "Any ATURI, it is up to appviews to validate these fields.",
+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 41 "type": "string", 42 42 "description": "Parent directory path" 43 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 44 49 "files": { 45 50 "type": "array", 46 51 "items": { ··· 69 74 "description": "Invalid request parameters" 70 75 } 71 76 ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 72 91 }, 73 92 "treeEntry": { 74 93 "type": "object",
+23 -9
log/log.go
··· 4 4 "context" 5 5 "log/slog" 6 6 "os" 7 + 8 + "github.com/charmbracelet/log" 7 9 ) 8 10 9 - // NewHandler sets up a new slog.Handler with the service name 10 - // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 - Level: slog.LevelDebug, 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 14 16 }) 15 - 16 - var attrs []slog.Attr 17 - attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)}) 18 - handler.WithAttrs(attrs) 19 - return handler 20 17 } 21 18 22 19 func New(name string) *slog.Logger { ··· 49 46 50 47 return slog.Default() 51 48 } 49 + 50 + // sublogger derives a new logger from an existing one by appending a suffix to its prefix. 51 + func SubLogger(base *slog.Logger, suffix string) *slog.Logger { 52 + // try to get the underlying charmbracelet logger 53 + if cl, ok := base.Handler().(*log.Logger); ok { 54 + prefix := cl.GetPrefix() 55 + if prefix != "" { 56 + prefix = prefix + "/" + suffix 57 + } else { 58 + prefix = suffix 59 + } 60 + return slog.New(NewHandler(prefix)) 61 + } 62 + 63 + // Fallback: no known handler type 64 + return slog.New(NewHandler(suffix)) 65 + }
+62 -11
nix/gomod2nix.toml
··· 29 29 [mod."github.com/avast/retry-go/v4"] 30 30 version = "v4.6.1" 31 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 + [mod."github.com/aymanbagabas/go-osc52/v2"] 33 + version = "v2.0.1" 34 + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 32 35 [mod."github.com/aymerick/douceur"] 33 36 version = "v0.2.0" 34 37 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" ··· 40 43 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 44 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 45 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 46 + version = "v0.0.0-20251003000214-3259b215110e" 47 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 48 [mod."github.com/bluesky-social/jetstream"] 46 49 version = "v0.0.0-20241210005130-ea96859b93d1" 47 50 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 63 66 [mod."github.com/cespare/xxhash/v2"] 64 67 version = "v2.3.0" 65 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=" 66 87 [mod."github.com/cloudflare/circl"] 67 88 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 89 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 145 166 [mod."github.com/go-jose/go-jose/v3"] 146 167 version = "v3.0.4" 147 168 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 169 + [mod."github.com/go-logfmt/logfmt"] 170 + version = "v0.6.0" 171 + hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 148 172 [mod."github.com/go-logr/logr"] 149 173 version = "v1.4.3" 150 174 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 163 187 [mod."github.com/gogo/protobuf"] 164 188 version = "v1.3.2" 165 189 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 190 + [mod."github.com/goki/freetype"] 191 + version = "v1.0.5" 192 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 166 193 [mod."github.com/golang-jwt/jwt/v5"] 167 194 version = "v5.2.3" 168 195 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 295 322 [mod."github.com/lestrrat-go/option"] 296 323 version = "v1.0.1" 297 324 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 325 + [mod."github.com/lucasb-eyer/go-colorful"] 326 + version = "v1.2.0" 327 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 298 328 [mod."github.com/mattn/go-isatty"] 299 329 version = "v0.0.20" 300 330 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 331 + [mod."github.com/mattn/go-runewidth"] 332 + version = "v0.0.16" 333 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 301 334 [mod."github.com/mattn/go-sqlite3"] 302 335 version = "v1.14.24" 303 336 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 325 358 [mod."github.com/mr-tron/base58"] 326 359 version = "v1.2.0" 327 360 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 361 + [mod."github.com/muesli/termenv"] 362 + version = "v0.16.0" 363 + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 328 364 [mod."github.com/multiformats/go-base32"] 329 365 version = "v0.1.0" 330 366 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 391 427 [mod."github.com/resend/resend-go/v2"] 392 428 version = "v2.15.0" 393 429 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 430 + [mod."github.com/rivo/uniseg"] 431 + version = "v0.4.7" 432 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 394 433 [mod."github.com/ryanuber/go-glob"] 395 434 version = "v1.0.0" 396 435 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 407 446 [mod."github.com/spaolacci/murmur3"] 408 447 version = "v1.1.0" 409 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=" 410 455 [mod."github.com/stretchr/testify"] 411 456 version = "v1.10.0" 412 457 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 431 476 [mod."github.com/wyatt915/treeblood"] 432 477 version = "v0.1.15" 433 478 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 479 + [mod."github.com/xo/terminfo"] 480 + version = "v0.0.0-20220910002029-abceb7e1c41e" 481 + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 434 482 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 483 + version = "v1.7.13" 484 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 437 485 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 486 version = "v2.0.0-20230729083705-37449abec8cc" 439 487 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 488 + [mod."gitlab.com/staticnoise/goldmark-callout"] 489 + version = "v0.0.0-20240609120641-6366b799e4ab" 490 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 440 491 [mod."gitlab.com/yawning/secp256k1-voi"] 441 492 version = "v0.0.0-20230925100816-f2616030848b" 442 493 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 479 530 [mod."golang.org/x/exp"] 480 531 version = "v0.0.0-20250620022241-b7579e27df2b" 481 532 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 533 + [mod."golang.org/x/image"] 534 + version = "v0.31.0" 535 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 482 536 [mod."golang.org/x/net"] 483 537 version = "v0.42.0" 484 538 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 539 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 540 + version = "v0.17.0" 541 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 488 542 [mod."golang.org/x/sys"] 489 543 version = "v0.34.0" 490 544 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 545 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 546 + version = "v0.29.0" 547 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 494 548 [mod."golang.org/x/time"] 495 549 version = "v0.12.0" 496 550 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 581 [mod."lukechampine.com/blake3"] 528 582 version = "v1.4.1" 529 583 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="
+2 -2
nix/modules/knot.nix
··· 22 22 23 23 appviewEndpoint = mkOption { 24 24 type = types.str; 25 - default = "https://tangled.sh"; 25 + default = "https://tangled.org"; 26 26 description = "Appview endpoint"; 27 27 }; 28 28 ··· 107 107 108 108 hostname = mkOption { 109 109 type = types.str; 110 - example = "knot.tangled.sh"; 110 + example = "my.knot.com"; 111 111 description = "Hostname for the server (required)"; 112 112 }; 113 113
+2 -2
nix/modules/spindle.nix
··· 33 33 34 34 hostname = mkOption { 35 35 type = types.str; 36 - example = "spindle.tangled.sh"; 36 + example = "my.spindle.com"; 37 37 description = "Hostname for the server (required)"; 38 38 }; 39 39 ··· 92 92 pipelines = { 93 93 nixery = mkOption { 94 94 type = types.str; 95 - default = "nixery.tangled.sh"; 95 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 96 description = "Nixery instance to use"; 97 97 }; 98 98
+1
nix/pkgs/appview-static-files.nix
··· 22 22 cp -rf ${lucide-src}/*.svg icons/ 23 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 25 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 28 # for whatever reason (produces broken css), so we are doing this instead
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+18 -7
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "os" ··· 42 43 // IsPatchValid checks if the given patch string is valid. 43 44 // It performs very basic sniffing for either git-diff or git-format-patch 44 45 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 46 53 if len(patch) == 0 { 47 - return false 54 + return EmptyPatchError 48 55 } 49 56 50 57 lines := strings.Split(patch, "\n") 51 58 if len(lines) < 2 { 52 - return false 59 + return EmptyPatchError 53 60 } 54 61 55 62 firstLine := strings.TrimSpace(lines[0]) ··· 60 67 strings.HasPrefix(firstLine, "Index: ") || 61 68 strings.HasPrefix(firstLine, "+++ ") || 62 69 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 70 + return nil 64 71 } 65 72 66 73 // check if it's format-patch ··· 70 77 // it's safe to say it's broken. 71 78 patches, err := ExtractPatches(patch) 72 79 if err != nil { 73 - return false 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 74 81 } 75 - return len(patches) > 0 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 76 87 } 77 88 78 - return false 89 + return GenericPatchError 79 90 } 80 91 81 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "reflect" 5 6 "testing" 6 7 ) ··· 9 10 tests := []struct { 10 11 name string 11 12 patch string 12 - expected bool 13 + expected error 13 14 }{ 14 15 { 15 16 name: `empty patch`, 16 17 patch: ``, 17 - expected: false, 18 + expected: EmptyPatchError, 18 19 }, 19 20 { 20 21 name: `single line patch`, 21 22 patch: `single line`, 22 - expected: false, 23 + expected: EmptyPatchError, 23 24 }, 24 25 { 25 26 name: `valid diff patch`, ··· 31 32 -old line 32 33 +new line 33 34 context`, 34 - expected: true, 35 + expected: nil, 35 36 }, 36 37 { 37 38 name: `valid patch starting with ---`, ··· 41 42 -old line 42 43 +new line 43 44 context`, 44 - expected: true, 45 + expected: nil, 45 46 }, 46 47 { 47 48 name: `valid patch starting with Index`, ··· 53 54 -old line 54 55 +new line 55 56 context`, 56 - expected: true, 57 + expected: nil, 57 58 }, 58 59 { 59 60 name: `valid patch starting with +++`, ··· 63 64 -old line 64 65 +new line 65 66 context`, 66 - expected: true, 67 + expected: nil, 67 68 }, 68 69 { 69 70 name: `valid patch starting with @@`, ··· 72 73 +new line 73 74 context 74 75 `, 75 - expected: true, 76 + expected: nil, 76 77 }, 77 78 { 78 79 name: `valid format patch`, ··· 90 91 +new content 91 92 -- 92 93 2.48.1`, 93 - expected: true, 94 + expected: nil, 94 95 }, 95 96 { 96 97 name: `invalid format patch`, 97 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 99 From: Author <author@example.com> 99 100 This is not a valid patch format`, 100 - expected: false, 101 + expected: FormatPatchError, 101 102 }, 102 103 { 103 104 name: `not a patch at all`, ··· 105 106 just some 106 107 random text 107 108 that isn't a patch`, 108 - expected: false, 109 + expected: GenericPatchError, 109 110 }, 110 111 } 111 112 112 113 for _, tt := range tests { 113 114 t.Run(tt.name, func(t *testing.T) { 114 115 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 + if !errors.Is(result, tt.expected) { 116 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 118 } 118 119 })
+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 108 tangled.RepoNSID, 109 109 tangled.RepoCollaboratorNSID, 110 110 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 112 if err != nil { 113 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 114 } ··· 171 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 172 // job in the above registered queue. 173 173 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 174 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 175 ccfg.Dev = cfg.Server.Dev 176 176 ccfg.ProcessFunc = spindle.processPipeline 177 177 ccfg.CursorStore = cursorStore ··· 210 210 } 211 211 212 212 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 213 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 214 215 + l := log.SubLogger(s.l, "xrpc") 216 + 217 217 x := xrpc.Xrpc{ 218 - Logger: logger, 218 + Logger: l, 219 219 Db: s.db, 220 220 Enforcer: s.e, 221 221 Engines: s.engs, ··· 305 305 306 306 ok := s.jq.Enqueue(queue.Job{ 307 307 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 308 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 311 Workflows: workflows,
+3 -3
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 + "tangled.org/core/log" 13 14 "tangled.org/core/spindle/models" 14 15 15 16 "github.com/go-chi/chi/v5" ··· 23 24 } 24 25 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 + l := log.SubLogger(s.l, "eventstream") 28 + 27 29 l.Debug("received new connection") 28 30 29 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 84 } 83 85 case <-time.After(30 * time.Second): 84 86 // send a keep-alive 85 - l.Debug("sent keepalive") 86 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 88 l.Error("failed to write control", "err", err) 88 89 } ··· 222 223 s.l.Debug("err", "err", err) 223 224 return err 224 225 } 225 - s.l.Debug("ops", "ops", events) 226 226 227 227 for _, event := range events { 228 228 // first extract the inner json into a map
+14 -10
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 4 5 "github.com/go-git/go-git/v5/plumbing/object" 5 6 ) 6 7 ··· 33 34 } 34 35 35 36 type RepoFormatPatchResponse struct { 36 - Rev1 string `json:"rev1,omitempty"` 37 - Rev2 string `json:"rev2,omitempty"` 38 - FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 - MergeBase string `json:"merge_base,omitempty"` // deprecated 40 - Patch string `json:"patch,omitempty"` 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 40 + FormatPatchRaw string `json:"patch,omitempty"` 41 + CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 42 + CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 41 43 } 42 44 43 45 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 46 + Ref string `json:"ref,omitempty"` 47 + Parent string `json:"parent,omitempty"` 48 + Description string `json:"description,omitempty"` 49 + DotDot string `json:"dotdot,omitempty"` 50 + Files []NiceTree `json:"files,omitempty"` 51 + ReadmeFileName string `json:"readme_filename,omitempty"` 52 + Readme string `json:"readme_contents,omitempty"` 49 53 } 50 54 51 55 type TagReference struct {
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 22 23 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 25 return &ServiceAuth{ 25 - logger: logger, 26 + logger: log.SubLogger(logger, "serviceauth"), 26 27 resolver: resolver, 27 28 audienceDid: audienceDid, 28 29 } ··· 30 31 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 34 token := r.Header.Get("Authorization") 36 35 token = strings.TrimPrefix(token, "Bearer ") 37 36 ··· 42 41 43 42 did, err := s.Validate(r.Context(), token, nil) 44 43 if err != nil { 45 - l.Error("signature verification failed", "err", err) 44 + sa.logger.Error("signature verification failed", "err", err) 46 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 46 return 48 47 } 48 + 49 + sa.logger.Debug("valid signature", ActorDid, did) 49 50 50 51 r = r.WithContext( 51 52 context.WithValue(r.Context(), ActorDid, did),