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

Compare changes

Choose any two refs to compare.

Changed files
+5614 -2457
.tangled
api
appview
db
dns
issues
knots
labels
middleware
models
notifications
notify
oauth
ogcard
pages
pagination
pipelines
pulls
repo
settings
signup
spindles
state
strings
validator
xrpcclient
cmd
appview
cborgen
genjwks
knot
punchcardPopulate
spindle
docs
jetstream
knotserver
lexicons
log
nix
patchutil
spindle
types
xrpc
serviceauth
+1 -1
.tangled/workflows/build.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+1 -1
.tangled/workflows/test.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+30
api/tangled/repodeleteBranch.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
-1
appview/db/artifact.go
··· 67 ) 68 69 rows, err := e.Query(query, args...) 70 - 71 if err != nil { 72 return nil, err 73 }
··· 67 ) 68 69 rows, err := e.Query(query, args...) 70 if err != nil { 71 return nil, err 72 }
+53
appview/db/collaborators.go
··· 3 import ( 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) ··· 59 60 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 61 }
··· 3 import ( 4 "fmt" 5 "strings" 6 + "time" 7 8 "tangled.org/core/appview/models" 9 ) ··· 60 61 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 62 } 63 + 64 + func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 + var collaborators []models.Collaborator 66 + var conditions []string 67 + var args []any 68 + for _, filter := range filters { 69 + conditions = append(conditions, filter.Condition()) 70 + args = append(args, filter.Arg()...) 71 + } 72 + whereClause := "" 73 + if conditions != nil { 74 + whereClause = " where " + strings.Join(conditions, " and ") 75 + } 76 + query := fmt.Sprintf(`select 77 + id, 78 + did, 79 + rkey, 80 + subject_did, 81 + repo_at, 82 + created 83 + from collaborators %s`, 84 + whereClause, 85 + ) 86 + rows, err := e.Query(query, args...) 87 + if err != nil { 88 + return nil, err 89 + } 90 + defer rows.Close() 91 + for rows.Next() { 92 + var collaborator models.Collaborator 93 + var createdAt string 94 + if err := rows.Scan( 95 + &collaborator.Id, 96 + &collaborator.Did, 97 + &collaborator.Rkey, 98 + &collaborator.SubjectDid, 99 + &collaborator.RepoAt, 100 + &createdAt, 101 + ); err != nil { 102 + return nil, err 103 + } 104 + collaborator.Created, err = time.Parse(time.RFC3339, createdAt) 105 + if err != nil { 106 + collaborator.Created = time.Now() 107 + } 108 + collaborators = append(collaborators, collaborator) 109 + } 110 + if err := rows.Err(); err != nil { 111 + return nil, err 112 + } 113 + return collaborators, nil 114 + }
+45 -28
appview/db/db.go
··· 4 "context" 5 "database/sql" 6 "fmt" 7 - "log" 8 "reflect" 9 "strings" 10 11 _ "github.com/mattn/go-sqlite3" 12 ) 13 14 type DB struct { 15 *sql.DB 16 } 17 18 type Execer interface { ··· 26 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 } 28 29 - func Make(dbPath string) (*DB, error) { 30 // https://github.com/mattn/go-sqlite3#connection-string 31 opts := []string{ 32 "_foreign_keys=1", ··· 35 "_auto_vacuum=incremental", 36 } 37 38 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 if err != nil { 40 return nil, err 41 } 42 - 43 - ctx := context.Background() 44 45 conn, err := db.Conn(ctx) 46 if err != nil { ··· 574 } 575 576 // run migrations 577 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 578 tx.Exec(` 579 alter table repos add column description text check (length(description) <= 200); 580 `) 581 return nil 582 }) 583 584 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 585 // add unconstrained column 586 _, err := tx.Exec(` 587 alter table public_keys ··· 604 return nil 605 }) 606 607 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 608 _, err := tx.Exec(` 609 alter table comments drop column comment_at; 610 alter table comments add column rkey text; ··· 612 return err 613 }) 614 615 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 616 _, err := tx.Exec(` 617 alter table comments add column deleted text; -- timestamp 618 alter table comments add column edited text; -- timestamp ··· 620 return err 621 }) 622 623 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 624 _, err := tx.Exec(` 625 alter table pulls add column source_branch text; 626 alter table pulls add column source_repo_at text; ··· 629 return err 630 }) 631 632 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 633 _, err := tx.Exec(` 634 alter table repos add column source text; 635 `) ··· 641 // 642 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 643 conn.ExecContext(ctx, "pragma foreign_keys = off;") 644 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 645 _, err := tx.Exec(` 646 create table pulls_new ( 647 -- identifiers ··· 698 }) 699 conn.ExecContext(ctx, "pragma foreign_keys = on;") 700 701 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 702 tx.Exec(` 703 alter table repos add column spindle text; 704 `) ··· 708 // drop all knot secrets, add unique constraint to knots 709 // 710 // knots will henceforth use service auth for signed requests 711 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 712 _, err := tx.Exec(` 713 create table registrations_new ( 714 id integer primary key autoincrement, ··· 731 }) 732 733 // recreate and add rkey + created columns with default constraint 734 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 735 // create new table 736 // - repo_at instead of repo integer 737 // - rkey field ··· 785 return err 786 }) 787 788 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 789 _, err := tx.Exec(` 790 alter table issues add column rkey text not null default ''; 791 ··· 797 }) 798 799 // repurpose the read-only column to "needs-upgrade" 800 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 801 _, err := tx.Exec(` 802 alter table registrations rename column read_only to needs_upgrade; 803 `) ··· 805 }) 806 807 // require all knots to upgrade after the release of total xrpc 808 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 809 _, err := tx.Exec(` 810 update registrations set needs_upgrade = 1; 811 `) ··· 813 }) 814 815 // require all knots to upgrade after the release of total xrpc 816 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 817 _, err := tx.Exec(` 818 alter table spindles add column needs_upgrade integer not null default 0; 819 `) ··· 831 // 832 // disable foreign-keys for the next migration 833 conn.ExecContext(ctx, "pragma foreign_keys = off;") 834 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 835 _, err := tx.Exec(` 836 create table if not exists issues_new ( 837 -- identifiers ··· 901 // - new columns 902 // * column "reply_to" which can be any other comment 903 // * column "at-uri" which is a generated column 904 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 905 _, err := tx.Exec(` 906 create table if not exists issue_comments ( 907 -- identifiers ··· 961 // 962 // disable foreign-keys for the next migration 963 conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 - runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 _, err := tx.Exec(` 966 create table if not exists pulls_new ( 967 -- identifiers ··· 1042 // 1043 // disable foreign-keys for the next migration 1044 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 - runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 _, err := tx.Exec(` 1047 create table if not exists pull_submissions_new ( 1048 -- identifiers ··· 1094 }) 1095 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 1097 - return &DB{db}, nil 1098 } 1099 1100 type migrationFn = func(*sql.Tx) error 1101 1102 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1103 tx, err := c.BeginTx(context.Background(), nil) 1104 if err != nil { 1105 return err ··· 1116 // run migration 1117 err = migrationFn(tx) 1118 if err != nil { 1119 - log.Printf("Failed to run migration %s: %v", name, err) 1120 return err 1121 } 1122 1123 // mark migration as complete 1124 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1125 if err != nil { 1126 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 1127 return err 1128 } 1129 ··· 1132 return err 1133 } 1134 1135 - log.Printf("migration %s applied successfully", name) 1136 } else { 1137 - log.Printf("skipped migration %s, already applied", name) 1138 } 1139 1140 return nil
··· 4 "context" 5 "database/sql" 6 "fmt" 7 + "log/slog" 8 "reflect" 9 "strings" 10 11 _ "github.com/mattn/go-sqlite3" 12 + "tangled.org/core/log" 13 ) 14 15 type DB struct { 16 *sql.DB 17 + logger *slog.Logger 18 } 19 20 type Execer interface { ··· 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 29 } 30 31 + func Make(ctx context.Context, dbPath string) (*DB, error) { 32 // https://github.com/mattn/go-sqlite3#connection-string 33 opts := []string{ 34 "_foreign_keys=1", ··· 37 "_auto_vacuum=incremental", 38 } 39 40 + logger := log.FromContext(ctx) 41 + logger = log.SubLogger(logger, "db") 42 + 43 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 44 if err != nil { 45 return nil, err 46 } 47 48 conn, err := db.Conn(ctx) 49 if err != nil { ··· 577 } 578 579 // run migrations 580 + runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 581 tx.Exec(` 582 alter table repos add column description text check (length(description) <= 200); 583 `) 584 return nil 585 }) 586 587 + runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 588 // add unconstrained column 589 _, err := tx.Exec(` 590 alter table public_keys ··· 607 return nil 608 }) 609 610 + runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 611 _, err := tx.Exec(` 612 alter table comments drop column comment_at; 613 alter table comments add column rkey text; ··· 615 return err 616 }) 617 618 + runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 619 _, err := tx.Exec(` 620 alter table comments add column deleted text; -- timestamp 621 alter table comments add column edited text; -- timestamp ··· 623 return err 624 }) 625 626 + runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 627 _, err := tx.Exec(` 628 alter table pulls add column source_branch text; 629 alter table pulls add column source_repo_at text; ··· 632 return err 633 }) 634 635 + runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 636 _, err := tx.Exec(` 637 alter table repos add column source text; 638 `) ··· 644 // 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 647 + runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 648 _, err := tx.Exec(` 649 create table pulls_new ( 650 -- identifiers ··· 701 }) 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 703 704 + runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 705 tx.Exec(` 706 alter table repos add column spindle text; 707 `) ··· 711 // drop all knot secrets, add unique constraint to knots 712 // 713 // knots will henceforth use service auth for signed requests 714 + runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 715 _, err := tx.Exec(` 716 create table registrations_new ( 717 id integer primary key autoincrement, ··· 734 }) 735 736 // recreate and add rkey + created columns with default constraint 737 + runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 738 // create new table 739 // - repo_at instead of repo integer 740 // - rkey field ··· 788 return err 789 }) 790 791 + runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 792 _, err := tx.Exec(` 793 alter table issues add column rkey text not null default ''; 794 ··· 800 }) 801 802 // repurpose the read-only column to "needs-upgrade" 803 + runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 804 _, err := tx.Exec(` 805 alter table registrations rename column read_only to needs_upgrade; 806 `) ··· 808 }) 809 810 // require all knots to upgrade after the release of total xrpc 811 + runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 812 _, err := tx.Exec(` 813 update registrations set needs_upgrade = 1; 814 `) ··· 816 }) 817 818 // require all knots to upgrade after the release of total xrpc 819 + runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 820 _, err := tx.Exec(` 821 alter table spindles add column needs_upgrade integer not null default 0; 822 `) ··· 834 // 835 // disable foreign-keys for the next migration 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 837 + runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 838 _, err := tx.Exec(` 839 create table if not exists issues_new ( 840 -- identifiers ··· 904 // - new columns 905 // * column "reply_to" which can be any other comment 906 // * column "at-uri" which is a generated column 907 + runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 908 _, err := tx.Exec(` 909 create table if not exists issue_comments ( 910 -- identifiers ··· 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 ··· 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 ··· 1097 }) 1098 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1099 1100 + // knots may report the combined patch for a comparison, we can store that on the appview side 1101 + // (but not on the pds record), because calculating the combined patch requires a git index 1102 + runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1103 + _, err := tx.Exec(` 1104 + alter table pull_submissions add column combined text; 1105 + `) 1106 + return err 1107 + }) 1108 + 1109 + return &DB{ 1110 + db, 1111 + logger, 1112 + }, nil 1113 } 1114 1115 type migrationFn = func(*sql.Tx) error 1116 1117 + func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1118 + logger = logger.With("migration", name) 1119 + 1120 tx, err := c.BeginTx(context.Background(), nil) 1121 if err != nil { 1122 return err ··· 1133 // run migration 1134 err = migrationFn(tx) 1135 if err != nil { 1136 + logger.Error("failed to run migration", "err", err) 1137 return err 1138 } 1139 1140 // mark migration as complete 1141 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1142 if err != nil { 1143 + logger.Error("failed to mark migration as complete", "err", err) 1144 return err 1145 } 1146 ··· 1149 return err 1150 } 1151 1152 + logger.Info("migration applied successfully") 1153 } else { 1154 + logger.Warn("skipped migration, already applied") 1155 } 1156 1157 return nil
+8 -25
appview/db/issues.go
··· 101 pLower := FilterGte("row_num", page.Offset+1) 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 104 - args = append(args, pLower.Arg()...) 105 - args = append(args, pUpper.Arg()...) 106 - pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 107 108 query := fmt.Sprintf( 109 ` ··· 128 %s 129 `, 130 whereClause, 131 - pagination, 132 ) 133 134 rows, err := e.Query(query, args...) ··· 244 } 245 246 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 - return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 - } 249 - 250 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 - query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 - row := e.QueryRow(query, repoAt, issueId) 253 - 254 - var issue models.Issue 255 - var createdAt string 256 - err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 - if err != nil { 258 - return nil, err 259 - } 260 - 261 - createdTime, err := time.Parse(time.RFC3339, createdAt) 262 - if err != nil { 263 - return nil, err 264 - } 265 - issue.Created = createdTime 266 - 267 - return &issue, nil 268 } 269 270 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
··· 101 pLower := FilterGte("row_num", page.Offset+1) 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 104 + pageClause := "" 105 + if page.Limit > 0 { 106 + args = append(args, pLower.Arg()...) 107 + args = append(args, pUpper.Arg()...) 108 + pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 109 + } 110 111 query := fmt.Sprintf( 112 ` ··· 131 %s 132 `, 133 whereClause, 134 + pageClause, 135 ) 136 137 rows, err := e.Query(query, args...) ··· 247 } 248 249 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 250 + return GetIssuesPaginated(e, pagination.Page{}, filters...) 251 } 252 253 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+34
appview/db/language.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) 9 ··· 82 83 return nil 84 }
··· 1 package db 2 3 import ( 4 + "database/sql" 5 "fmt" 6 "strings" 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/appview/models" 10 ) 11 ··· 84 85 return nil 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+88 -49
appview/db/notifications.go
··· 8 "strings" 9 "time" 10 11 "tangled.org/core/appview/models" 12 "tangled.org/core/appview/pagination" 13 ) 14 15 - func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 query := ` 17 INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 ` 20 21 - result, err := d.DB.ExecContext(ctx, query, 22 notification.RecipientDid, 23 notification.ActorDid, 24 string(notification.Type), ··· 59 whereClause += " AND " + condition 60 } 61 } 62 63 query := fmt.Sprintf(` 64 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 from notifications 66 %s 67 order by created desc 68 - limit ? offset ? 69 - `, whereClause) 70 - 71 - args = append(args, page.Limit, page.Offset) 72 73 rows, err := e.QueryContext(context.Background(), query, args...) 74 if err != nil { ··· 274 return count, nil 275 } 276 277 - func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 idFilter := FilterEq("id", notificationID) 279 recipientFilter := FilterEq("recipient_did", userDID) 280 ··· 286 287 args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 289 - result, err := d.DB.ExecContext(ctx, query, args...) 290 if err != nil { 291 return fmt.Errorf("failed to mark notification as read: %w", err) 292 } ··· 303 return nil 304 } 305 306 - func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 recipientFilter := FilterEq("recipient_did", userDID) 308 readFilter := FilterEq("read", 0) 309 ··· 315 316 args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 318 - _, err := d.DB.ExecContext(ctx, query, args...) 319 if err != nil { 320 return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 } ··· 323 return nil 324 } 325 326 - func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 idFilter := FilterEq("id", notificationID) 328 recipientFilter := FilterEq("recipient_did", userDID) 329 ··· 334 335 args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 337 - result, err := d.DB.ExecContext(ctx, query, args...) 338 if err != nil { 339 return fmt.Errorf("failed to delete notification: %w", err) 340 } ··· 351 return nil 352 } 353 354 - func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 - userFilter := FilterEq("user_did", userDID) 356 357 query := fmt.Sprintf(` 358 - SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 - pull_commented, followed, pull_merged, issue_closed, email_notifications 360 - FROM notification_preferences 361 - WHERE %s 362 - `, userFilter.Condition()) 363 - 364 - var prefs models.NotificationPreferences 365 - err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 - &prefs.ID, 367 - &prefs.UserDid, 368 - &prefs.RepoStarred, 369 - &prefs.IssueCreated, 370 - &prefs.IssueCommented, 371 - &prefs.PullCreated, 372 - &prefs.PullCommented, 373 - &prefs.Followed, 374 - &prefs.PullMerged, 375 - &prefs.IssueClosed, 376 - &prefs.EmailNotifications, 377 - ) 378 379 if err != nil { 380 - if err == sql.ErrNoRows { 381 - return &models.NotificationPreferences{ 382 - UserDid: userDID, 383 - RepoStarred: true, 384 - IssueCreated: true, 385 - IssueCommented: true, 386 - PullCreated: true, 387 - PullCommented: true, 388 - Followed: true, 389 - PullMerged: true, 390 - IssueClosed: true, 391 - EmailNotifications: false, 392 - }, nil 393 } 394 - return nil, fmt.Errorf("failed to get notification preferences: %w", err) 395 } 396 397 - return &prefs, nil 398 } 399 400 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
··· 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), ··· 60 whereClause += " AND " + condition 61 } 62 } 63 + pageClause := "" 64 + if page.Limit > 0 { 65 + pageClause = " limit ? offset ? " 66 + args = append(args, page.Limit, page.Offset) 67 + } 68 69 query := fmt.Sprintf(` 70 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 71 from notifications 72 %s 73 order by created desc 74 + %s 75 + `, whereClause, pageClause) 76 77 rows, err := e.QueryContext(context.Background(), query, args...) 78 if err != nil { ··· 278 return count, nil 279 } 280 281 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 282 idFilter := FilterEq("id", notificationID) 283 recipientFilter := FilterEq("recipient_did", userDID) 284 ··· 290 291 args := append(idFilter.Arg(), recipientFilter.Arg()...) 292 293 + result, err := e.Exec(query, args...) 294 if err != nil { 295 return fmt.Errorf("failed to mark notification as read: %w", err) 296 } ··· 307 return nil 308 } 309 310 + func MarkAllNotificationsRead(e Execer, userDID string) error { 311 recipientFilter := FilterEq("recipient_did", userDID) 312 readFilter := FilterEq("read", 0) 313 ··· 319 320 args := append(recipientFilter.Arg(), readFilter.Arg()...) 321 322 + _, err := e.Exec(query, args...) 323 if err != nil { 324 return fmt.Errorf("failed to mark all notifications as read: %w", err) 325 } ··· 327 return nil 328 } 329 330 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 331 idFilter := FilterEq("id", notificationID) 332 recipientFilter := FilterEq("recipient_did", userDID) 333 ··· 338 339 args := append(idFilter.Arg(), recipientFilter.Arg()...) 340 341 + result, err := e.Exec(query, args...) 342 if err != nil { 343 return fmt.Errorf("failed to delete notification: %w", err) 344 } ··· 355 return nil 356 } 357 358 + func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 359 + prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 360 + if err != nil { 361 + return nil, err 362 + } 363 + 364 + p, ok := prefs[syntax.DID(userDid)] 365 + if !ok { 366 + return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 367 + } 368 + 369 + return p, nil 370 + } 371 + 372 + func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 373 + prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 374 + 375 + var conditions []string 376 + var args []any 377 + for _, filter := range filters { 378 + conditions = append(conditions, filter.Condition()) 379 + args = append(args, filter.Arg()...) 380 + } 381 + 382 + whereClause := "" 383 + if conditions != nil { 384 + whereClause = " where " + strings.Join(conditions, " and ") 385 + } 386 387 query := fmt.Sprintf(` 388 + select 389 + id, 390 + user_did, 391 + repo_starred, 392 + issue_created, 393 + issue_commented, 394 + pull_created, 395 + pull_commented, 396 + followed, 397 + pull_merged, 398 + issue_closed, 399 + email_notifications 400 + from 401 + notification_preferences 402 + %s 403 + `, whereClause) 404 405 + rows, err := e.Query(query, args...) 406 if err != nil { 407 + return nil, err 408 + } 409 + defer rows.Close() 410 + 411 + for rows.Next() { 412 + var prefs models.NotificationPreferences 413 + if err := rows.Scan( 414 + &prefs.ID, 415 + &prefs.UserDid, 416 + &prefs.RepoStarred, 417 + &prefs.IssueCreated, 418 + &prefs.IssueCommented, 419 + &prefs.PullCreated, 420 + &prefs.PullCommented, 421 + &prefs.Followed, 422 + &prefs.PullMerged, 423 + &prefs.IssueClosed, 424 + &prefs.EmailNotifications, 425 + ); err != nil { 426 + return nil, err 427 } 428 + 429 + prefsMap[prefs.UserDid] = &prefs 430 } 431 432 + if err := rows.Err(); err != nil { 433 + return nil, err 434 + } 435 + 436 + return prefsMap, nil 437 } 438 439 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
+23 -20
appview/db/pulls.go
··· 90 pull.ID = int(id) 91 92 _, err = tx.Exec(` 93 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 - values (?, ?, ?, ?) 95 - `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 246 // collect pull source for all pulls that need it 247 var sourceAts []syntax.ATURI 248 for _, p := range pulls { 249 - if p.PullSource.RepoAt != nil { 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 } ··· 259 sourceRepoMap[r.RepoAt()] = &r 260 } 261 for _, p := range pulls { 262 - if p.PullSource.RepoAt != nil { 263 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 p.PullSource.Repo = sourceRepo 265 } ··· 313 pull_at, 314 round_number, 315 patch, 316 created, 317 source_rev 318 from ··· 332 333 for rows.Next() { 334 var submission models.PullSubmission 335 - var createdAt string 336 - var sourceRev sql.NullString 337 err := rows.Scan( 338 &submission.ID, 339 &submission.PullAt, 340 &submission.RoundNumber, 341 &submission.Patch, 342 - &createdAt, 343 - &sourceRev, 344 ) 345 if err != nil { 346 return nil, err 347 } 348 349 - createdTime, err := time.Parse(time.RFC3339, createdAt) 350 - if err != nil { 351 - return nil, err 352 } 353 - submission.Created = createdTime 354 355 - if sourceRev.Valid { 356 - submission.SourceRev = sourceRev.String 357 } 358 359 submissionMap[submission.ID] = &submission ··· 590 return err 591 } 592 593 - func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 594 - newRoundNumber := len(pull.Submissions) 595 _, err := e.Exec(` 596 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 - values (?, ?, ?, ?) 598 - `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 599 600 return err 601 }
··· 90 pull.ID = int(id) 91 92 _, err = tx.Exec(` 93 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 + values (?, ?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 246 // collect pull source for all pulls that need it 247 var sourceAts []syntax.ATURI 248 for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 } ··· 259 sourceRepoMap[r.RepoAt()] = &r 260 } 261 for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 p.PullSource.Repo = sourceRepo 265 } ··· 313 pull_at, 314 round_number, 315 patch, 316 + combined, 317 created, 318 source_rev 319 from ··· 333 334 for rows.Next() { 335 var submission models.PullSubmission 336 + var submissionCreatedStr string 337 + var submissionSourceRev, submissionCombined sql.NullString 338 err := rows.Scan( 339 &submission.ID, 340 &submission.PullAt, 341 &submission.RoundNumber, 342 &submission.Patch, 343 + &submissionCombined, 344 + &submissionCreatedStr, 345 + &submissionSourceRev, 346 ) 347 if err != nil { 348 return nil, err 349 } 350 351 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 352 + submission.Created = t 353 } 354 355 + if submissionSourceRev.Valid { 356 + submission.SourceRev = submissionSourceRev.String 357 + } 358 + 359 + if submissionCombined.Valid { 360 + submission.Combined = submissionCombined.String 361 } 362 363 submissionMap[submission.ID] = &submission ··· 594 return err 595 } 596 597 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 598 _, err := e.Exec(` 599 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 600 + values (?, ?, ?, ?, ?) 601 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 602 603 return err 604 }
+34 -7
appview/db/reaction.go
··· 62 return count, nil 63 } 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 67 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 71 } 72 - countMap[kind] = count 73 } 74 - return countMap, nil 75 } 76 77 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
··· 62 return count, nil 63 } 64 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 91 } 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 99 } 100 + 101 + return reactionMap, rows.Err() 102 } 103 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+38 -10
appview/db/timeline.go
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 var events []models.TimelineEvent 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 16 if err != nil { 17 return nil, err 18 } 19 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 21 if err != nil { 22 return nil, err 23 } 24 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 26 if err != nil { 27 return nil, err 28 } ··· 70 return isStarred, starCount 71 } 72 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 75 if err != nil { 76 return nil, err 77 } ··· 125 return events, nil 126 } 127 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 130 if err != nil { 131 return nil, err 132 } ··· 166 return events, nil 167 } 168 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 171 if err != nil { 172 return nil, err 173 }
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 var events []models.TimelineEvent 14 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 39 if err != nil { 40 return nil, err 41 } ··· 83 return isStarred, starCount 84 } 85 86 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 + filters := make([]filter, 0) 88 + if userIsFollowing != nil { 89 + filters = append(filters, FilterIn("did", userIsFollowing)) 90 + } 91 + 92 + repos, err := GetRepos(e, limit, filters...) 93 if err != nil { 94 return nil, err 95 } ··· 143 return events, nil 144 } 145 146 + func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 + filters := make([]filter, 0) 148 + if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + } 151 + 152 + stars, err := GetStars(e, limit, filters...) 153 if err != nil { 154 return nil, err 155 } ··· 189 return events, nil 190 } 191 192 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 + filters := make([]filter, 0) 194 + if userIsFollowing != nil { 195 + filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 + } 197 + 198 + follows, err := GetFollows(e, limit, filters...) 199 if err != nil { 200 return nil, err 201 }
+4 -4
appview/dns/cloudflare.go
··· 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 } 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 Type: record.Type, 36 Name: record.Name, 37 Content: record.Content, ··· 39 Proxied: &record.Proxied, 40 }) 41 if err != nil { 42 - return fmt.Errorf("failed to create DNS record: %w", err) 43 } 44 - return nil 45 } 46 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
··· 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 } 32 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 Type: record.Type, 36 Name: record.Name, 37 Content: record.Content, ··· 39 Proxied: &record.Proxied, 40 }) 41 if err != nil { 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 } 44 + return result.ID, nil 45 } 46 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+1 -1
appview/ingester.go
··· 89 } 90 91 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 93 } 94 95 return nil
··· 89 } 90 91 if err != nil { 92 + l.Warn("refused to ingest record", "err", err) 93 } 94 95 return nil
+41 -43
appview/issues/issues.go
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 - "log" 9 "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 "tangled.org/core/idresolver" 31 - tlog "tangled.org/core/log" 32 "tangled.org/core/tid" 33 ) 34 ··· 53 config *config.Config, 54 notifier notify.Notifier, 55 validator *validator.Validator, 56 ) *Issues { 57 return &Issues{ 58 oauth: oauth, ··· 62 db: db, 63 config: config, 64 notifier: notifier, 65 - logger: tlog.New("issues"), 66 validator: validator, 67 } 68 } ··· 72 user := rp.oauth.GetUser(r) 73 f, err := rp.repoResolver.Resolve(r) 74 if err != nil { 75 - log.Println("failed to get repo and knot", err) 76 return 77 } 78 ··· 83 return 84 } 85 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 if err != nil { 88 l.Error("failed to get issue reactions", "err", err) 89 } ··· 99 db.FilterContains("scope", tangled.RepoIssueNSID), 100 ) 101 if err != nil { 102 - log.Println("failed to fetch labels", err) 103 rp.pages.Error503(w) 104 return 105 } ··· 115 Issue: issue, 116 CommentList: issue.CommentList(), 117 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 119 UserReacted: userReactions, 120 LabelDefs: defs, 121 }) ··· 126 user := rp.oauth.GetUser(r) 127 f, err := rp.repoResolver.Resolve(r) 128 if err != nil { 129 - log.Println("failed to get repo and knot", err) 130 return 131 } 132 ··· 166 return 167 } 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 199 200 err = db.PutIssue(tx, newIssue) 201 if err != nil { 202 - log.Println("failed to edit issue", err) 203 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 return 205 } ··· 237 // delete from PDS 238 client, err := rp.oauth.AuthorizedClient(r) 239 if err != nil { 240 - log.Println("failed to get authorized client", err) 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 282 283 collaborators, err := f.Collaborators(r.Context()) 284 if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 286 } 287 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 return user.Did == collab.Did ··· 296 db.FilterEq("id", issue.Id), 297 ) 298 if err != nil { 299 - log.Println("failed to close issue", err) 300 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 return 302 } ··· 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 308 return 309 } else { 310 - log.Println("user is not permitted to close issue") 311 http.Error(w, "for biden", http.StatusUnauthorized) 312 return 313 } ··· 318 user := rp.oauth.GetUser(r) 319 f, err := rp.repoResolver.Resolve(r) 320 if err != nil { 321 - log.Println("failed to get repo and knot", err) 322 return 323 } 324 ··· 331 332 collaborators, err := f.Collaborators(r.Context()) 333 if err != nil { 334 - log.Println("failed to fetch repo collaborators: %w", err) 335 } 336 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 337 return user.Did == collab.Did ··· 344 db.FilterEq("id", issue.Id), 345 ) 346 if err != nil { 347 - log.Println("failed to reopen issue", err) 348 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 349 return 350 } 351 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 352 return 353 } else { 354 - log.Println("user is not the owner of the repo") 355 http.Error(w, "forbidden", http.StatusUnauthorized) 356 return 357 } ··· 408 } 409 410 // create a record first 411 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 412 Collection: tangled.RepoIssueCommentNSID, 413 Repo: comment.Did, 414 Rkey: comment.Rkey, ··· 538 newBody := r.FormValue("body") 539 client, err := rp.oauth.AuthorizedClient(r) 540 if err != nil { 541 - log.Println("failed to get authorized client", err) 542 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 543 return 544 } ··· 551 552 _, err = db.AddIssueComment(rp.db, newComment) 553 if err != nil { 554 - log.Println("failed to perferom update-description query", err) 555 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 556 return 557 } ··· 559 // rkey is optional, it was introduced later 560 if newComment.Rkey != "" { 561 // update the record on pds 562 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 if err != nil { 564 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 return 567 } 568 569 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 570 Collection: tangled.RepoIssueCommentNSID, 571 Repo: user.Did, 572 Rkey: newComment.Rkey, ··· 729 if comment.Rkey != "" { 730 client, err := rp.oauth.AuthorizedClient(r) 731 if err != nil { 732 - log.Println("failed to get authorized client", err) 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 return 735 } 736 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 737 Collection: tangled.RepoIssueCommentNSID, 738 Repo: user.Did, 739 Rkey: comment.Rkey, 740 }) 741 if err != nil { 742 - log.Println(err) 743 } 744 } 745 ··· 757 } 758 759 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 760 params := r.URL.Query() 761 state := params.Get("state") 762 isOpen := true ··· 769 isOpen = true 770 } 771 772 - page, ok := r.Context().Value("page").(pagination.Page) 773 - if !ok { 774 - log.Println("failed to get page") 775 - page = pagination.FirstPage() 776 - } 777 778 user := rp.oauth.GetUser(r) 779 f, err := rp.repoResolver.Resolve(r) 780 if err != nil { 781 - log.Println("failed to get repo and knot", err) 782 return 783 } 784 ··· 793 db.FilterEq("open", openVal), 794 ) 795 if err != nil { 796 - log.Println("failed to get issues", err) 797 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 798 return 799 } ··· 804 db.FilterContains("scope", tangled.RepoIssueNSID), 805 ) 806 if err != nil { 807 - log.Println("failed to fetch labels", err) 808 rp.pages.Error503(w) 809 return 810 } ··· 848 Body: r.FormValue("body"), 849 Did: user.Did, 850 Created: time.Now(), 851 } 852 853 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 865 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 return 867 } 868 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 869 Collection: tangled.RepoIssueNSID, 870 Repo: user.Did, 871 Rkey: issue.Rkey, ··· 901 902 err = db.PutIssue(tx, issue) 903 if err != nil { 904 - log.Println("failed to create issue", err) 905 rp.pages.Notice(w, "issues", "Failed to create issue.") 906 return 907 } 908 909 if err = tx.Commit(); err != nil { 910 - log.Println("failed to create issue", err) 911 rp.pages.Notice(w, "issues", "Failed to create issue.") 912 return 913 } ··· 923 // this is used to rollback changes made to the PDS 924 // 925 // it is a no-op if the provided ATURI is empty 926 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 927 if aturi == "" { 928 return nil 929 } ··· 934 repo := parsed.Authority().String() 935 rkey := parsed.RecordKey().String() 936 937 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 938 Collection: collection, 939 Repo: repo, 940 Rkey: rkey,
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "slices" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 "tangled.org/core/idresolver" 30 "tangled.org/core/tid" 31 ) 32 ··· 51 config *config.Config, 52 notifier notify.Notifier, 53 validator *validator.Validator, 54 + logger *slog.Logger, 55 ) *Issues { 56 return &Issues{ 57 oauth: oauth, ··· 61 db: db, 62 config: config, 63 notifier: notifier, 64 + logger: logger, 65 validator: validator, 66 } 67 } ··· 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { 74 + l.Error("failed to get repo and knot", "err", err) 75 return 76 } 77 ··· 82 return 83 } 84 85 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 86 if err != nil { 87 l.Error("failed to get issue reactions", "err", err) 88 } ··· 98 db.FilterContains("scope", tangled.RepoIssueNSID), 99 ) 100 if err != nil { 101 + l.Error("failed to fetch labels", "err", err) 102 rp.pages.Error503(w) 103 return 104 } ··· 114 Issue: issue, 115 CommentList: issue.CommentList(), 116 OrderedReactionKinds: models.OrderedReactionKinds, 117 + Reactions: reactionMap, 118 UserReacted: userReactions, 119 LabelDefs: defs, 120 }) ··· 125 user := rp.oauth.GetUser(r) 126 f, err := rp.repoResolver.Resolve(r) 127 if err != nil { 128 + l.Error("failed to get repo and knot", "err", err) 129 return 130 } 131 ··· 165 return 166 } 167 168 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 169 if err != nil { 170 l.Error("failed to get record", "err", err) 171 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 172 return 173 } 174 175 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 176 Collection: tangled.RepoIssueNSID, 177 Repo: user.Did, 178 Rkey: newIssue.Rkey, ··· 198 199 err = db.PutIssue(tx, newIssue) 200 if err != nil { 201 + l.Error("failed to edit issue", "err", err) 202 rp.pages.Notice(w, "issues", "Failed to edit issue.") 203 return 204 } ··· 236 // delete from PDS 237 client, err := rp.oauth.AuthorizedClient(r) 238 if err != nil { 239 + l.Error("failed to get authorized client", "err", err) 240 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 241 return 242 } 243 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 244 Collection: tangled.RepoIssueNSID, 245 Repo: issue.Did, 246 Rkey: issue.Rkey, ··· 281 282 collaborators, err := f.Collaborators(r.Context()) 283 if err != nil { 284 + l.Error("failed to fetch repo collaborators", "err", err) 285 } 286 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 287 return user.Did == collab.Did ··· 295 db.FilterEq("id", issue.Id), 296 ) 297 if err != nil { 298 + l.Error("failed to close issue", "err", err) 299 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 300 return 301 } ··· 306 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 307 return 308 } else { 309 + l.Error("user is not permitted to close issue") 310 http.Error(w, "for biden", http.StatusUnauthorized) 311 return 312 } ··· 317 user := rp.oauth.GetUser(r) 318 f, err := rp.repoResolver.Resolve(r) 319 if err != nil { 320 + l.Error("failed to get repo and knot", "err", err) 321 return 322 } 323 ··· 330 331 collaborators, err := f.Collaborators(r.Context()) 332 if err != nil { 333 + l.Error("failed to fetch repo collaborators", "err", err) 334 } 335 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 336 return user.Did == collab.Did ··· 343 db.FilterEq("id", issue.Id), 344 ) 345 if err != nil { 346 + l.Error("failed to reopen issue", "err", err) 347 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 348 return 349 } 350 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 351 return 352 } else { 353 + l.Error("user is not the owner of the repo") 354 http.Error(w, "forbidden", http.StatusUnauthorized) 355 return 356 } ··· 407 } 408 409 // create a record first 410 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 411 Collection: tangled.RepoIssueCommentNSID, 412 Repo: comment.Did, 413 Rkey: comment.Rkey, ··· 537 newBody := r.FormValue("body") 538 client, err := rp.oauth.AuthorizedClient(r) 539 if err != nil { 540 + l.Error("failed to get authorized client", "err", err) 541 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 542 return 543 } ··· 550 551 _, err = db.AddIssueComment(rp.db, newComment) 552 if err != nil { 553 + l.Error("failed to perferom update-description query", "err", err) 554 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 555 return 556 } ··· 558 // rkey is optional, it was introduced later 559 if newComment.Rkey != "" { 560 // update the record on pds 561 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 562 if err != nil { 563 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 564 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 565 return 566 } 567 568 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 569 Collection: tangled.RepoIssueCommentNSID, 570 Repo: user.Did, 571 Rkey: newComment.Rkey, ··· 728 if comment.Rkey != "" { 729 client, err := rp.oauth.AuthorizedClient(r) 730 if err != nil { 731 + l.Error("failed to get authorized client", "err", err) 732 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 733 return 734 } 735 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 736 Collection: tangled.RepoIssueCommentNSID, 737 Repo: user.Did, 738 Rkey: comment.Rkey, 739 }) 740 if err != nil { 741 + l.Error("failed to delete from PDS", "err", err) 742 } 743 } 744 ··· 756 } 757 758 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 759 + l := rp.logger.With("handler", "RepoIssues") 760 + 761 params := r.URL.Query() 762 state := params.Get("state") 763 isOpen := true ··· 770 isOpen = true 771 } 772 773 + page := pagination.FromContext(r.Context()) 774 775 user := rp.oauth.GetUser(r) 776 f, err := rp.repoResolver.Resolve(r) 777 if err != nil { 778 + l.Error("failed to get repo and knot", "err", err) 779 return 780 } 781 ··· 790 db.FilterEq("open", openVal), 791 ) 792 if err != nil { 793 + l.Error("failed to get issues", "err", err) 794 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 795 return 796 } ··· 801 db.FilterContains("scope", tangled.RepoIssueNSID), 802 ) 803 if err != nil { 804 + l.Error("failed to fetch labels", "err", err) 805 rp.pages.Error503(w) 806 return 807 } ··· 845 Body: r.FormValue("body"), 846 Did: user.Did, 847 Created: time.Now(), 848 + Repo: &f.Repo, 849 } 850 851 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 863 rp.pages.Notice(w, "issues", "Failed to create issue.") 864 return 865 } 866 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 867 Collection: tangled.RepoIssueNSID, 868 Repo: user.Did, 869 Rkey: issue.Rkey, ··· 899 900 err = db.PutIssue(tx, issue) 901 if err != nil { 902 + l.Error("failed to create issue", "err", err) 903 rp.pages.Notice(w, "issues", "Failed to create issue.") 904 return 905 } 906 907 if err = tx.Commit(); err != nil { 908 + l.Error("failed to create issue", "err", err) 909 rp.pages.Notice(w, "issues", "Failed to create issue.") 910 return 911 } ··· 921 // this is used to rollback changes made to the PDS 922 // 923 // it is a no-op if the provided ATURI is empty 924 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 925 if aturi == "" { 926 return nil 927 } ··· 932 repo := parsed.Authority().String() 933 rkey := parsed.RecordKey().String() 934 935 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 936 Collection: collection, 937 Repo: repo, 938 Rkey: rkey,
+267
appview/issues/opengraph.go
···
··· 1 + package issues 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/ogcard" 15 + ) 16 + 17 + func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 + width, height := ogcard.DefaultSize() 19 + mainCard, err := ogcard.NewCard(width, height) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + // Split: content area (75%) and status/stats area (25%) 25 + contentCard, statsArea := mainCard.Split(false, 75) 26 + 27 + // Add padding to content 28 + contentCard.SetMargin(50) 29 + 30 + // Split content horizontally: main content (80%) and avatar area (20%) 31 + mainContent, avatarArea := contentCard.Split(true, 80) 32 + 33 + // Add margin to main content like repo card 34 + mainContent.SetMargin(10) 35 + 36 + // Use full main content area for repo name and title 37 + bounds := mainContent.Img.Bounds() 38 + startX := bounds.Min.X + mainContent.Margin 39 + startY := bounds.Min.Y + mainContent.Margin 40 + 41 + // Draw full repository name at top (owner/repo format) 42 + var repoOwner string 43 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 + if err != nil { 45 + repoOwner = repo.Did 46 + } else { 47 + repoOwner = "@" + owner.Handle.String() 48 + } 49 + 50 + fullRepoName := repoOwner + " / " + repo.Name 51 + if len(fullRepoName) > 60 { 52 + fullRepoName = fullRepoName[:60] + "…" 53 + } 54 + 55 + grayColor := color.RGBA{88, 96, 105, 255} 56 + err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // Draw issue title below repo name with wrapping 62 + titleY := startY + 60 63 + titleX := startX 64 + 65 + // Truncate title if too long 66 + issueTitle := issue.Title 67 + maxTitleLength := 80 68 + if len(issueTitle) > maxTitleLength { 69 + issueTitle = issueTitle[:maxTitleLength] + "…" 70 + } 71 + 72 + // Create a temporary card for the title area to enable wrapping 73 + titleBounds := mainContent.Img.Bounds() 74 + titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 + titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 + 77 + titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 + titleCard := &ogcard.Card{ 79 + Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 + Font: mainContent.Font, 81 + Margin: 0, 82 + } 83 + 84 + // Draw wrapped title 85 + lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + // Calculate where title ends (number of lines * line height) 91 + lineHeight := 60 // Approximate line height for 54pt font 92 + titleEndY := titleY + (len(lines) * lineHeight) + 10 93 + 94 + // Draw issue ID in gray below the title 95 + issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 + err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + // Get issue author handle (needed for avatar and metadata) 102 + var authorHandle string 103 + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 + if err != nil { 105 + authorHandle = issue.Did 106 + } else { 107 + authorHandle = "@" + author.Handle.String() 108 + } 109 + 110 + // Draw avatar circle on the right side 111 + avatarBounds := avatarArea.Img.Bounds() 112 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 + if avatarSize > 220 { 114 + avatarSize = 220 115 + } 116 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 + avatarY := avatarBounds.Min.Y + 20 118 + 119 + // Get avatar URL for issue author 120 + avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 + if err != nil { 123 + log.Printf("failed to draw avatar (non-fatal): %v", err) 124 + } 125 + 126 + // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 + statusCommentsArea, dollyArea := statsArea.Split(true, 80) 128 + 129 + // Draw status and comment count in status/comments area 130 + statsBounds := statusCommentsArea.Img.Bounds() 131 + statsX := statsBounds.Min.X + 60 // left padding 132 + statsY := statsBounds.Min.Y 133 + 134 + iconColor := color.RGBA{88, 96, 105, 255} 135 + iconSize := 36 136 + textSize := 36.0 137 + labelSize := 28.0 138 + iconBaselineOffset := int(textSize) / 2 139 + 140 + // Draw status (open/closed) with colored icon and text 141 + var statusIcon string 142 + var statusText string 143 + var statusBgColor color.RGBA 144 + 145 + if issue.Open { 146 + statusIcon = "static/icons/circle-dot.svg" 147 + statusText = "open" 148 + statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 + } else { 150 + statusIcon = "static/icons/circle-dot.svg" 151 + statusText = "closed" 152 + statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 + } 154 + 155 + badgeIconSize := 36 156 + 157 + // Draw icon with status color (no background) 158 + err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 + if err != nil { 160 + log.Printf("failed to draw status icon: %v", err) 161 + } 162 + 163 + // Draw text with status color (no background) 164 + textX := statsX + badgeIconSize + 12 165 + badgeTextSize := 32.0 166 + err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left) 167 + if err != nil { 168 + log.Printf("failed to draw status text: %v", err) 169 + } 170 + 171 + statusTextWidth := len(statusText) * 20 172 + currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 + 174 + // Draw comment count 175 + err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 + if err != nil { 177 + log.Printf("failed to draw comment icon: %v", err) 178 + } 179 + 180 + currentX += iconSize + 15 181 + commentText := fmt.Sprintf("%d comments", commentCount) 182 + if commentCount == 1 { 183 + commentText = "1 comment" 184 + } 185 + err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 186 + if err != nil { 187 + log.Printf("failed to draw comment text: %v", err) 188 + } 189 + 190 + // Draw dolly logo on the right side 191 + dollyBounds := dollyArea.Img.Bounds() 192 + dollySize := 90 193 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 + err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 197 + if err != nil { 198 + log.Printf("dolly silhouette not available (this is ok): %v", err) 199 + } 200 + 201 + // Draw "opened by @author" and date at the bottom with more spacing 202 + labelY := statsY + iconSize + 30 203 + 204 + // Format the opened date 205 + openedDate := issue.Created.Format("Jan 2, 2006") 206 + metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 207 + 208 + err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 209 + if err != nil { 210 + log.Printf("failed to draw metadata: %v", err) 211 + } 212 + 213 + return mainCard, nil 214 + } 215 + 216 + func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 217 + f, err := rp.repoResolver.Resolve(r) 218 + if err != nil { 219 + log.Println("failed to get repo and knot", err) 220 + return 221 + } 222 + 223 + issue, ok := r.Context().Value("issue").(*models.Issue) 224 + if !ok { 225 + log.Println("issue not found in context") 226 + http.Error(w, "issue not found", http.StatusNotFound) 227 + return 228 + } 229 + 230 + // Get comment count 231 + commentCount := len(issue.Comments) 232 + 233 + // Get owner handle for avatar 234 + var ownerHandle string 235 + owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 236 + if err != nil { 237 + ownerHandle = f.Repo.Did 238 + } else { 239 + ownerHandle = "@" + owner.Handle.String() 240 + } 241 + 242 + card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 243 + if err != nil { 244 + log.Println("failed to draw issue summary card", err) 245 + http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) 246 + return 247 + } 248 + 249 + var imageBuffer bytes.Buffer 250 + err = png.Encode(&imageBuffer, card.Img) 251 + if err != nil { 252 + log.Println("failed to encode issue summary card", err) 253 + http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 254 + return 255 + } 256 + 257 + imageBytes := imageBuffer.Bytes() 258 + 259 + w.Header().Set("Content-Type", "image/png") 260 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 261 + w.WriteHeader(http.StatusOK) 262 + _, err = w.Write(imageBytes) 263 + if err != nil { 264 + log.Println("failed to write issue summary card", err) 265 + return 266 + } 267 + }
+1
appview/issues/router.go
··· 16 r.Route("/{issue}", func(r chi.Router) { 17 r.Use(mw.ResolveIssue) 18 r.Get("/", i.RepoSingleIssue) 19 20 // authenticated routes 21 r.Group(func(r chi.Router) {
··· 16 r.Route("/{issue}", func(r chi.Router) { 17 r.Use(mw.ResolveIssue) 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 20 21 // authenticated routes 22 r.Group(func(r chi.Router) {
+6 -6
appview/knots/knots.go
··· 185 return 186 } 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
··· 185 return 186 } 187 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
+10 -12
appview/labels/labels.go
··· 9 "net/http" 10 "time" 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/middleware" ··· 21 "tangled.org/core/appview/oauth" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 - "tangled.org/core/log" 26 "tangled.org/core/rbac" 27 "tangled.org/core/tid" 28 ) 29 30 type Labels struct { ··· 42 db *db.DB, 43 validator *validator.Validator, 44 enforcer *rbac.Enforcer, 45 ) *Labels { 46 - logger := log.New("labels") 47 - 48 return &Labels{ 49 oauth: oauth, 50 pages: pages, ··· 196 return 197 } 198 199 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.LabelOpNSID, 201 Repo: did, 202 Rkey: rkey, ··· 252 // this is used to rollback changes made to the PDS 253 // 254 // it is a no-op if the provided ATURI is empty 255 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 256 if aturi == "" { 257 return nil 258 } ··· 263 repo := parsed.Authority().String() 264 rkey := parsed.RecordKey().String() 265 266 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 267 Collection: collection, 268 Repo: repo, 269 Rkey: rkey,
··· 9 "net/http" 10 "time" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/middleware" ··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 "tangled.org/core/rbac" 20 "tangled.org/core/tid" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + atpclient "github.com/bluesky-social/indigo/atproto/client" 24 + "github.com/bluesky-social/indigo/atproto/syntax" 25 + lexutil "github.com/bluesky-social/indigo/lex/util" 26 + "github.com/go-chi/chi/v5" 27 ) 28 29 type Labels struct { ··· 41 db *db.DB, 42 validator *validator.Validator, 43 enforcer *rbac.Enforcer, 44 + logger *slog.Logger, 45 ) *Labels { 46 return &Labels{ 47 oauth: oauth, 48 pages: pages, ··· 194 return 195 } 196 197 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 198 Collection: tangled.LabelOpNSID, 199 Repo: did, 200 Rkey: rkey, ··· 250 // this is used to rollback changes made to the PDS 251 // 252 // it is a no-op if the provided ATURI is empty 253 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 254 if aturi == "" { 255 return nil 256 } ··· 261 repo := parsed.Authority().String() 262 rkey := parsed.RecordKey().String() 263 264 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 265 Collection: collection, 266 Repo: repo, 267 Rkey: rkey,
+6 -15
appview/middleware/middleware.go
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 - func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 - return func(next http.Handler) http.Handler { 48 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 - _, _, _ = mw.oauth.GetSession(r) 50 - next.ServeHTTP(w, r) 51 - }) 52 - } 53 - } 54 - 55 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 56 return func(next http.Handler) http.Handler { 57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 returnURL := "/" ··· 72 } 73 } 74 75 - _, auth, err := a.GetSession(r) 76 if err != nil { 77 - log.Println("not logged in, redirecting", "err", err) 78 redirectFunc(w, r) 79 return 80 } 81 82 - if !auth { 83 - log.Printf("not logged in, redirecting") 84 redirectFunc(w, r) 85 return 86 } ··· 114 } 115 } 116 117 - ctx := context.WithValue(r.Context(), "page", page) 118 next.ServeHTTP(w, r.WithContext(ctx)) 119 }) 120 }
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 + sess, err := o.ResumeSession(r) 67 if err != nil { 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 redirectFunc(w, r) 70 return 71 } 72 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 redirectFunc(w, r) 76 return 77 } ··· 105 } 106 } 107 108 + ctx := pagination.IntoContext(r.Context(), page) 109 next.ServeHTTP(w, r.WithContext(ctx)) 110 }) 111 }
+24
appview/models/issue.go
··· 54 Replies []*IssueComment 55 } 56 57 func (i *Issue) CommentList() []CommentListItem { 58 // Create a map to quickly find comments by their aturi 59 toplevel := make(map[string]*CommentListItem) ··· 167 168 func (i *IssueComment) IsTopLevel() bool { 169 return i.ReplyTo == nil 170 } 171 172 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
··· 54 Replies []*IssueComment 55 } 56 57 + func (it *CommentListItem) Participants() []syntax.DID { 58 + participantSet := make(map[syntax.DID]struct{}) 59 + participants := []syntax.DID{} 60 + 61 + addParticipant := func(did syntax.DID) { 62 + if _, exists := participantSet[did]; !exists { 63 + participantSet[did] = struct{}{} 64 + participants = append(participants, did) 65 + } 66 + } 67 + 68 + addParticipant(syntax.DID(it.Self.Did)) 69 + 70 + for _, c := range it.Replies { 71 + addParticipant(syntax.DID(c.Did)) 72 + } 73 + 74 + return participants 75 + } 76 + 77 func (i *Issue) CommentList() []CommentListItem { 78 // Create a map to quickly find comments by their aturi 79 toplevel := make(map[string]*CommentListItem) ··· 187 188 func (i *IssueComment) IsTopLevel() bool { 189 return i.ReplyTo == nil 190 + } 191 + 192 + func (i *IssueComment) IsReply() bool { 193 + return i.ReplyTo != nil 194 } 195 196 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+14 -13
appview/models/label.go
··· 461 return result 462 } 463 464 func DefaultLabelDefs() []string { 465 - rkeys := []string{ 466 - "wontfix", 467 - "duplicate", 468 - "assignee", 469 - "good-first-issue", 470 - "documentation", 471 } 472 - 473 - defs := make([]string, len(rkeys)) 474 - for i, r := range rkeys { 475 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 476 - } 477 - 478 - return defs 479 } 480 481 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
··· 461 return result 462 } 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 } 480 } 481 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+43 -1
appview/models/notifications.go
··· 2 3 import ( 4 "time" 5 ) 6 7 type NotificationType string ··· 69 70 type NotificationPreferences struct { 71 ID int64 72 - UserDid string 73 RepoStarred bool 74 IssueCreated bool 75 IssueCommented bool ··· 80 IssueClosed bool 81 EmailNotifications bool 82 }
··· 2 3 import ( 4 "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 ) 8 9 type NotificationType string ··· 71 72 type NotificationPreferences struct { 73 ID int64 74 + UserDid syntax.DID 75 RepoStarred bool 76 IssueCreated bool 77 IssueCommented 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 + }
+30 -23
appview/models/pull.go
··· 84 func (p Pull) AsRecord() tangled.RepoPull { 85 var source *tangled.RepoPull_Source 86 if p.PullSource != nil { 87 - s := p.PullSource.AsRecord() 88 - source = &s 89 source.Sha = p.LatestSha() 90 } 91 92 record := tangled.RepoPull{ ··· 111 Repo *Repo 112 } 113 114 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 115 - var repoAt *string 116 - if p.RepoAt != nil { 117 - s := p.RepoAt.String() 118 - repoAt = &s 119 - } 120 - record := tangled.RepoPull_Source{ 121 - Branch: p.Branch, 122 - Repo: repoAt, 123 - } 124 - return record 125 - } 126 - 127 type PullSubmission struct { 128 // ids 129 ID int ··· 134 // content 135 RoundNumber int 136 Patch string 137 Comments []PullComment 138 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 ··· 159 Created time.Time 160 } 161 162 func (p *Pull) LatestPatch() string { 163 - latestSubmission := p.Submissions[p.LastRoundNumber()] 164 - return latestSubmission.Patch 165 } 166 167 func (p *Pull) LatestSha() string { 168 - latestSubmission := p.Submissions[p.LastRoundNumber()] 169 - return latestSubmission.SourceRev 170 } 171 172 func (p *Pull) PullAt() syntax.ATURI { 173 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 - } 175 - 176 - func (p *Pull) LastRoundNumber() int { 177 - return len(p.Submissions) - 1 178 } 179 180 func (p *Pull) IsPatchBased() bool { ··· 263 return participants 264 } 265 266 type Stack []*Pull 267 268 // position of this pull in the stack ··· 350 351 return mergeable 352 }
··· 84 func (p Pull) AsRecord() tangled.RepoPull { 85 var source *tangled.RepoPull_Source 86 if p.PullSource != nil { 87 + source = &tangled.RepoPull_Source{} 88 + source.Branch = p.PullSource.Branch 89 source.Sha = p.LatestSha() 90 + if p.PullSource.RepoAt != nil { 91 + s := p.PullSource.RepoAt.String() 92 + source.Repo = &s 93 + } 94 } 95 96 record := tangled.RepoPull{ ··· 115 Repo *Repo 116 } 117 118 type PullSubmission struct { 119 // ids 120 ID int ··· 125 // content 126 RoundNumber int 127 Patch string 128 + Combined string 129 Comments []PullComment 130 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 131 ··· 151 Created time.Time 152 } 153 154 + func (p *Pull) LastRoundNumber() int { 155 + return len(p.Submissions) - 1 156 + } 157 + 158 + func (p *Pull) LatestSubmission() *PullSubmission { 159 + return p.Submissions[p.LastRoundNumber()] 160 + } 161 + 162 func (p *Pull) LatestPatch() string { 163 + return p.LatestSubmission().Patch 164 } 165 166 func (p *Pull) LatestSha() string { 167 + return p.LatestSubmission().SourceRev 168 } 169 170 func (p *Pull) PullAt() syntax.ATURI { 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 172 } 173 174 func (p *Pull) IsPatchBased() bool { ··· 257 return participants 258 } 259 260 + func (s PullSubmission) CombinedPatch() string { 261 + if s.Combined == "" { 262 + return s.Patch 263 + } 264 + 265 + return s.Combined 266 + } 267 + 268 type Stack []*Pull 269 270 // position of this pull in the stack ··· 352 353 return mergeable 354 } 355 + 356 + type BranchDeleteStatus struct { 357 + Repo *Repo 358 + Branch string 359 + }
+5
appview/models/reaction.go
··· 55 Rkey string 56 Kind ReactionKind 57 }
··· 55 Rkey string 56 Kind ReactionKind 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+5
appview/models/repo.go
··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 }
··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+36 -39
appview/notifications/notifications.go
··· 1 package notifications 2 3 import ( 4 - "fmt" 5 - "log" 6 "net/http" 7 "strconv" 8 ··· 15 ) 16 17 type Notifications struct { 18 - db *db.DB 19 - oauth *oauth.OAuth 20 - pages *pages.Pages 21 } 22 23 - func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 24 return &Notifications{ 25 - db: database, 26 - oauth: oauthHandler, 27 - pages: pagesHandler, 28 } 29 } 30 31 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 r := chi.NewRouter() 33 34 - r.Use(middleware.AuthMiddleware(n.oauth)) 35 36 - r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 - 38 - r.Get("/count", n.getUnreadCount) 39 - r.Post("/{id}/read", n.markRead) 40 - r.Post("/read-all", n.markAllRead) 41 - r.Delete("/{id}", n.deleteNotification) 42 43 return r 44 } 45 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 - userDid := n.oauth.GetDid(r) 48 49 - page, ok := r.Context().Value("page").(pagination.Page) 50 - if !ok { 51 - log.Println("failed to get page") 52 - page = pagination.FirstPage() 53 - } 54 55 total, err := db.CountNotifications( 56 n.db, 57 - db.FilterEq("recipient_did", userDid), 58 ) 59 if err != nil { 60 - log.Println("failed to get total notifications:", err) 61 n.pages.Error500(w) 62 return 63 } ··· 65 notifications, err := db.GetNotificationsWithEntities( 66 n.db, 67 page, 68 - db.FilterEq("recipient_did", userDid), 69 ) 70 if err != nil { 71 - log.Println("failed to get notifications:", err) 72 n.pages.Error500(w) 73 return 74 } 75 76 - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 77 if err != nil { 78 - log.Println("failed to mark notifications as read:", err) 79 } 80 81 unreadCount := 0 82 83 - user := n.oauth.GetUser(r) 84 - if user == nil { 85 - http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 - return 87 - } 88 - 89 - fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 90 LoggedInUser: user, 91 Notifications: notifications, 92 UnreadCount: unreadCount, 93 Page: page, 94 Total: total, 95 - })) 96 } 97 98 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 user := n.oauth.GetUser(r) 100 count, err := db.CountNotifications( 101 n.db, 102 db.FilterEq("recipient_did", user.Did), ··· 127 return 128 } 129 130 - err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 131 if err != nil { 132 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 133 return ··· 139 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 140 userDid := n.oauth.GetDid(r) 141 142 - err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 143 if err != nil { 144 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 145 return ··· 158 return 159 } 160 161 - err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 162 if err != nil { 163 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 164 return
··· 1 package notifications 2 3 import ( 4 + "log/slog" 5 "net/http" 6 "strconv" 7 ··· 14 ) 15 16 type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + logger *slog.Logger 21 } 22 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 24 return &Notifications{ 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + logger: logger, 29 } 30 } 31 32 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 33 r := chi.NewRouter() 34 35 + r.Get("/count", n.getUnreadCount) 36 37 + r.Group(func(r chi.Router) { 38 + r.Use(middleware.AuthMiddleware(n.oauth)) 39 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 40 + r.Post("/{id}/read", n.markRead) 41 + r.Post("/read-all", n.markAllRead) 42 + r.Delete("/{id}", n.deleteNotification) 43 + }) 44 45 return r 46 } 47 48 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 49 + l := n.logger.With("handler", "notificationsPage") 50 + user := n.oauth.GetUser(r) 51 52 + page := pagination.FromContext(r.Context()) 53 54 total, err := db.CountNotifications( 55 n.db, 56 + db.FilterEq("recipient_did", user.Did), 57 ) 58 if err != nil { 59 + l.Error("failed to get total notifications", "err", err) 60 n.pages.Error500(w) 61 return 62 } ··· 64 notifications, err := db.GetNotificationsWithEntities( 65 n.db, 66 page, 67 + db.FilterEq("recipient_did", user.Did), 68 ) 69 if err != nil { 70 + l.Error("failed to get notifications", "err", err) 71 n.pages.Error500(w) 72 return 73 } 74 75 + err = db.MarkAllNotificationsRead(n.db, user.Did) 76 if err != nil { 77 + l.Error("failed to mark notifications as read", "err", err) 78 } 79 80 unreadCount := 0 81 82 + n.pages.Notifications(w, pages.NotificationsParams{ 83 LoggedInUser: user, 84 Notifications: notifications, 85 UnreadCount: unreadCount, 86 Page: page, 87 Total: total, 88 + }) 89 } 90 91 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 92 user := n.oauth.GetUser(r) 93 + if user == nil { 94 + return 95 + } 96 + 97 count, err := db.CountNotifications( 98 n.db, 99 db.FilterEq("recipient_did", user.Did), ··· 124 return 125 } 126 127 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 128 if err != nil { 129 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 130 return ··· 136 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 137 userDid := n.oauth.GetDid(r) 138 139 + err := db.MarkAllNotificationsRead(n.db, userDid) 140 if err != nil { 141 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 142 return ··· 155 return 156 } 157 158 + err = db.DeleteNotification(n.db, notificationID, userDid) 159 if err != nil { 160 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 161 return
+303 -251
appview/notify/db/db.go
··· 3 import ( 4 "context" 5 "log" 6 7 "tangled.org/core/appview/db" 8 "tangled.org/core/appview/models" 9 "tangled.org/core/appview/notify" ··· 36 return 37 } 38 39 - // don't notify yourself 40 - if repo.Did == star.StarredByDid { 41 - return 42 - } 43 - 44 - // check if user wants these notifications 45 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 - if err != nil { 47 - log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 - return 49 - } 50 - if !prefs.RepoStarred { 51 - return 52 - } 53 54 - notification := &models.Notification{ 55 - RecipientDid: repo.Did, 56 - ActorDid: star.StarredByDid, 57 - Type: models.NotificationTypeRepoStarred, 58 - EntityType: "repo", 59 - EntityId: string(star.RepoAt), 60 - RepoId: &repo.Id, 61 - } 62 - err = n.db.CreateNotification(ctx, notification) 63 - if err != nil { 64 - log.Printf("NewStar: failed to create notification: %v", err) 65 - return 66 - } 67 } 68 69 func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { ··· 71 } 72 73 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 - if err != nil { 76 - log.Printf("NewIssue: failed to get repos: %v", err) 77 - return 78 - } 79 80 - if repo.Did == issue.Did { 81 - return 82 - } 83 - 84 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 if err != nil { 86 - log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 return 88 } 89 - if !prefs.IssueCreated { 90 - return 91 } 92 93 - notification := &models.Notification{ 94 - RecipientDid: repo.Did, 95 - ActorDid: issue.Did, 96 - Type: models.NotificationTypeIssueCreated, 97 - EntityType: "issue", 98 - EntityId: string(issue.AtUri()), 99 - RepoId: &repo.Id, 100 - IssueId: &issue.Id, 101 - } 102 103 - err = n.db.CreateNotification(ctx, notification) 104 - if err != nil { 105 - log.Printf("NewIssue: failed to create notification: %v", err) 106 - return 107 - } 108 } 109 110 func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { ··· 119 } 120 issue := issues[0] 121 122 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 - if err != nil { 124 - log.Printf("NewIssueComment: failed to get repos: %v", err) 125 - return 126 - } 127 128 - recipients := make(map[string]bool) 129 130 - // notify issue author (if not the commenter) 131 - if issue.Did != comment.Did { 132 - prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 - if err == nil && prefs.IssueCommented { 134 - recipients[issue.Did] = true 135 - } else if err != nil { 136 - log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 } 138 } 139 140 - // notify repo owner (if not the commenter and not already added) 141 - if repo.Did != comment.Did && repo.Did != issue.Did { 142 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 - if err == nil && prefs.IssueCommented { 144 - recipients[repo.Did] = true 145 - } else if err != nil { 146 - log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 - } 148 - } 149 150 - // create notifications for all recipients 151 - for recipientDid := range recipients { 152 - notification := &models.Notification{ 153 - RecipientDid: recipientDid, 154 - ActorDid: comment.Did, 155 - Type: models.NotificationTypeIssueCommented, 156 - EntityType: "issue", 157 - EntityId: string(issue.AtUri()), 158 - RepoId: &repo.Id, 159 - IssueId: &issue.Id, 160 - } 161 - 162 - err = n.db.CreateNotification(ctx, notification) 163 - if err != nil { 164 - log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 - } 166 - } 167 } 168 169 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 - prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 - if err != nil { 172 - log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 - return 174 - } 175 - if !prefs.Followed { 176 - return 177 - } 178 - 179 - notification := &models.Notification{ 180 - RecipientDid: follow.SubjectDid, 181 - ActorDid: follow.UserDid, 182 - Type: models.NotificationTypeFollowed, 183 - EntityType: "follow", 184 - EntityId: follow.UserDid, 185 - } 186 187 - err = n.db.CreateNotification(ctx, notification) 188 - if err != nil { 189 - log.Printf("NewFollow: failed to create notification: %v", err) 190 - return 191 - } 192 } 193 194 func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { ··· 202 return 203 } 204 205 - if repo.Did == pull.OwnerDid { 206 - return 207 - } 208 - 209 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 if err != nil { 211 - log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 return 213 } 214 - if !prefs.PullCreated { 215 - return 216 } 217 218 - notification := &models.Notification{ 219 - RecipientDid: repo.Did, 220 - ActorDid: pull.OwnerDid, 221 - Type: models.NotificationTypePullCreated, 222 - EntityType: "pull", 223 - EntityId: string(pull.RepoAt), 224 - RepoId: &repo.Id, 225 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 - } 227 228 - err = n.db.CreateNotification(ctx, notification) 229 - if err != nil { 230 - log.Printf("NewPull: failed to create notification: %v", err) 231 - return 232 - } 233 } 234 235 func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 - pulls, err := db.GetPulls(n.db, 237 - db.FilterEq("repo_at", comment.RepoAt), 238 - db.FilterEq("pull_id", comment.PullId)) 239 if err != nil { 240 log.Printf("NewPullComment: failed to get pulls: %v", err) 241 return 242 } 243 - if len(pulls) == 0 { 244 - log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 - return 246 - } 247 - pull := pulls[0] 248 249 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 if err != nil { ··· 252 return 253 } 254 255 - recipients := make(map[string]bool) 256 - 257 - // notify pull request author (if not the commenter) 258 - if pull.OwnerDid != comment.OwnerDid { 259 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 - if err == nil && prefs.PullCommented { 261 - recipients[pull.OwnerDid] = true 262 - } else if err != nil { 263 - log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 - } 265 } 266 267 - // notify repo owner (if not the commenter and not already added) 268 - if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 - if err == nil && prefs.PullCommented { 271 - recipients[repo.Did] = true 272 - } else if err != nil { 273 - log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 - } 275 - } 276 - 277 - for recipientDid := range recipients { 278 - notification := &models.Notification{ 279 - RecipientDid: recipientDid, 280 - ActorDid: comment.OwnerDid, 281 - Type: models.NotificationTypePullCommented, 282 - EntityType: "pull", 283 - EntityId: comment.RepoAt, 284 - RepoId: &repo.Id, 285 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 - } 287 288 - err = n.db.CreateNotification(ctx, notification) 289 - if err != nil { 290 - log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 - } 292 - } 293 } 294 295 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 309 } 310 311 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 - // Get repo details 313 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 if err != nil { 315 - log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 return 317 } 318 - 319 - // Don't notify yourself 320 - if repo.Did == issue.Did { 321 - return 322 } 323 - 324 - // Check if user wants these notifications 325 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 - if err != nil { 327 - log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 - return 329 - } 330 - if !prefs.IssueClosed { 331 - return 332 } 333 334 - notification := &models.Notification{ 335 - RecipientDid: repo.Did, 336 - ActorDid: issue.Did, 337 - Type: models.NotificationTypeIssueClosed, 338 - EntityType: "issue", 339 - EntityId: string(issue.AtUri()), 340 - RepoId: &repo.Id, 341 - IssueId: &issue.Id, 342 - } 343 344 - err = n.db.CreateNotification(ctx, notification) 345 - if err != nil { 346 - log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 - return 348 - } 349 } 350 351 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { ··· 356 return 357 } 358 359 - // Don't notify yourself 360 - if repo.Did == pull.OwnerDid { 361 - return 362 - } 363 - 364 - // Check if user wants these notifications 365 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 if err != nil { 367 - log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 return 369 } 370 - if !prefs.PullMerged { 371 - return 372 } 373 374 - notification := &models.Notification{ 375 - RecipientDid: pull.OwnerDid, 376 - ActorDid: repo.Did, 377 - Type: models.NotificationTypePullMerged, 378 - EntityType: "pull", 379 - EntityId: string(pull.RepoAt), 380 - RepoId: &repo.Id, 381 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 - } 383 384 - err = n.db.CreateNotification(ctx, notification) 385 - if err != nil { 386 - log.Printf("NewPullMerged: failed to create notification: %v", err) 387 - return 388 - } 389 } 390 391 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 // Get repo details 393 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 if err != nil { 395 - log.Printf("NewPullClosed: failed to get repos: %v", err) 396 return 397 } 398 399 - // Don't notify yourself 400 - if repo.Did == pull.OwnerDid { 401 return 402 } 403 404 - // Check if user wants these notifications - reuse pull_merged preference for now 405 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 if err != nil { 407 - log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 return 409 } 410 - if !prefs.PullMerged { 411 return 412 } 413 414 - notification := &models.Notification{ 415 - RecipientDid: pull.OwnerDid, 416 - ActorDid: repo.Did, 417 - Type: models.NotificationTypePullClosed, 418 - EntityType: "pull", 419 - EntityId: string(pull.RepoAt), 420 - RepoId: &repo.Id, 421 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 } 423 424 - err = n.db.CreateNotification(ctx, notification) 425 - if err != nil { 426 - log.Printf("NewPullClosed: failed to create notification: %v", err) 427 return 428 } 429 }
··· 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" ··· 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) { ··· 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) { ··· 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) { ··· 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 { ··· 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) { ··· 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) { ··· 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 }
+42 -50
appview/notify/merged_notifier.go
··· 2 3 import ( 4 "context" 5 6 "tangled.org/core/appview/models" 7 ) ··· 16 17 var _ Notifier = &mergedNotifier{} 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 22 } 23 } 24 25 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 29 } 30 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 34 } 35 36 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 40 } 41 func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 - for _, notifier := range m.notifiers { 43 - notifier.NewIssueComment(ctx, comment) 44 - } 45 } 46 47 func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 - for _, notifier := range m.notifiers { 49 - notifier.NewIssueClosed(ctx, issue) 50 - } 51 } 52 53 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewFollow(ctx, follow) 56 - } 57 } 58 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 59 - for _, notifier := range m.notifiers { 60 - notifier.DeleteFollow(ctx, follow) 61 - } 62 } 63 64 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 65 - for _, notifier := range m.notifiers { 66 - notifier.NewPull(ctx, pull) 67 - } 68 } 69 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 - for _, notifier := range m.notifiers { 71 - notifier.NewPullComment(ctx, comment) 72 - } 73 } 74 75 func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 - for _, notifier := range m.notifiers { 77 - notifier.NewPullMerged(ctx, pull) 78 - } 79 } 80 81 func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 - for _, notifier := range m.notifiers { 83 - notifier.NewPullClosed(ctx, pull) 84 - } 85 } 86 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 - for _, notifier := range m.notifiers { 89 - notifier.UpdateProfile(ctx, profile) 90 - } 91 } 92 93 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 - for _, notifier := range m.notifiers { 95 - notifier.NewString(ctx, string) 96 - } 97 } 98 99 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 - for _, notifier := range m.notifiers { 101 - notifier.EditString(ctx, string) 102 - } 103 } 104 105 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 - for _, notifier := range m.notifiers { 107 - notifier.DeleteString(ctx, did, rkey) 108 - } 109 }
··· 2 3 import ( 4 "context" 5 + "reflect" 6 + "sync" 7 8 "tangled.org/core/appview/models" 9 ) ··· 18 19 var _ Notifier = &mergedNotifier{} 20 21 + // fanout calls the same method on all notifiers concurrently 22 + func (m *mergedNotifier) fanout(method string, args ...any) { 23 + var wg sync.WaitGroup 24 + for _, n := range m.notifiers { 25 + wg.Add(1) 26 + go func(notifier Notifier) { 27 + defer wg.Done() 28 + v := reflect.ValueOf(notifier).MethodByName(method) 29 + in := make([]reflect.Value, len(args)) 30 + for i, arg := range args { 31 + in[i] = reflect.ValueOf(arg) 32 + } 33 + v.Call(in) 34 + }(n) 35 } 36 + wg.Wait() 37 + } 38 + 39 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 40 + m.fanout("NewRepo", ctx, repo) 41 } 42 43 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 44 + m.fanout("NewStar", ctx, star) 45 } 46 + 47 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 48 + m.fanout("DeleteStar", ctx, star) 49 } 50 51 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 52 + m.fanout("NewIssue", ctx, issue) 53 } 54 + 55 func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 56 + m.fanout("NewIssueComment", ctx, comment) 57 } 58 59 func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 60 + m.fanout("NewIssueClosed", ctx, issue) 61 } 62 63 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 64 + m.fanout("NewFollow", ctx, follow) 65 } 66 + 67 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 68 + m.fanout("DeleteFollow", ctx, follow) 69 } 70 71 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 72 + m.fanout("NewPull", ctx, pull) 73 } 74 + 75 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 76 + m.fanout("NewPullComment", ctx, comment) 77 } 78 79 func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 80 + m.fanout("NewPullMerged", ctx, pull) 81 } 82 83 func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 84 + m.fanout("NewPullClosed", ctx, pull) 85 } 86 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 + m.fanout("UpdateProfile", ctx, profile) 89 } 90 91 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 92 + m.fanout("NewString", ctx, s) 93 } 94 95 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 96 + m.fanout("EditString", ctx, s) 97 } 98 99 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 100 + m.fanout("DeleteString", ctx, did, rkey) 101 }
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
···
+2 -1
appview/oauth/consts.go
··· 1 package oauth 2 3 const ( 4 - SessionName = "appview-session" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionPds = "pds" 8 SessionAccessJwt = "accessJwt" 9 SessionRefreshJwt = "refreshJwt"
··· 1 package oauth 2 3 const ( 4 + SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 + SessionId = "id" 8 SessionPds = "pds" 9 SessionAccessJwt = "accessJwt" 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/consts" 28 - "tangled.org/core/idresolver" 29 - "tangled.org/core/rbac" 30 - "tangled.org/core/tid" 31 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
···
+286
appview/oauth/handler.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "net/http" 10 + "slices" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/go-chi/chi/v5" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 + "github.com/posthog/posthog-go" 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/consts" 20 + "tangled.org/core/tid" 21 + ) 22 + 23 + func (o *OAuth) Router() http.Handler { 24 + r := chi.NewRouter() 25 + 26 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 + r.Get("/oauth/jwks.json", o.jwks) 28 + r.Get("/oauth/callback", o.callback) 29 + return r 30 + } 31 + 32 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 33 + doc := o.ClientApp.Config.ClientMetadata() 34 + doc.JWKSURI = &o.JwksUri 35 + 36 + w.Header().Set("Content-Type", "application/json") 37 + if err := json.NewEncoder(w).Encode(doc); err != nil { 38 + http.Error(w, err.Error(), http.StatusInternalServerError) 39 + return 40 + } 41 + } 42 + 43 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 44 + jwks := o.Config.OAuth.Jwks 45 + pubKey, err := pubKeyFromJwk(jwks) 46 + if err != nil { 47 + o.Logger.Error("error parsing public key", "err", err) 48 + http.Error(w, err.Error(), http.StatusInternalServerError) 49 + return 50 + } 51 + 52 + response := map[string]any{ 53 + "keys": []jwk.Key{pubKey}, 54 + } 55 + 56 + w.Header().Set("Content-Type", "application/json") 57 + w.WriteHeader(http.StatusOK) 58 + json.NewEncoder(w).Encode(response) 59 + } 60 + 61 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 62 + ctx := r.Context() 63 + l := o.Logger.With("query", r.URL.Query()) 64 + 65 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 66 + if err != nil { 67 + var callbackErr *oauth.AuthRequestCallbackError 68 + if errors.As(err, &callbackErr) { 69 + l.Debug("callback error", "err", callbackErr) 70 + http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 71 + return 72 + } 73 + l.Error("failed to process callback", "err", err) 74 + http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 75 + return 76 + } 77 + 78 + if err := o.SaveSession(w, r, sessData); err != nil { 79 + l.Error("failed to save session", "data", sessData, "err", err) 80 + http.Redirect(w, r, "/login?error=session", http.StatusFound) 81 + return 82 + } 83 + 84 + o.Logger.Debug("session saved successfully") 85 + go o.addToDefaultKnot(sessData.AccountDID.String()) 86 + go o.addToDefaultSpindle(sessData.AccountDID.String()) 87 + 88 + if !o.Config.Core.Dev { 89 + err = o.Posthog.Enqueue(posthog.Capture{ 90 + DistinctId: sessData.AccountDID.String(), 91 + Event: "signin", 92 + }) 93 + if err != nil { 94 + o.Logger.Error("failed to enqueue posthog event", "err", err) 95 + } 96 + } 97 + 98 + http.Redirect(w, r, "/", http.StatusFound) 99 + } 100 + 101 + func (o *OAuth) addToDefaultSpindle(did string) { 102 + l := o.Logger.With("subject", did) 103 + 104 + // use the tangled.sh app password to get an accessJwt 105 + // and create an sh.tangled.spindle.member record with that 106 + spindleMembers, err := db.GetSpindleMembers( 107 + o.Db, 108 + db.FilterEq("instance", "spindle.tangled.sh"), 109 + db.FilterEq("subject", did), 110 + ) 111 + if err != nil { 112 + l.Error("failed to get spindle members", "err", err) 113 + return 114 + } 115 + 116 + if len(spindleMembers) != 0 { 117 + l.Warn("already a member of the default spindle") 118 + return 119 + } 120 + 121 + l.Debug("adding to default spindle") 122 + session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 123 + if err != nil { 124 + l.Error("failed to create session", "err", err) 125 + return 126 + } 127 + 128 + record := tangled.SpindleMember{ 129 + LexiconTypeID: "sh.tangled.spindle.member", 130 + Subject: did, 131 + Instance: consts.DefaultSpindle, 132 + CreatedAt: time.Now().Format(time.RFC3339), 133 + } 134 + 135 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 136 + l.Error("failed to add to default spindle", "err", err) 137 + return 138 + } 139 + 140 + l.Debug("successfully added to default spindle", "did", did) 141 + } 142 + 143 + func (o *OAuth) addToDefaultKnot(did string) { 144 + l := o.Logger.With("subject", did) 145 + 146 + // use the tangled.sh app password to get an accessJwt 147 + // and create an sh.tangled.spindle.member record with that 148 + 149 + allKnots, err := o.Enforcer.GetKnotsForUser(did) 150 + if err != nil { 151 + l.Error("failed to get knot members for did", "err", err) 152 + return 153 + } 154 + 155 + if slices.Contains(allKnots, consts.DefaultKnot) { 156 + l.Warn("already a member of the default knot") 157 + return 158 + } 159 + 160 + l.Debug("addings to default knot") 161 + session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 162 + if err != nil { 163 + l.Error("failed to create session", "err", err) 164 + return 165 + } 166 + 167 + record := tangled.KnotMember{ 168 + LexiconTypeID: "sh.tangled.knot.member", 169 + Subject: did, 170 + Domain: consts.DefaultKnot, 171 + CreatedAt: time.Now().Format(time.RFC3339), 172 + } 173 + 174 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 175 + l.Error("failed to add to default knot", "err", err) 176 + return 177 + } 178 + 179 + if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 180 + l.Error("failed to set up enforcer rules", "err", err) 181 + return 182 + } 183 + 184 + l.Debug("successfully addeds to default Knot") 185 + } 186 + 187 + // create a session using apppasswords 188 + type session struct { 189 + AccessJwt string `json:"accessJwt"` 190 + PdsEndpoint string 191 + Did string 192 + } 193 + 194 + func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 195 + if appPassword == "" { 196 + return nil, fmt.Errorf("no app password configured, skipping member addition") 197 + } 198 + 199 + resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 200 + if err != nil { 201 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 202 + } 203 + 204 + pdsEndpoint := resolved.PDSEndpoint() 205 + if pdsEndpoint == "" { 206 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 207 + } 208 + 209 + sessionPayload := map[string]string{ 210 + "identifier": did, 211 + "password": appPassword, 212 + } 213 + sessionBytes, err := json.Marshal(sessionPayload) 214 + if err != nil { 215 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 216 + } 217 + 218 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 219 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 220 + if err != nil { 221 + return nil, fmt.Errorf("failed to create session request: %v", err) 222 + } 223 + sessionReq.Header.Set("Content-Type", "application/json") 224 + 225 + client := &http.Client{Timeout: 30 * time.Second} 226 + sessionResp, err := client.Do(sessionReq) 227 + if err != nil { 228 + return nil, fmt.Errorf("failed to create session: %v", err) 229 + } 230 + defer sessionResp.Body.Close() 231 + 232 + if sessionResp.StatusCode != http.StatusOK { 233 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 234 + } 235 + 236 + var session session 237 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 238 + return nil, fmt.Errorf("failed to decode session response: %v", err) 239 + } 240 + 241 + session.PdsEndpoint = pdsEndpoint 242 + session.Did = did 243 + 244 + return &session, nil 245 + } 246 + 247 + func (s *session) putRecord(record any, collection string) error { 248 + recordBytes, err := json.Marshal(record) 249 + if err != nil { 250 + return fmt.Errorf("failed to marshal knot member record: %w", err) 251 + } 252 + 253 + payload := map[string]any{ 254 + "repo": s.Did, 255 + "collection": collection, 256 + "rkey": tid.TID(), 257 + "record": json.RawMessage(recordBytes), 258 + } 259 + 260 + payloadBytes, err := json.Marshal(payload) 261 + if err != nil { 262 + return fmt.Errorf("failed to marshal request payload: %w", err) 263 + } 264 + 265 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 266 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 267 + if err != nil { 268 + return fmt.Errorf("failed to create HTTP request: %w", err) 269 + } 270 + 271 + req.Header.Set("Content-Type", "application/json") 272 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 273 + 274 + client := &http.Client{Timeout: 30 * time.Second} 275 + resp, err := client.Do(req) 276 + if err != nil { 277 + return fmt.Errorf("failed to add user to default service: %w", err) 278 + } 279 + defer resp.Body.Close() 280 + 281 + if resp.StatusCode != http.StatusOK { 282 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 283 + } 284 + 285 + return nil 286 + }
+124 -201
appview/oauth/oauth.go
··· 1 package oauth 2 3 import ( 4 "fmt" 5 - "log" 6 "net/http" 7 - "net/url" 8 "time" 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 13 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 ) 19 20 type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 } 25 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 31 } 32 - } 33 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 36 } 37 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 39 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 41 if err != nil { 42 return err 43 } 44 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 48 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 50 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 52 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 65 } 66 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 74 } 75 76 - did := userSession.Values[SessionDid].(string) 77 78 - err = o.sess.DeleteSession(r.Context(), did) 79 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 81 } 82 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 86 } 87 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 92 - } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 100 } 101 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 103 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 - self := o.ClientMetadata() 113 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 119 120 - if err != nil { 121 - return nil, false, err 122 - } 123 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 128 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 140 } 141 - 142 - return session, auth, nil 143 } 144 145 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 149 } 150 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 153 154 - if err != nil || clientSession.IsNew { 155 return nil 156 } 157 158 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 162 } 163 } 164 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 170 } 171 172 - return clientSession.Values[SessionDid].(string) 173 } 174 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 177 if err != nil { 178 return nil, fmt.Errorf("error getting session: %w", err) 179 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 208 } 209 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 // this is a higher level abstraction on ServerGetServiceAuth 213 type ServiceClientOpts struct { 214 service string ··· 259 return scheme + s.service 260 } 261 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 opts := ServiceClientOpts{} 264 for _, o := range os { 265 o(&opts) 266 } 267 268 - authorizedClient, err := o.AuthorizedClient(r) 269 if err != nil { 270 return nil, err 271 } ··· 276 opts.exp = sixty 277 } 278 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 if err != nil { 281 return nil, err 282 } 283 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), ··· 291 }, 292 }, nil 293 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
··· 1 package oauth 2 3 import ( 4 + "errors" 5 "fmt" 6 + "log/slog" 7 "net/http" 8 "time" 9 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + xrpc "github.com/bluesky-social/indigo/xrpc" 15 "github.com/gorilla/sessions" 16 + "github.com/lestrrat-go/jwx/v2/jwk" 17 + "github.com/posthog/posthog-go" 18 "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 22 ) 23 24 type OAuth struct { 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + Posthog posthog.Client 30 + Db *db.DB 31 + Enforcer *rbac.Enforcer 32 + IdResolver *idresolver.Resolver 33 + Logger *slog.Logger 34 } 35 36 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 37 + 38 + var oauthConfig oauth.ClientConfig 39 + var clientUri string 40 + 41 + if config.Core.Dev { 42 + clientUri = "http://127.0.0.1:3000" 43 + callbackUri := clientUri + "/oauth/callback" 44 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 45 + } else { 46 + clientUri = config.Core.AppviewHost 47 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 48 + callbackUri := clientUri + "/oauth/callback" 49 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 50 } 51 + 52 + jwksUri := clientUri + "/oauth/jwks.json" 53 + 54 + authStore, err := NewRedisStore(config.Redis.ToURL()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 60 + 61 + clientApp := oauth.NewClientApp(&oauthConfig, authStore) 62 + clientApp.Dir = res.Directory() 63 64 + return &OAuth{ 65 + ClientApp: clientApp, 66 + Config: config, 67 + SessStore: sessStore, 68 + JwksUri: jwksUri, 69 + Posthog: ph, 70 + Db: db, 71 + Enforcer: enforcer, 72 + IdResolver: res, 73 + Logger: logger, 74 + }, nil 75 } 76 77 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 78 // first we save the did in the user session 79 + userSession, err := o.SessStore.Get(r, SessionName) 80 if err != nil { 81 return err 82 } 83 84 + userSession.Values[SessionDid] = sessData.AccountDID.String() 85 + userSession.Values[SessionPds] = sessData.HostURL 86 + userSession.Values[SessionId] = sessData.SessionID 87 userSession.Values[SessionAuthenticated] = true 88 + return userSession.Save(r, w) 89 + } 90 + 91 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 92 + userSession, err := o.SessStore.Get(r, SessionName) 93 if err != nil { 94 + return nil, fmt.Errorf("error getting user session: %w", err) 95 } 96 + if userSession.IsNew { 97 + return nil, fmt.Errorf("no session available for user") 98 } 99 100 + d := userSession.Values[SessionDid].(string) 101 + sessDid, err := syntax.ParseDID(d) 102 + if err != nil { 103 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 104 } 105 106 + sessId := userSession.Values[SessionId].(string) 107 108 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 109 if err != nil { 110 + return nil, fmt.Errorf("failed to resume session: %w", err) 111 } 112 113 + return clientSess, nil 114 } 115 116 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 117 + userSession, err := o.SessStore.Get(r, SessionName) 118 if err != nil { 119 + return fmt.Errorf("error getting user session: %w", err) 120 + } 121 + if userSession.IsNew { 122 + return fmt.Errorf("no session available for user") 123 } 124 125 + d := userSession.Values[SessionDid].(string) 126 + sessDid, err := syntax.ParseDID(d) 127 if err != nil { 128 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 129 } 130 131 + sessId := userSession.Values[SessionId].(string) 132 133 + // delete the session 134 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 135 136 + // remove the cookie 137 + userSession.Options.MaxAge = -1 138 + err2 := o.SessStore.Save(r, w, userSession) 139 140 + return errors.Join(err1, err2) 141 + } 142 143 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 144 + k, err := jwk.ParseKey([]byte(jwks)) 145 + if err != nil { 146 + return nil, err 147 + } 148 + pubKey, err := k.PublicKey() 149 + if err != nil { 150 + return nil, err 151 } 152 + return pubKey, nil 153 } 154 155 type User struct { 156 + Did string 157 + Pds string 158 } 159 160 + func (o *OAuth) GetUser(r *http.Request) *User { 161 + sess, err := o.SessStore.Get(r, SessionName) 162 163 + if err != nil || sess.IsNew { 164 return nil 165 } 166 167 return &User{ 168 + Did: sess.Values[SessionDid].(string), 169 + Pds: sess.Values[SessionPds].(string), 170 } 171 } 172 173 + func (o *OAuth) GetDid(r *http.Request) string { 174 + if u := o.GetUser(r); u != nil { 175 + return u.Did 176 } 177 178 + return "" 179 } 180 181 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 182 + session, err := o.ResumeSession(r) 183 if err != nil { 184 return nil, fmt.Errorf("error getting session: %w", err) 185 } 186 + return session.APIClient(), nil 187 } 188 189 // this is a higher level abstraction on ServerGetServiceAuth 190 type ServiceClientOpts struct { 191 service string ··· 236 return scheme + s.service 237 } 238 239 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 240 opts := ServiceClientOpts{} 241 for _, o := range os { 242 o(&opts) 243 } 244 245 + client, err := o.AuthorizedClient(r) 246 if err != nil { 247 return nil, err 248 } ··· 253 opts.exp = sixty 254 } 255 256 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 257 if err != nil { 258 return nil, err 259 } 260 261 + return &xrpc.Client{ 262 + Auth: &xrpc.AuthInfo{ 263 AccessJwt: resp.Token, 264 }, 265 Host: opts.Host(), ··· 268 }, 269 }, nil 270 }
+147
appview/oauth/store.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+535
appview/ogcard/card.go
···
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 630 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + 398 + body := resp.Body 399 + bodyBytes, err := io.ReadAll(body) 400 + if err != nil { 401 + log.Printf("error when fetching external image from %s: %v", url, err) 402 + return nil, false 403 + } 404 + 405 + // Handle SVG separately 406 + if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 407 + return c.convertSVGToPNG(bodyBytes) 408 + } 409 + 410 + // Support content types are in-sync with the allowed custom avatar file types 411 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 412 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 413 + return nil, false 414 + } 415 + 416 + bodyBuffer := bytes.NewReader(bodyBytes) 417 + _, imgType, err := image.DecodeConfig(bodyBuffer) 418 + if err != nil { 419 + log.Printf("error when decoding external image from %s: %v", url, err) 420 + return nil, false 421 + } 422 + 423 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 424 + if (contentType == "image/png" && imgType != "png") || 425 + (contentType == "image/jpeg" && imgType != "jpeg") || 426 + (contentType == "image/gif" && imgType != "gif") || 427 + (contentType == "image/webp" && imgType != "webp") { 428 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 429 + return nil, false 430 + } 431 + 432 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 433 + if err != nil { 434 + log.Printf("error w/ bodyBuffer.Seek") 435 + return nil, false 436 + } 437 + img, _, err := image.Decode(bodyBuffer) 438 + if err != nil { 439 + log.Printf("error when decoding external image from %s: %v", url, err) 440 + return nil, false 441 + } 442 + 443 + return img, true 444 + } 445 + 446 + // convertSVGToPNG converts SVG data to a PNG image 447 + func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 448 + // Parse the SVG 449 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 450 + if err != nil { 451 + log.Printf("error parsing SVG: %v", err) 452 + return nil, false 453 + } 454 + 455 + // Set a reasonable size for the rasterized image 456 + width := 256 457 + height := 256 458 + icon.SetTarget(0, 0, float64(width), float64(height)) 459 + 460 + // Create an image to draw on 461 + rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 462 + 463 + // Fill with white background 464 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 465 + 466 + // Create a scanner and rasterize the SVG 467 + scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 468 + raster := rasterx.NewDasher(width, height, scanner) 469 + 470 + icon.Draw(raster, 1.0) 471 + 472 + return rgba, true 473 + } 474 + 475 + func (c *Card) DrawExternalImage(url string) { 476 + image, ok := c.fetchExternalImage(url) 477 + if !ok { 478 + image = fallbackImage() 479 + } 480 + c.DrawImage(image) 481 + } 482 + 483 + // DrawCircularExternalImage draws an external image as a circle at the specified position 484 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 485 + img, ok := c.fetchExternalImage(url) 486 + if !ok { 487 + img = fallbackImage() 488 + } 489 + 490 + // Create a circular mask 491 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 492 + center := size / 2 493 + radius := float64(size / 2) 494 + 495 + // Scale the source image to fit the circle 496 + srcBounds := img.Bounds() 497 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 498 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 499 + 500 + // Draw the image with circular clipping 501 + for cy := 0; cy < size; cy++ { 502 + for cx := 0; cx < size; cx++ { 503 + // Calculate distance from center 504 + dx := float64(cx - center) 505 + dy := float64(cy - center) 506 + distance := math.Sqrt(dx*dx + dy*dy) 507 + 508 + // Only draw pixels within the circle 509 + if distance <= radius { 510 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 511 + } 512 + } 513 + } 514 + 515 + // Draw the circle onto the card 516 + bounds := c.Img.Bounds() 517 + destRect := image.Rect(x, y, x+size, y+size) 518 + 519 + // Make sure we don't draw outside the card bounds 520 + if destRect.Max.X > bounds.Max.X { 521 + destRect.Max.X = bounds.Max.X 522 + } 523 + if destRect.Max.Y > bounds.Max.Y { 524 + destRect.Max.Y = bounds.Max.Y 525 + } 526 + 527 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 528 + 529 + return nil 530 + } 531 + 532 + // DrawRect draws a rect with the given color 533 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 534 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 535 + }
+10 -9
appview/pages/funcmap.go
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 - "cssContentHash": CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 297 }, 298 299 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 302 }, 303 "sshFingerprint": func(pubKey string) string { 304 fp, err := crypto.SSHFingerprint(pubKey) ··· 310 } 311 } 312 313 - func (p *Pages) avatarUri(handle, size string) string { 314 handle = strings.TrimPrefix(handle, "@") 315 316 secret := p.avatar.SharedSecret ··· 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 } 327 328 - func icon(name string, classes []string) (template.HTML, error) { 329 iconPath := filepath.Join("static", "icons", name) 330 331 if filepath.Ext(name) == "" {
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 + data, err := p.icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 + data, _ = p.icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 + "cssContentHash": p.CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 + return p.AvatarUrl(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 + return p.AvatarUrl(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 297 }, 298 299 "normalizeForHtmlId": func(s string) string { 300 + normalized := strings.ReplaceAll(s, ":", "_") 301 + normalized = strings.ReplaceAll(normalized, ".", "_") 302 + return normalized 303 }, 304 "sshFingerprint": func(pubKey string) string { 305 fp, err := crypto.SSHFingerprint(pubKey) ··· 311 } 312 } 313 314 + func (p *Pages) AvatarUrl(handle, size string) string { 315 handle = strings.TrimPrefix(handle, "@") 316 317 secret := p.avatar.SharedSecret ··· 326 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 327 } 328 329 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 330 iconPath := filepath.Join("static", "icons", name) 331 332 if filepath.Ext(name) == "" {
+5 -2
appview/pages/funcmap_test.go
··· 2 3 import ( 4 "html/template" 5 "tangled.org/core/appview/config" 6 "tangled.org/core/idresolver" 7 - "testing" 8 ) 9 10 func TestPages_funcMap(t *testing.T) { ··· 13 // Named input parameters for receiver constructor. 14 config *config.Config 15 res *idresolver.Resolver 16 want template.FuncMap 17 }{ 18 // TODO: Add test cases. 19 } 20 for _, tt := range tests { 21 t.Run(tt.name, func(t *testing.T) { 22 - p := NewPages(tt.config, tt.res) 23 got := p.funcMap() 24 // TODO: update the condition below to compare got with tt.want. 25 if true {
··· 2 3 import ( 4 "html/template" 5 + "log/slog" 6 + "testing" 7 + 8 "tangled.org/core/appview/config" 9 "tangled.org/core/idresolver" 10 ) 11 12 func TestPages_funcMap(t *testing.T) { ··· 15 // Named input parameters for receiver constructor. 16 config *config.Config 17 res *idresolver.Resolver 18 + l *slog.Logger 19 want template.FuncMap 20 }{ 21 // TODO: Add test cases. 22 } 23 for _, tt := range tests { 24 t.Run(tt.name, func(t *testing.T) { 25 + p := NewPages(tt.config, tt.res, tt.l) 26 got := p.funcMap() 27 // TODO: update the condition below to compare got with tt.want. 28 if true {
+6 -1
appview/pages/markup/markdown.go
··· 5 "bytes" 6 "fmt" 7 "io" 8 "net/url" 9 "path" 10 "strings" ··· 20 "github.com/yuin/goldmark/renderer/html" 21 "github.com/yuin/goldmark/text" 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 "tangled.org/core/api/tangled" ··· 45 IsDev bool 46 RendererType RendererType 47 Sanitizer Sanitizer 48 } 49 50 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 treeblood.MathML(), 65 ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), ··· 140 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 switch node.Type { 142 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 144 for i, attr := range node.Attr { 145 if attr.Key != "src" { 146 continue
··· 5 "bytes" 6 "fmt" 7 "io" 8 + "io/fs" 9 "net/url" 10 "path" 11 "strings" ··· 21 "github.com/yuin/goldmark/renderer/html" 22 "github.com/yuin/goldmark/text" 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" ··· 47 IsDev bool 48 RendererType RendererType 49 Sanitizer Sanitizer 50 + Files fs.FS 51 } 52 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 66 ), 67 treeblood.MathML(), 68 + callout.CalloutExtention, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(), ··· 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 145 switch node.Type { 146 case htmlparse.ElementNode: 147 + switch node.Data { 148 + case "img", "source": 149 for i, attr := range node.Attr { 150 if attr.Key != "src" { 151 continue
+3
appview/pages/markup/sanitizer.go
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 return policy 118 } 119
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 120 return policy 121 } 122
+42 -23
appview/pages/pages.go
··· 54 logger *slog.Logger 55 } 56 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 } 65 66 p := &Pages{ ··· 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 - logger: slog.Default().With("component", "pages"), 75 } 76 77 if p.dev { ··· 220 221 type LoginParams struct { 222 ReturnUrl string 223 } 224 225 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 306 LoggedInUser *oauth.User 307 Timeline []models.TimelineEvent 308 Repos []models.Repo 309 } 310 311 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 312 return p.execute("timeline/timeline", w, params) 313 } 314 315 type UserProfileSettingsParams struct { ··· 971 LabelDefs map[string]*models.LabelDefinition 972 973 OrderedReactionKinds []models.ReactionKind 974 - Reactions map[models.ReactionKind]int 975 UserReacted map[models.ReactionKind]bool 976 } 977 ··· 996 ThreadAt syntax.ATURI 997 Kind models.ReactionKind 998 Count int 999 IsReacted bool 1000 } 1001 ··· 1113 } 1114 1115 type RepoSinglePullParams struct { 1116 - LoggedInUser *oauth.User 1117 - RepoInfo repoinfo.RepoInfo 1118 - Active string 1119 - Pull *models.Pull 1120 - Stack models.Stack 1121 - AbandonedPulls []*models.Pull 1122 - MergeCheck types.MergeCheckResponse 1123 - ResubmitCheck ResubmitResult 1124 - Pipelines map[string]models.Pipeline 1125 1126 OrderedReactionKinds []models.ReactionKind 1127 - Reactions map[models.ReactionKind]int 1128 UserReacted map[models.ReactionKind]bool 1129 1130 LabelDefs map[string]*models.LabelDefinition ··· 1217 } 1218 1219 type PullActionsParams struct { 1220 - LoggedInUser *oauth.User 1221 - RepoInfo repoinfo.RepoInfo 1222 - Pull *models.Pull 1223 - RoundNumber int 1224 - MergeCheck types.MergeCheckResponse 1225 - ResubmitCheck ResubmitResult 1226 - Stack models.Stack 1227 } 1228 1229 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1460 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1461 } 1462 1463 - sub, err := fs.Sub(Files, "static") 1464 if err != nil { 1465 p.logger.Error("no static dir found? that's crazy", "err", err) 1466 panic(err) ··· 1483 }) 1484 } 1485 1486 - func CssContentHash() string { 1487 - cssFile, err := Files.Open("static/tw.css") 1488 if err != nil { 1489 slog.Debug("Error opening CSS file", "err", err) 1490 return ""
··· 54 logger *slog.Logger 55 } 56 57 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 65 } 66 67 p := &Pages{ ··· 72 rctx: rctx, 73 resolver: res, 74 templateDir: "appview/pages", 75 + logger: logger, 76 } 77 78 if p.dev { ··· 221 222 type LoginParams struct { 223 ReturnUrl string 224 + ErrorCode string 225 } 226 227 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 308 LoggedInUser *oauth.User 309 Timeline []models.TimelineEvent 310 Repos []models.Repo 311 + GfiLabel *models.LabelDefinition 312 } 313 314 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 315 return p.execute("timeline/timeline", w, params) 316 + } 317 + 318 + type GoodFirstIssuesParams struct { 319 + LoggedInUser *oauth.User 320 + Issues []models.Issue 321 + RepoGroups []*models.RepoGroup 322 + LabelDefs map[string]*models.LabelDefinition 323 + GfiLabel *models.LabelDefinition 324 + Page pagination.Page 325 + } 326 + 327 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 328 + return p.execute("goodfirstissues/index", w, params) 329 } 330 331 type UserProfileSettingsParams struct { ··· 987 LabelDefs map[string]*models.LabelDefinition 988 989 OrderedReactionKinds []models.ReactionKind 990 + Reactions map[models.ReactionKind]models.ReactionDisplayData 991 UserReacted map[models.ReactionKind]bool 992 } 993 ··· 1012 ThreadAt syntax.ATURI 1013 Kind models.ReactionKind 1014 Count int 1015 + Users []string 1016 IsReacted bool 1017 } 1018 ··· 1130 } 1131 1132 type RepoSinglePullParams struct { 1133 + LoggedInUser *oauth.User 1134 + RepoInfo repoinfo.RepoInfo 1135 + Active string 1136 + Pull *models.Pull 1137 + Stack models.Stack 1138 + AbandonedPulls []*models.Pull 1139 + BranchDeleteStatus *models.BranchDeleteStatus 1140 + MergeCheck types.MergeCheckResponse 1141 + ResubmitCheck ResubmitResult 1142 + Pipelines map[string]models.Pipeline 1143 1144 OrderedReactionKinds []models.ReactionKind 1145 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1146 UserReacted map[models.ReactionKind]bool 1147 1148 LabelDefs map[string]*models.LabelDefinition ··· 1235 } 1236 1237 type PullActionsParams struct { 1238 + LoggedInUser *oauth.User 1239 + RepoInfo repoinfo.RepoInfo 1240 + Pull *models.Pull 1241 + RoundNumber int 1242 + MergeCheck types.MergeCheckResponse 1243 + ResubmitCheck ResubmitResult 1244 + BranchDeleteStatus *models.BranchDeleteStatus 1245 + Stack models.Stack 1246 } 1247 1248 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1479 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1480 } 1481 1482 + sub, err := fs.Sub(p.embedFS, "static") 1483 if err != nil { 1484 p.logger.Error("no static dir found? that's crazy", "err", err) 1485 panic(err) ··· 1502 }) 1503 } 1504 1505 + func (p *Pages) CssContentHash() string { 1506 + cssFile, err := p.embedFS.Open("static/tw.css") 1507 if err != nil { 1508 slog.Debug("Error opening CSS file", "err", err) 1509 return ""
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
··· 1 + <svg 2 + version="1.1" 3 + id="svg1" 4 + width="32" 5 + height="32" 6 + viewBox="0 0 25 25" 7 + sodipodi:docname="tangled_dolly_silhouette.png" 8 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 + xmlns="http://www.w3.org/2000/svg" 11 + xmlns:svg="http://www.w3.org/2000/svg"> 12 + <title>Dolly</title> 13 + <defs 14 + id="defs1" /> 15 + <sodipodi:namedview 16 + id="namedview1" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="true" 23 + inkscape:deskcolor="#d1d1d1"> 24 + <inkscape:page 25 + x="0" 26 + y="0" 27 + width="25" 28 + height="25" 29 + id="page2" 30 + margin="0" 31 + bleed="0" /> 32 + </sodipodi:namedview> 33 + <g 34 + inkscape:groupmode="layer" 35 + inkscape:label="Image" 36 + id="g1"> 37 + <path 38 + class="dolly" 39 + fill="currentColor" 40 + style="stroke-width:1.12248" 41 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 + id="path1" /> 43 + </g> 44 + </svg>
+167
appview/pages/templates/goodfirstissues/index.html
···
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 <!-- preload main font --> 18 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 ··· 21 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 26 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 29 {{ if .LoggedInUser }} 30 <div id="upgrade-banner" ··· 38 {{ end }} 39 40 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 44 {{ block "content" . }}{{ end }} 45 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 - {{ end }} 53 </div> 54 {{ end }} 55 56 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 58 {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }}
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 20 <!-- preload main font --> 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 22 ··· 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 25 {{ block "extrameta" . }}{{ end }} 26 </head> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 {{ block "topbarLayout" . }} 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 40 {{ end }} 41 42 {{ block "mainLayout" . }} 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 47 {{ block "content" . }}{{ end }} 48 </main> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 53 {{ block "contentAfter" . }}{{ end }} 54 </main> 55 + {{ end }} 56 + </div> 57 </div> 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 + <footer class="mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
+87 -34
appview/pages/templates/layouts/fragments/footer.html
··· 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 10 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 </div> 20 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 27 </div> 28 29 - <div class="flex flex-col gap-1"> 30 - <div class="{{ $headerStyle }}">social</div> 31 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 32 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 33 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 34 </div> 35 36 - <div class="flex flex-col gap-1"> 37 - <div class="{{ $headerStyle }}">contact</div> 38 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 39 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 40 </div> 41 - </div> 42 43 - <div class="text-center lg:text-right flex-shrink-0"> 44 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 45 </div> 46 </div> 47 </div>
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 + 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 46 </div> 47 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 + </div> 53 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 64 </div> 65 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 93 </div> 94 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 </div> 99 </div> 100 </div>
+2 -2
appview/pages/templates/layouts/fragments/topbar.html
··· 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 51 <summary 52 class="cursor-pointer list-none flex items-center gap-1" 53 > 54 - {{ $user := didOrHandle .Did .Handle }} 55 <img 56 src="{{ tinyAvatar $user }}" 57 alt=""
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 51 <summary 52 class="cursor-pointer list-none flex items-center gap-1" 53 > 54 + {{ $user := .Did }} 55 <img 56 src="{{ tinyAvatar $user }}" 57 alt=""
+9
appview/pages/templates/layouts/profilebase.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 <meta property="og:type" content="profile" /> 6 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 {{ end }} 9 10 {{ define "content" }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 + {{ $avatarUrl := fullAvatar .Card.UserHandle }} 5 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 6 <meta property="og:type" content="profile" /> 7 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 8 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 9 + <meta property="og:image" content="{{ $avatarUrl }}" /> 10 + <meta property="og:image:width" content="512" /> 11 + <meta property="og:image:height" content="512" /> 12 + 13 + <meta name="twitter:card" content="summary" /> 14 + <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 + <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 16 + <meta name="twitter:image" content="{{ $avatarUrl }}" /> 17 {{ end }} 18 19 {{ define "content" }}
+5 -2
appview/pages/templates/notifications/fragments/item.html
··· 8 "> 9 {{ template "notificationIcon" . }} 10 <div class="flex-1 w-full flex flex-col gap-1"> 11 - <span>{{ template "notificationHeader" . }}</span> 12 <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 </div> 14 ··· 19 {{ define "notificationIcon" }} 20 <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 - <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 23 {{ i .Icon "size-3 text-black dark:text-white" }} 24 </div> 25 </div>
··· 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 ··· 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>
+3 -3
appview/pages/templates/repo/commit.html
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 + <header class="col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 + <footer class="col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+9 -1
appview/pages/templates/repo/fragments/og.html
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 <meta property="og:type" content="object" /> 9 <meta property="og:url" content="{{ $url }}" /> 10 <meta property="og:description" content="{{ $description }}" /> 11 {{ end }}
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 <meta property="og:type" content="object" /> 9 <meta property="og:url" content="{{ $url }}" /> 10 <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/participants.html
··· 1 {{ define "repo/fragments/participants" }} 2 {{ $all := . }} 3 {{ $ps := take $all 5 }} 4 - <div class="px-6 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>
··· 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>
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 {{ if .IsReacted }} 24 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 {{ else }}
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 relative group 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 28 {{ if .IsReacted }} 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 {{ else }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 34 35 {{ define "editIssueComment" }} 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 hx-swap="outerHTML" 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 45 {{ define "deleteIssueComment" }} 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 hx-confirm="Are you sure you want to delete your comment?" 50 hx-swap="outerHTML"
··· 34 35 {{ define "editIssueComment" }} 36 <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 hx-swap="outerHTML" 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 45 {{ define "deleteIssueComment" }} 46 <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 hx-confirm="Are you sure you want to delete your comment?" 50 hx-swap="outerHTML"
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 </div> 139 </form> 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 143 </div> 144 {{ end }} 145 {{ end }}
··· 138 </div> 139 </form> 140 {{ else }} 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 148 </div> 149 {{ end }} 150 {{ end }}
+19
appview/pages/templates/repo/issues/fragments/og.html
···
··· 1 + {{ define "repo/issues/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 + {{ $description := or .Issue.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+7 -8
appview/pages/templates/repo/issues/issue.html
··· 2 3 4 {{ define "extrameta" }} 5 - {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 - {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 - 8 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 9 {{ end }} 10 11 {{ define "repoContentLayout" }} ··· 87 88 {{ define "editIssue" }} 89 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 91 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 hx-swap="innerHTML" 93 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 98 {{ define "deleteIssue" }} 99 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 101 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 hx-confirm="Are you sure you want to delete your issue?" 103 hx-swap="none"> ··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 {{ 114 template "repo/fragments/reaction" 115 (dict 116 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 }} 121 {{ end }} 122 </div>
··· 2 3 4 {{ define "extrameta" }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 6 {{ end }} 7 8 {{ define "repoContentLayout" }} ··· 84 85 {{ define "editIssue" }} 86 <a 87 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 88 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 89 hx-swap="innerHTML" 90 hx-target="#issue-{{.Issue.IssueId}}"> ··· 94 95 {{ define "deleteIssue" }} 96 <a 97 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 98 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 99 hx-confirm="Are you sure you want to delete your issue?" 100 hx-swap="none"> ··· 107 <div class="flex items-center gap-2"> 108 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 109 {{ range $kind := .OrderedReactionKinds }} 110 + {{ $reactionData := index $.Reactions $kind }} 111 {{ 112 template "repo/fragments/reaction" 113 (dict 114 "Kind" $kind 115 + "Count" $reactionData.Count 116 "IsReacted" (index $.UserReacted $kind) 117 + "ThreadAt" $.Issue.AtUri 118 + "Users" $reactionData.Users) 119 }} 120 {{ end }} 121 </div>
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 92 </div> 93 {{ block "pagination" . }} {{ end }} 94 {{ end }}
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 42 </div> 43 {{ block "pagination" . }} {{ end }} 44 {{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
···
··· 1 + {{ define "repo/pulls/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }} 3 + {{ $description := or .Pull.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 </button> 36 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 {{ $disabled := "" }} 38 {{ if $isConflicted }}
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 </button> 36 + {{ if .BranchDeleteStatus }} 37 + <button 38 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 + hx-swap="none" 41 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 42 + {{ i "git-branch" "w-4 h-4" }} 43 + <span>delete branch</span> 44 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 + </button> 46 + {{ end }} 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 48 {{ $disabled := "" }} 49 {{ if $isConflicted }}
+15 -11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 {{ if not .Pull.IsPatchBased }} 43 from 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - {{ if .Pull.IsForkBased }} 46 - {{ if .Pull.PullSource.Repo }} 47 - {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 - <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 - {{- else -}} 50 - <span class="italic">[deleted fork]</span> 51 - {{- end -}} 52 - {{- end -}} 53 - {{- .Pull.PullSource.Branch -}} 54 </span> 55 {{ end }} 56 </span> ··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 {{ 70 template "repo/fragments/reaction" 71 (dict 72 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 }} 77 {{ end }} 78 </div>
··· 42 {{ if not .Pull.IsPatchBased }} 43 from 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 + {{ if not .Pull.IsForkBased }} 46 + {{ $repoPath := .RepoInfo.FullName }} 47 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 + {{ else if .Pull.PullSource.Repo }} 49 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 + <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 + {{ else }} 53 + <span class="italic">[deleted fork]</span>: 54 + {{ .Pull.PullSource.Branch }} 55 + {{ end }} 56 </span> 57 {{ end }} 58 </span> ··· 68 <div class="flex items-center gap-2 mt-2"> 69 {{ template "repo/fragments/reactionsPopUp" . }} 70 {{ range $kind := . }} 71 + {{ $reactionData := index $.Reactions $kind }} 72 {{ 73 template "repo/fragments/reaction" 74 (dict 75 "Kind" $kind 76 + "Count" $reactionData.Count 77 "IsReacted" (index $.UserReacted $kind) 78 + "ThreadAt" $.Pull.PullAt 79 + "Users" $reactionData.Users) 80 }} 81 {{ end }} 82 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ resolve .LoggedInUser.Did }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 29 {{ end }} 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 52 {{ end }} 53 </div> 54 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 63 {{ define "contentAfter" }} 64 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
··· 28 29 {{ end }} 30 31 {{ define "mainLayout" }} 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 33 {{ block "contentLayout" . }} 34 {{ block "content" . }}{{ end }} 35 {{ end }} ··· 46 {{ end }} 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
appview/pages/templates/repo/pulls/patch.html
··· 34 </section> 35 {{ end }} 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/fragments/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 45 {{ block "contentLayout" . }} 46 {{ block "content" . }}{{ end }} 47 {{ end }} ··· 57 </div> 58 {{ end }} 59 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 {{ end }} 67 68 {{ define "contentAfter" }}
··· 34 </section> 35 {{ end }} 36 37 {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 51 </div> 52 {{ end }} 53 </div> 54 {{ end }} 55 56 {{ define "contentAfter" }}
+18 -8
appview/pages/templates/repo/pulls/pull.html
··· 3 {{ end }} 4 5 {{ define "extrameta" }} 6 - {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 {{ define "repoContentLayout" }} ··· 187 {{ end }} 188 189 {{ if $.LoggedInUser }} 190 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 191 {{ else }} 192 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 193 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 194 - <a href="/login" class="underline">login</a> to join the discussion 195 </div> 196 {{ end }} 197 </div>
··· 3 {{ end }} 4 5 {{ define "extrameta" }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 {{ end }} 8 9 {{ define "repoContentLayout" }} ··· 184 {{ end }} 185 186 {{ if $.LoggedInUser }} 187 + {{ template "repo/pulls/fragments/pullActions" 188 + (dict 189 + "LoggedInUser" $.LoggedInUser 190 + "Pull" $.Pull 191 + "RepoInfo" $.RepoInfo 192 + "RoundNumber" .RoundNumber 193 + "MergeCheck" $.MergeCheck 194 + "ResubmitCheck" $.ResubmitCheck 195 + "BranchDeleteStatus" $.BranchDeleteStatus 196 + "Stack" $.Stack) }} 197 {{ else }} 198 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 199 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 200 + sign up 201 + </a> 202 + <span class="text-gray-500 dark:text-gray-400">or</span> 203 + <a href="/login" class="underline">login</a> 204 + to add to the discussion 205 </div> 206 {{ end }} 207 </div>
+2
appview/pages/templates/repo/settings/access.html
··· 83 </label> 84 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 <input 86 type="text" 87 id="add-collaborator" 88 name="collaborator"
··· 83 </label> 84 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 <input 86 + autocapitalize="none" 87 + autocorrect="off" 88 type="text" 89 id="add-collaborator" 90 name="collaborator"
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 30 </label> 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 <input 33 type="text" 34 id="member-did-{{ .Id }}" 35 name="member"
··· 30 </label> 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 <input 33 + autocapitalize="none" 34 + autocorrect="off" 35 type="text" 36 id="member-did-{{ .Id }}" 37 name="member"
+1 -1
appview/pages/templates/strings/string.html
··· 47 </span> 48 </section> 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 - <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 <span> 52 {{ .String.Filename }} 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
··· 47 </span> 48 </section> 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 <span> 52 {{ .String.Filename }} 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+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 }}
+1
appview/pages/templates/timeline/home.html
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 {{ template "timeline/fragments/trending" . }} 16 {{ template "timeline/fragments/timeline" . }} 17 <div class="flex justify-end">
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 {{ template "timeline/fragments/trending" . }} 18 {{ template "timeline/fragments/timeline" . }} 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link 24 rel="stylesheet" 25 href="/static/tw.css?{{ cssContentHash }}"
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 24 <link 25 rel="stylesheet" 26 href="/static/tw.css?{{ cssContentHash }}"
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 </div> 8 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
··· 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+24 -2
appview/pages/templates/user/login.html
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 - <main class="max-w-md px-6 -mt-4"> 16 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 17 {{ template "fragments/logotype" }} 18 </h1> ··· 20 tightly-knit social coding. 21 </h2> 22 <form 23 - class="mt-4 max-w-sm mx-auto" 24 hx-post="/login" 25 hx-swap="none" 26 hx-disabled-elt="#login-button" ··· 28 <div class="flex flex-col"> 29 <label for="handle">handle</label> 30 <input 31 type="text" 32 id="handle" 33 name="handle" ··· 52 <span>login</span> 53 </button> 54 </form> 55 <p class="text-sm text-gray-500"> 56 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 57 </p>
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>login &middot; tangled</title> 14 </head> 15 <body class="flex items-center justify-center min-h-screen"> 16 + <main class="max-w-md px-7 mt-4"> 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 {{ template "fragments/logotype" }} 19 </h1> ··· 21 tightly-knit social coding. 22 </h2> 23 <form 24 + class="mt-4" 25 hx-post="/login" 26 hx-swap="none" 27 hx-disabled-elt="#login-button" ··· 29 <div class="flex flex-col"> 30 <label for="handle">handle</label> 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 35 type="text" 36 id="handle" 37 name="handle" ··· 56 <span>login</span> 57 </button> 58 </form> 59 + {{ if .ErrorCode }} 60 + <div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300"> 61 + <span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span> 62 + <div> 63 + <h5 class="font-medium">Login error</h5> 64 + <p class="text-sm"> 65 + {{ if eq .ErrorCode "access_denied" }} 66 + You have not authorized the app. 67 + {{ else if eq .ErrorCode "session" }} 68 + Server failed to create user session. 69 + {{ else }} 70 + Internal Server error. 71 + {{ end }} 72 + Please try again. 73 + </p> 74 + </div> 75 + </div> 76 + {{ end }} 77 <p class="text-sm text-gray-500"> 78 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 79 </p>
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 39 </span> 40 - {{ end }} 41 </div> 42 </div> 43 <div class="flex items-center justify-between p-4">
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 <span class="font-bold"> 37 + {{ resolve .LoggedInUser.Did }} 38 </span> 39 </div> 40 </div> 41 <div class="flex items-center justify-between p-4">
+1
appview/pages/templates/user/signup.html
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>sign up &middot; tangled</title> 14
+23
appview/pagination/page.go
··· 1 package pagination 2 3 type Page struct { 4 Offset int // where to start from 5 Limit int // number of items in a page ··· 10 Offset: 0, 11 Limit: 30, 12 } 13 } 14 15 func (p Page) Previous() Page {
··· 1 package pagination 2 3 + import "context" 4 + 5 type Page struct { 6 Offset int // where to start from 7 Limit int // number of items in a page ··· 12 Offset: 0, 13 Limit: 30, 14 } 15 + } 16 + 17 + type ctxKey struct{} 18 + 19 + func IntoContext(ctx context.Context, page Page) context.Context { 20 + return context.WithValue(ctx, ctxKey{}, page) 21 + } 22 + 23 + func FromContext(ctx context.Context) Page { 24 + if ctx == nil { 25 + return FirstPage() 26 + } 27 + v := ctx.Value(ctxKey{}) 28 + if v == nil { 29 + return FirstPage() 30 + } 31 + page, ok := v.(Page) 32 + if !ok { 33 + return FirstPage() 34 + } 35 + return page 36 } 37 38 func (p Page) Previous() Page {
+3 -4
appview/pipelines/pipelines.go
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 - "tangled.org/core/log" 20 "tangled.org/core/rbac" 21 spindlemodel "tangled.org/core/spindle/models" 22 ··· 45 db *db.DB, 46 config *config.Config, 47 enforcer *rbac.Enforcer, 48 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 - return &Pipelines{oauth: oauth, 52 repoResolver: repoResolver, 53 pages: pages, 54 idResolver: idResolver,
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/rbac" 20 spindlemodel "tangled.org/core/spindle/models" 21 ··· 44 db *db.DB, 45 config *config.Config, 46 enforcer *rbac.Enforcer, 47 + logger *slog.Logger, 48 ) *Pipelines { 49 + return &Pipelines{ 50 + oauth: oauth, 51 repoResolver: repoResolver, 52 pages: pages, 53 idResolver: idResolver,
+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 + }
+189 -190
appview/pulls/pulls.go
··· 6 "errors" 7 "fmt" 8 "log" 9 "net/http" 10 "sort" 11 "strconv" 12 "strings" ··· 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/markup" 23 "tangled.org/core/appview/reporesolver" 24 "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/idresolver" 26 "tangled.org/core/patchutil" 27 "tangled.org/core/tid" 28 "tangled.org/core/types" 29 30 - "github.com/bluekeyes/go-gitdiff/gitdiff" 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 43 db *db.DB 44 config *config.Config 45 notifier notify.Notifier 46 } 47 48 func New( ··· 53 db *db.DB, 54 config *config.Config, 55 notifier notify.Notifier, 56 ) *Pulls { 57 return &Pulls{ 58 oauth: oauth, ··· 62 db: db, 63 config: config, 64 notifier: notifier, 65 } 66 } 67 ··· 98 } 99 100 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 101 resubmitResult := pages.Unknown 102 if user.Did == pull.OwnerDid { 103 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 } 105 106 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 107 - LoggedInUser: user, 108 - RepoInfo: f.RepoInfo(user), 109 - Pull: pull, 110 - RoundNumber: roundNumber, 111 - MergeCheck: mergeCheckResponse, 112 - ResubmitCheck: resubmitResult, 113 - Stack: stack, 114 }) 115 return 116 } ··· 135 stack, _ := r.Context().Value("stack").(models.Stack) 136 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 138 - totalIdents := 1 139 - for _, submission := range pull.Submissions { 140 - totalIdents += len(submission.Comments) 141 - } 142 - 143 - identsToResolve := make([]string, totalIdents) 144 - 145 - // populate idents 146 - identsToResolve[0] = pull.OwnerDid 147 - idx := 1 148 - for _, submission := range pull.Submissions { 149 - for _, comment := range submission.Comments { 150 - identsToResolve[idx] = comment.OwnerDid 151 - idx += 1 152 - } 153 - } 154 - 155 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 156 resubmitResult := pages.Unknown 157 if user != nil && user.Did == pull.OwnerDid { 158 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 189 m[p.Sha] = p 190 } 191 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 if err != nil { 194 log.Println("failed to get pull reactions") 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 217 } 218 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 220 - LoggedInUser: user, 221 - RepoInfo: repoInfo, 222 - Pull: pull, 223 - Stack: stack, 224 - AbandonedPulls: abandonedPulls, 225 - MergeCheck: mergeCheckResponse, 226 - ResubmitCheck: resubmitResult, 227 - Pipelines: m, 228 229 OrderedReactionKinds: models.OrderedReactionKinds, 230 - Reactions: reactionCountMap, 231 UserReacted: userReactions, 232 233 LabelDefs: defs, ··· 301 return result 302 } 303 304 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 305 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 306 return pages.Unknown ··· 348 349 targetBranch := branchResp 350 351 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 352 353 if pull.IsStacked() && stack != nil { 354 top := stack[0] 355 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 356 } 357 358 if latestSourceRev != targetBranch.Hash { ··· 392 return 393 } 394 395 - patch := pull.Submissions[roundIdInt].Patch 396 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 397 398 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 443 return 444 } 445 446 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 447 if err != nil { 448 log.Println("failed to interdiff; current patch malformed") 449 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 450 return 451 } 452 453 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 454 if err != nil { 455 log.Println("failed to interdiff; previous patch malformed") 456 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 652 653 createdAt := time.Now().Format(time.RFC3339) 654 655 - pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 656 - if err != nil { 657 - log.Println("failed to get pull at", err) 658 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 659 - return 660 - } 661 - 662 client, err := s.oauth.AuthorizedClient(r) 663 if err != nil { 664 log.Println("failed to get authorized client", err) 665 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 666 return 667 } 668 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 669 Collection: tangled.RepoPullCommentNSID, 670 Repo: user.Did, 671 Rkey: tid.TID(), 672 Record: &lexutil.LexiconTypeDecoder{ 673 Val: &tangled.RepoPullComment{ 674 - Pull: string(pullAt), 675 Body: body, 676 CreatedAt: createdAt, 677 }, ··· 919 } 920 921 sourceRev := comparison.Rev2 922 - patch := comparison.Patch 923 924 - if !patchutil.IsPatchValid(patch) { 925 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 926 return 927 } ··· 934 Sha: comparison.Rev2, 935 } 936 937 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 938 } 939 940 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 941 - if !patchutil.IsPatchValid(patch) { 942 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 943 return 944 } 945 946 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 947 } 948 949 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) { ··· 1026 } 1027 1028 sourceRev := comparison.Rev2 1029 - patch := comparison.Patch 1030 1031 - if !patchutil.IsPatchValid(patch) { 1032 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1033 return 1034 } ··· 1046 Sha: sourceRev, 1047 } 1048 1049 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1050 } 1051 1052 func (s *Pulls) createPullRequest( ··· 1056 user *oauth.User, 1057 title, body, targetBranch string, 1058 patch string, 1059 sourceRev string, 1060 pullSource *models.PullSource, 1061 recordPullSource *tangled.RepoPull_Source, ··· 1093 1094 // We've already checked earlier if it's diff-based and title is empty, 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1096 - if title == "" { 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1098 if err != nil { 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1104 return 1105 } 1106 1107 - title = formatPatches[0].Title 1108 - body = formatPatches[0].Body 1109 } 1110 1111 rkey := tid.TID() 1112 initialSubmission := models.PullSubmission{ 1113 Patch: patch, 1114 SourceRev: sourceRev, 1115 } 1116 pull := &models.Pull{ ··· 1138 return 1139 } 1140 1141 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1142 Collection: tangled.RepoPullNSID, 1143 Repo: user.Did, 1144 Rkey: rkey, ··· 1149 Repo: string(f.RepoAt()), 1150 Branch: targetBranch, 1151 }, 1152 - Patch: patch, 1153 - Source: recordPullSource, 1154 }, 1155 }, 1156 }) ··· 1235 } 1236 writes = append(writes, &write) 1237 } 1238 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1239 Repo: user.Did, 1240 Writes: writes, 1241 }) ··· 1285 return 1286 } 1287 1288 - if patch == "" || !patchutil.IsPatchValid(patch) { 1289 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1290 return 1291 } ··· 1539 1540 patch := r.FormValue("patch") 1541 1542 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1543 } 1544 1545 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1600 } 1601 1602 sourceRev := comparison.Rev2 1603 - patch := comparison.Patch 1604 1605 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1606 } 1607 1608 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1634 return 1635 } 1636 1637 - // extract patch by performing compare 1638 - forkScheme := "http" 1639 - if !s.config.Core.Dev { 1640 - forkScheme = "https" 1641 - } 1642 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1643 - forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1644 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1645 - if err != nil { 1646 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1647 - log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1648 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1649 - return 1650 - } 1651 - log.Printf("failed to compare branches: %s", err) 1652 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1653 - return 1654 - } 1655 - 1656 - var forkComparison types.RepoFormatPatchResponse 1657 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1658 - log.Println("failed to decode XRPC compare response for fork", err) 1659 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1660 - return 1661 - } 1662 - 1663 // update the hidden tracking branch to latest 1664 client, err := s.oauth.ServiceClient( 1665 r, ··· 1691 return 1692 } 1693 1694 // Use the fork comparison we already made 1695 comparison := forkComparison 1696 1697 sourceRev := comparison.Rev2 1698 - patch := comparison.Patch 1699 1700 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1701 - } 1702 - 1703 - // validate a resubmission against a pull request 1704 - func validateResubmittedPatch(pull *models.Pull, patch string) error { 1705 - if patch == "" { 1706 - return fmt.Errorf("Patch is empty.") 1707 - } 1708 - 1709 - if patch == pull.LatestPatch() { 1710 - return fmt.Errorf("Patch is identical to previous submission.") 1711 - } 1712 - 1713 - if !patchutil.IsPatchValid(patch) { 1714 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1715 - } 1716 - 1717 - return nil 1718 } 1719 1720 func (s *Pulls) resubmitPullHelper( ··· 1724 user *oauth.User, 1725 pull *models.Pull, 1726 patch string, 1727 sourceRev string, 1728 ) { 1729 if pull.IsStacked() { ··· 1732 return 1733 } 1734 1735 - if err := validateResubmittedPatch(pull, patch); err != nil { 1736 s.pages.Notice(w, "resubmit-error", err.Error()) 1737 return 1738 } 1739 1740 // validate sourceRev if branch/fork based 1741 if pull.IsBranchBased() || pull.IsForkBased() { 1742 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1743 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1744 return 1745 } ··· 1753 } 1754 defer tx.Rollback() 1755 1756 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 1757 if err != nil { 1758 log.Println("failed to create pull request", err) 1759 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1766 return 1767 } 1768 1769 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1770 if err != nil { 1771 // failed to get record 1772 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1789 } 1790 } 1791 1792 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1793 Collection: tangled.RepoPullNSID, 1794 Repo: user.Did, 1795 Rkey: pull.Rkey, ··· 1801 Repo: string(f.RepoAt()), 1802 Branch: pull.TargetBranch, 1803 }, 1804 - Patch: patch, // new patch 1805 - Source: recordPullSource, 1806 }, 1807 }, 1808 }) ··· 1853 // commits that got deleted: corresponding pull is closed 1854 // commits that got added: new pull is created 1855 // commits that got updated: corresponding pull is resubmitted & new round begins 1856 - // 1857 - // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1858 additions := make(map[string]*models.Pull) 1859 deletions := make(map[string]*models.Pull) 1860 - unchanged := make(map[string]struct{}) 1861 updated := make(map[string]struct{}) 1862 1863 // pulls in orignal stack but not in new one ··· 1879 for _, np := range newStack { 1880 if op, ok := origById[np.ChangeId]; ok { 1881 // pull exists in both stacks 1882 - // TODO: can we avoid reparse? 1883 - origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1884 - newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1885 - 1886 - origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1887 - newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1888 - 1889 - patchutil.SortPatch(newFiles) 1890 - patchutil.SortPatch(origFiles) 1891 - 1892 - // text content of patch may be identical, but a jj rebase might have forwarded it 1893 - // 1894 - // we still need to update the hash in submission.Patch and submission.SourceRev 1895 - if patchutil.Equal(newFiles, origFiles) && 1896 - origHeader.Title == newHeader.Title && 1897 - origHeader.Body == newHeader.Body { 1898 - unchanged[op.ChangeId] = struct{}{} 1899 - } else { 1900 - updated[op.ChangeId] = struct{}{} 1901 - } 1902 } 1903 } 1904 ··· 1965 continue 1966 } 1967 1968 - submission := np.Submissions[np.LastRoundNumber()] 1969 - 1970 - // resubmit the old pull 1971 - err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 1972 - 1973 - if err != nil { 1974 - log.Println("failed to update pull", err, op.PullId) 1975 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1976 - return 1977 - } 1978 - 1979 - record := op.AsRecord() 1980 - record.Patch = submission.Patch 1981 - 1982 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1983 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1984 - Collection: tangled.RepoPullNSID, 1985 - Rkey: op.Rkey, 1986 - Value: &lexutil.LexiconTypeDecoder{ 1987 - Val: &record, 1988 - }, 1989 - }, 1990 - }) 1991 - } 1992 - 1993 - // unchanged pulls are edited without starting a new round 1994 - // 1995 - // update source-revs & patches without advancing rounds 1996 - for changeId := range unchanged { 1997 - op, _ := origById[changeId] 1998 - np, _ := newById[changeId] 1999 - 2000 - origSubmission := op.Submissions[op.LastRoundNumber()] 2001 - newSubmission := np.Submissions[np.LastRoundNumber()] 2002 - 2003 - log.Println("moving unchanged change id : ", changeId) 2004 - 2005 - err := db.UpdatePull( 2006 - tx, 2007 - newSubmission.Patch, 2008 - newSubmission.SourceRev, 2009 - db.FilterEq("id", origSubmission.ID), 2010 - ) 2011 - 2012 if err != nil { 2013 log.Println("failed to update pull", err, op.PullId) 2014 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2015 return 2016 } 2017 2018 - record := op.AsRecord() 2019 - record.Patch = newSubmission.Patch 2020 2021 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2022 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2061 return 2062 } 2063 2064 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2065 Repo: user.Did, 2066 Writes: writes, 2067 }) ··· 2357 initialSubmission := models.PullSubmission{ 2358 Patch: fp.Raw, 2359 SourceRev: fp.SHA, 2360 } 2361 pull := models.Pull{ 2362 Title: title,
··· 6 "errors" 7 "fmt" 8 "log" 9 + "log/slog" 10 "net/http" 11 + "slices" 12 "sort" 13 "strconv" 14 "strings" ··· 23 "tangled.org/core/appview/pages" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/validator" 27 "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/idresolver" 29 "tangled.org/core/patchutil" 30 + "tangled.org/core/rbac" 31 "tangled.org/core/tid" 32 "tangled.org/core/types" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 35 lexutil "github.com/bluesky-social/indigo/lex/util" 36 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 46 db *db.DB 47 config *config.Config 48 notifier notify.Notifier 49 + enforcer *rbac.Enforcer 50 + logger *slog.Logger 51 + validator *validator.Validator 52 } 53 54 func New( ··· 59 db *db.DB, 60 config *config.Config, 61 notifier notify.Notifier, 62 + enforcer *rbac.Enforcer, 63 + validator *validator.Validator, 64 + logger *slog.Logger, 65 ) *Pulls { 66 return &Pulls{ 67 oauth: oauth, ··· 71 db: db, 72 config: config, 73 notifier: notifier, 74 + enforcer: enforcer, 75 + logger: logger, 76 + validator: validator, 77 } 78 } 79 ··· 110 } 111 112 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 113 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 114 resubmitResult := pages.Unknown 115 if user.Did == pull.OwnerDid { 116 resubmitResult = s.resubmitCheck(r, f, pull, stack) 117 } 118 119 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 120 + LoggedInUser: user, 121 + RepoInfo: f.RepoInfo(user), 122 + Pull: pull, 123 + RoundNumber: roundNumber, 124 + MergeCheck: mergeCheckResponse, 125 + ResubmitCheck: resubmitResult, 126 + BranchDeleteStatus: branchDeleteStatus, 127 + Stack: stack, 128 }) 129 return 130 } ··· 149 stack, _ := r.Context().Value("stack").(models.Stack) 150 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 151 152 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 153 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 154 resubmitResult := pages.Unknown 155 if user != nil && user.Did == pull.OwnerDid { 156 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 187 m[p.Sha] = p 188 } 189 190 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 191 if err != nil { 192 log.Println("failed to get pull reactions") 193 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 215 } 216 217 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 218 + LoggedInUser: user, 219 + RepoInfo: repoInfo, 220 + Pull: pull, 221 + Stack: stack, 222 + AbandonedPulls: abandonedPulls, 223 + BranchDeleteStatus: branchDeleteStatus, 224 + MergeCheck: mergeCheckResponse, 225 + ResubmitCheck: resubmitResult, 226 + Pipelines: m, 227 228 OrderedReactionKinds: models.OrderedReactionKinds, 229 + Reactions: reactionMap, 230 UserReacted: userReactions, 231 232 LabelDefs: defs, ··· 300 return result 301 } 302 303 + func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 304 + if pull.State != models.PullMerged { 305 + return nil 306 + } 307 + 308 + user := s.oauth.GetUser(r) 309 + if user == nil { 310 + return nil 311 + } 312 + 313 + var branch string 314 + var repo *models.Repo 315 + // check if the branch exists 316 + // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 317 + if pull.IsBranchBased() { 318 + branch = pull.PullSource.Branch 319 + repo = &f.Repo 320 + } else if pull.IsForkBased() { 321 + branch = pull.PullSource.Branch 322 + repo = pull.PullSource.Repo 323 + } else { 324 + return nil 325 + } 326 + 327 + // deleted fork 328 + if repo == nil { 329 + return nil 330 + } 331 + 332 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 333 + perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 334 + if !slices.Contains(perms, "repo:push") { 335 + return nil 336 + } 337 + 338 + scheme := "http" 339 + if !s.config.Core.Dev { 340 + scheme = "https" 341 + } 342 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 343 + xrpcc := &indigoxrpc.Client{ 344 + Host: host, 345 + } 346 + 347 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 348 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 349 + return nil 350 + } 351 + 352 + return &models.BranchDeleteStatus{ 353 + Repo: repo, 354 + Branch: resp.Name, 355 + } 356 + } 357 + 358 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 359 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 360 return pages.Unknown ··· 402 403 targetBranch := branchResp 404 405 + latestSourceRev := pull.LatestSha() 406 407 if pull.IsStacked() && stack != nil { 408 top := stack[0] 409 + latestSourceRev = top.LatestSha() 410 } 411 412 if latestSourceRev != targetBranch.Hash { ··· 446 return 447 } 448 449 + patch := pull.Submissions[roundIdInt].CombinedPatch() 450 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 451 452 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 497 return 498 } 499 500 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 501 if err != nil { 502 log.Println("failed to interdiff; current patch malformed") 503 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 504 return 505 } 506 507 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 508 if err != nil { 509 log.Println("failed to interdiff; previous patch malformed") 510 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 706 707 createdAt := time.Now().Format(time.RFC3339) 708 709 client, err := s.oauth.AuthorizedClient(r) 710 if err != nil { 711 log.Println("failed to get authorized client", err) 712 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 713 return 714 } 715 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 716 Collection: tangled.RepoPullCommentNSID, 717 Repo: user.Did, 718 Rkey: tid.TID(), 719 Record: &lexutil.LexiconTypeDecoder{ 720 Val: &tangled.RepoPullComment{ 721 + Pull: pull.PullAt().String(), 722 Body: body, 723 CreatedAt: createdAt, 724 }, ··· 966 } 967 968 sourceRev := comparison.Rev2 969 + patch := comparison.FormatPatchRaw 970 + combined := comparison.CombinedPatchRaw 971 972 + if err := s.validator.ValidatePatch(&patch); err != nil { 973 + s.logger.Error("failed to validate patch", "err", err) 974 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 975 return 976 } ··· 983 Sha: comparison.Rev2, 984 } 985 986 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 987 } 988 989 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 990 + if err := s.validator.ValidatePatch(&patch); err != nil { 991 + s.logger.Error("patch validation failed", "err", err) 992 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 993 return 994 } 995 996 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 997 } 998 999 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { ··· 1076 } 1077 1078 sourceRev := comparison.Rev2 1079 + patch := comparison.FormatPatchRaw 1080 + combined := comparison.CombinedPatchRaw 1081 1082 + if err := s.validator.ValidatePatch(&patch); err != nil { 1083 + s.logger.Error("failed to validate patch", "err", err) 1084 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1085 return 1086 } ··· 1098 Sha: sourceRev, 1099 } 1100 1101 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1102 } 1103 1104 func (s *Pulls) createPullRequest( ··· 1108 user *oauth.User, 1109 title, body, targetBranch string, 1110 patch string, 1111 + combined string, 1112 sourceRev string, 1113 pullSource *models.PullSource, 1114 recordPullSource *tangled.RepoPull_Source, ··· 1146 1147 // We've already checked earlier if it's diff-based and title is empty, 1148 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1149 + if title == "" || body == "" { 1150 formatPatches, err := patchutil.ExtractPatches(patch) 1151 if err != nil { 1152 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1157 return 1158 } 1159 1160 + if title == "" { 1161 + title = formatPatches[0].Title 1162 + } 1163 + if body == "" { 1164 + body = formatPatches[0].Body 1165 + } 1166 } 1167 1168 rkey := tid.TID() 1169 initialSubmission := models.PullSubmission{ 1170 Patch: patch, 1171 + Combined: combined, 1172 SourceRev: sourceRev, 1173 } 1174 pull := &models.Pull{ ··· 1196 return 1197 } 1198 1199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1200 Collection: tangled.RepoPullNSID, 1201 Repo: user.Did, 1202 Rkey: rkey, ··· 1207 Repo: string(f.RepoAt()), 1208 Branch: targetBranch, 1209 }, 1210 + Patch: patch, 1211 + Source: recordPullSource, 1212 + CreatedAt: time.Now().Format(time.RFC3339), 1213 }, 1214 }, 1215 }) ··· 1294 } 1295 writes = append(writes, &write) 1296 } 1297 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1298 Repo: user.Did, 1299 Writes: writes, 1300 }) ··· 1344 return 1345 } 1346 1347 + if err := s.validator.ValidatePatch(&patch); err != nil { 1348 + s.logger.Error("faield to validate patch", "err", err) 1349 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1350 return 1351 } ··· 1599 1600 patch := r.FormValue("patch") 1601 1602 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1603 } 1604 1605 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1660 } 1661 1662 sourceRev := comparison.Rev2 1663 + patch := comparison.FormatPatchRaw 1664 + combined := comparison.CombinedPatchRaw 1665 1666 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1667 } 1668 1669 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1695 return 1696 } 1697 1698 // update the hidden tracking branch to latest 1699 client, err := s.oauth.ServiceClient( 1700 r, ··· 1726 return 1727 } 1728 1729 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1730 + // extract patch by performing compare 1731 + forkScheme := "http" 1732 + if !s.config.Core.Dev { 1733 + forkScheme = "https" 1734 + } 1735 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1736 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1737 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1738 + if err != nil { 1739 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1740 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1741 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1742 + return 1743 + } 1744 + log.Printf("failed to compare branches: %s", err) 1745 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1746 + return 1747 + } 1748 + 1749 + var forkComparison types.RepoFormatPatchResponse 1750 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1751 + log.Println("failed to decode XRPC compare response for fork", err) 1752 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1753 + return 1754 + } 1755 + 1756 // Use the fork comparison we already made 1757 comparison := forkComparison 1758 1759 sourceRev := comparison.Rev2 1760 + patch := comparison.FormatPatchRaw 1761 + combined := comparison.CombinedPatchRaw 1762 1763 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1764 } 1765 1766 func (s *Pulls) resubmitPullHelper( ··· 1770 user *oauth.User, 1771 pull *models.Pull, 1772 patch string, 1773 + combined string, 1774 sourceRev string, 1775 ) { 1776 if pull.IsStacked() { ··· 1779 return 1780 } 1781 1782 + if err := s.validator.ValidatePatch(&patch); err != nil { 1783 s.pages.Notice(w, "resubmit-error", err.Error()) 1784 + return 1785 + } 1786 + 1787 + if patch == pull.LatestPatch() { 1788 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1789 return 1790 } 1791 1792 // validate sourceRev if branch/fork based 1793 if pull.IsBranchBased() || pull.IsForkBased() { 1794 + if sourceRev == pull.LatestSha() { 1795 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1796 return 1797 } ··· 1805 } 1806 defer tx.Rollback() 1807 1808 + pullAt := pull.PullAt() 1809 + newRoundNumber := len(pull.Submissions) 1810 + newPatch := patch 1811 + newSourceRev := sourceRev 1812 + combinedPatch := combined 1813 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1814 if err != nil { 1815 log.Println("failed to create pull request", err) 1816 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1823 return 1824 } 1825 1826 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1827 if err != nil { 1828 // failed to get record 1829 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1846 } 1847 } 1848 1849 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1850 Collection: tangled.RepoPullNSID, 1851 Repo: user.Did, 1852 Rkey: pull.Rkey, ··· 1858 Repo: string(f.RepoAt()), 1859 Branch: pull.TargetBranch, 1860 }, 1861 + Patch: patch, // new patch 1862 + Source: recordPullSource, 1863 + CreatedAt: time.Now().Format(time.RFC3339), 1864 }, 1865 }, 1866 }) ··· 1911 // commits that got deleted: corresponding pull is closed 1912 // commits that got added: new pull is created 1913 // commits that got updated: corresponding pull is resubmitted & new round begins 1914 additions := make(map[string]*models.Pull) 1915 deletions := make(map[string]*models.Pull) 1916 updated := make(map[string]struct{}) 1917 1918 // pulls in orignal stack but not in new one ··· 1934 for _, np := range newStack { 1935 if op, ok := origById[np.ChangeId]; ok { 1936 // pull exists in both stacks 1937 + updated[op.ChangeId] = struct{}{} 1938 } 1939 } 1940 ··· 2001 continue 2002 } 2003 2004 + // resubmit the new pull 2005 + pullAt := op.PullAt() 2006 + newRoundNumber := len(op.Submissions) 2007 + newPatch := np.LatestPatch() 2008 + combinedPatch := np.LatestSubmission().Combined 2009 + newSourceRev := np.LatestSha() 2010 + err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 2011 if err != nil { 2012 log.Println("failed to update pull", err, op.PullId) 2013 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2014 return 2015 } 2016 2017 + record := np.AsRecord() 2018 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2020 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2059 return 2060 } 2061 2062 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2063 Repo: user.Did, 2064 Writes: writes, 2065 }) ··· 2355 initialSubmission := models.PullSubmission{ 2356 Patch: fp.Raw, 2357 SourceRev: fp.SHA, 2358 + Combined: fp.Raw, 2359 } 2360 pull := models.Pull{ 2361 Title: title,
+1
appview/pulls/router.go
··· 23 r.Route("/{pull}", func(r chi.Router) { 24 r.Use(mw.ResolvePull()) 25 r.Get("/", s.RepoSinglePull) 26 27 r.Route("/round/{round}", func(r chi.Router) { 28 r.Get("/", s.RepoPullPatch)
··· 23 r.Route("/{pull}", func(r chi.Router) { 24 r.Use(mw.ResolvePull()) 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 27 28 r.Route("/round/{round}", func(r chi.Router) { 29 r.Get("/", s.RepoPullPatch)
+11 -10
appview/repo/artifact.go
··· 10 "net/url" 11 "time" 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" ··· 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/tid" 27 "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 60 return 61 } 62 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 if err != nil { 65 log.Println("failed to upload blob", err) 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 rkey := tid.TID() 73 createdAt := time.Now() 74 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 Collection: tangled.RepoArtifactNSID, 77 Repo: user.Did, 78 Rkey: rkey, ··· 249 return 250 } 251 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 Collection: tangled.RepoArtifactNSID, 254 Repo: user.Did, 255 Rkey: artifact.Rkey,
··· 10 "net/url" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" ··· 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 29 ) 30 31 // TODO: proper statuses here on early exit ··· 61 return 62 } 63 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 65 if err != nil { 66 log.Println("failed to upload blob", err) 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 rkey := tid.TID() 74 createdAt := time.Now() 75 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 Repo: user.Did, 79 Rkey: rkey, ··· 250 return 251 } 252 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 254 Collection: tangled.RepoArtifactNSID, 255 Repo: user.Did, 256 Rkey: artifact.Rkey,
+26 -12
appview/repo/index.go
··· 3 import ( 4 "errors" 5 "fmt" 6 - "log" 7 "net/http" 8 "net/url" 9 "slices" ··· 31 ) 32 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 ref := chi.URLParam(r, "ref") 35 ref, _ = url.PathUnescape(ref) 36 37 f, err := rp.repoResolver.Resolve(r) 38 if err != nil { 39 - log.Println("failed to fully resolve repo", err) 40 return 41 } 42 ··· 56 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 - log.Println("failed to call XRPC repo.index", err) 60 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 LoggedInUser: user, 62 NeedsKnotUpgrade: true, ··· 66 } 67 68 rp.pages.Error503(w) 69 - log.Println("failed to build index response", err) 70 return 71 } 72 ··· 119 emails := uniqueEmails(commitsTrunc) 120 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 121 if err != nil { 122 - log.Println("failed to get email to did map", err) 123 } 124 125 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 126 if err != nil { 127 - log.Println(err) 128 } 129 130 // TODO: a bit dirty 131 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 132 if err != nil { 133 - log.Printf("failed to compute language percentages: %s", err) 134 // non-fatal 135 } 136 ··· 140 } 141 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 142 if err != nil { 143 - log.Printf("failed to fetch pipeline statuses: %s", err) 144 // non-fatal 145 } 146 ··· 162 163 func (rp *Repo) getLanguageInfo( 164 ctx context.Context, 165 f *reporesolver.ResolvedRepo, 166 xrpcc *indigoxrpc.Client, 167 currentRef string, ··· 180 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 if err != nil { 182 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 - log.Println("failed to call XRPC repo.languages", xrpcerr) 184 return nil, xrpcerr 185 } 186 return nil, err ··· 199 Bytes: lang.Size, 200 }) 201 } 202 203 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 205 if err != nil { 206 // non-fatal 207 - log.Println("failed to cache lang results", err) 208 } 209 } 210
··· 3 import ( 4 "errors" 5 "fmt" 6 + "log/slog" 7 "net/http" 8 "net/url" 9 "slices" ··· 31 ) 32 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 36 ref := chi.URLParam(r, "ref") 37 ref, _ = url.PathUnescape(ref) 38 39 f, err := rp.repoResolver.Resolve(r) 40 if err != nil { 41 + l.Error("failed to fully resolve repo", "err", err) 42 return 43 } 44 ··· 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 61 + l.Error("failed to call XRPC repo.index", "err", err) 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 LoggedInUser: user, 64 NeedsKnotUpgrade: true, ··· 68 } 69 70 rp.pages.Error503(w) 71 + l.Error("failed to build index response", "err", err) 72 return 73 } 74 ··· 121 emails := uniqueEmails(commitsTrunc) 122 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 123 if err != nil { 124 + l.Error("failed to get email to did map", "err", err) 125 } 126 127 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 128 if err != nil { 129 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 130 } 131 132 // TODO: a bit dirty 133 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 134 if err != nil { 135 + l.Warn("failed to compute language percentages", "err", err) 136 // non-fatal 137 } 138 ··· 142 } 143 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 144 if err != nil { 145 + l.Error("failed to fetch pipeline statuses", "err", err) 146 // non-fatal 147 } 148 ··· 164 165 func (rp *Repo) getLanguageInfo( 166 ctx context.Context, 167 + l *slog.Logger, 168 f *reporesolver.ResolvedRepo, 169 xrpcc *indigoxrpc.Client, 170 currentRef string, ··· 183 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 184 if err != nil { 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 187 return nil, xrpcerr 188 } 189 return nil, err ··· 202 Bytes: lang.Size, 203 }) 204 } 205 + 206 + tx, err := rp.db.Begin() 207 + if err != nil { 208 + return nil, err 209 + } 210 + defer tx.Rollback() 211 212 // update appview's cache 213 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 214 if err != nil { 215 // non-fatal 216 + l.Error("failed to cache lang results", "err", err) 217 + } 218 + 219 + err = tx.Commit() 220 + if err != nil { 221 + return nil, err 222 } 223 } 224
+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 + }
+213 -126
appview/repo/repo.go
··· 7 "errors" 8 "fmt" 9 "io" 10 - "log" 11 "log/slog" 12 "net/http" 13 "net/url" ··· 17 "strings" 18 "time" 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.org/core/api/tangled" 24 "tangled.org/core/appview/commitverify" 25 "tangled.org/core/appview/config" ··· 40 "tangled.org/core/types" 41 "tangled.org/core/xrpc/serviceauth" 42 43 securejoin "github.com/cyphar/filepath-securejoin" 44 "github.com/go-chi/chi/v5" 45 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 ) 49 50 type Repo struct { ··· 90 } 91 92 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 93 ref := chi.URLParam(r, "ref") 94 ref, _ = url.PathUnescape(ref) 95 96 f, err := rp.repoResolver.Resolve(r) 97 if err != nil { 98 - log.Println("failed to get repo and knot", err) 99 return 100 } 101 ··· 111 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 112 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 113 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 114 - log.Println("failed to call XRPC repo.archive", xrpcerr) 115 rp.pages.Error503(w) 116 return 117 } ··· 128 } 129 130 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 131 f, err := rp.repoResolver.Resolve(r) 132 if err != nil { 133 - log.Println("failed to fully resolve repo", err) 134 return 135 } 136 ··· 165 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 166 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 167 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 - log.Println("failed to call XRPC repo.log", xrpcerr) 169 rp.pages.Error503(w) 170 return 171 } 172 173 var xrpcResp types.RepoLogResponse 174 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 - log.Println("failed to decode XRPC response", err) 176 rp.pages.Error503(w) 177 return 178 } 179 180 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 181 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 - log.Println("failed to call XRPC repo.tags", xrpcerr) 183 rp.pages.Error503(w) 184 return 185 } ··· 189 var tagResp types.RepoTagsResponse 190 if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 191 for _, tag := range tagResp.Tags { 192 - tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 193 } 194 } 195 } 196 197 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 - log.Println("failed to call XRPC repo.branches", xrpcerr) 200 rp.pages.Error503(w) 201 return 202 } ··· 214 215 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 216 if err != nil { 217 - log.Println("failed to fetch email to did mapping", err) 218 } 219 220 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 221 if err != nil { 222 - log.Println(err) 223 } 224 225 repoInfo := f.RepoInfo(user) ··· 230 } 231 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 232 if err != nil { 233 - log.Println(err) 234 // non-fatal 235 } 236 ··· 246 } 247 248 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 249 f, err := rp.repoResolver.Resolve(r) 250 if err != nil { 251 - log.Println("failed to get repo and knot", err) 252 w.WriteHeader(http.StatusBadRequest) 253 return 254 } ··· 260 } 261 262 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 263 f, err := rp.repoResolver.Resolve(r) 264 if err != nil { 265 - log.Println("failed to get repo and knot", err) 266 w.WriteHeader(http.StatusBadRequest) 267 return 268 } ··· 270 repoAt := f.RepoAt() 271 rkey := repoAt.RecordKey().String() 272 if rkey == "" { 273 - log.Println("invalid aturi for repo", err) 274 w.WriteHeader(http.StatusInternalServerError) 275 return 276 } ··· 287 newDescription := r.FormValue("description") 288 client, err := rp.oauth.AuthorizedClient(r) 289 if err != nil { 290 - log.Println("failed to get client") 291 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 292 return 293 } ··· 295 // optimistic update 296 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 297 if err != nil { 298 - log.Println("failed to perferom update-description query", err) 299 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 300 return 301 } ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 324 }) 325 326 if err != nil { 327 - log.Println("failed to perferom update-description query", err) 328 // failed to get record 329 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 330 return ··· 341 } 342 343 func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 344 f, err := rp.repoResolver.Resolve(r) 345 if err != nil { 346 - log.Println("failed to fully resolve repo", err) 347 return 348 } 349 ref := chi.URLParam(r, "ref") ··· 371 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 372 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 373 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 374 - log.Println("failed to call XRPC repo.diff", xrpcerr) 375 rp.pages.Error503(w) 376 return 377 } 378 379 var result types.RepoCommitResponse 380 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 381 - log.Println("failed to decode XRPC response", err) 382 rp.pages.Error503(w) 383 return 384 } 385 386 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 387 if err != nil { 388 - log.Println("failed to get email to did mapping:", err) 389 } 390 391 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 392 if err != nil { 393 - log.Println(err) 394 } 395 396 user := rp.oauth.GetUser(r) 397 repoInfo := f.RepoInfo(user) 398 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 399 if err != nil { 400 - log.Println(err) 401 // non-fatal 402 } 403 var pipeline *models.Pipeline ··· 417 } 418 419 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 420 f, err := rp.repoResolver.Resolve(r) 421 if err != nil { 422 - log.Println("failed to fully resolve repo", err) 423 return 424 } 425 ··· 444 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 445 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 446 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 - log.Println("failed to call XRPC repo.tree", xrpcerr) 448 rp.pages.Error503(w) 449 return 450 } ··· 519 } 520 521 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 522 f, err := rp.repoResolver.Resolve(r) 523 if err != nil { 524 - log.Println("failed to get repo and knot", err) 525 return 526 } 527 ··· 537 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 538 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 539 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 540 - log.Println("failed to call XRPC repo.tags", xrpcerr) 541 rp.pages.Error503(w) 542 return 543 } 544 545 var result types.RepoTagsResponse 546 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 547 - log.Println("failed to decode XRPC response", err) 548 rp.pages.Error503(w) 549 return 550 } 551 552 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 553 if err != nil { 554 - log.Println("failed grab artifacts", err) 555 return 556 } 557 ··· 588 } 589 590 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 591 f, err := rp.repoResolver.Resolve(r) 592 if err != nil { 593 - log.Println("failed to get repo and knot", err) 594 return 595 } 596 ··· 606 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 607 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 608 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 609 - log.Println("failed to call XRPC repo.branches", xrpcerr) 610 rp.pages.Error503(w) 611 return 612 } 613 614 var result types.RepoBranchesResponse 615 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 616 - log.Println("failed to decode XRPC response", err) 617 rp.pages.Error503(w) 618 return 619 } ··· 628 }) 629 } 630 631 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 632 f, err := rp.repoResolver.Resolve(r) 633 if err != nil { 634 - log.Println("failed to get repo and knot", err) 635 return 636 } 637 ··· 653 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 654 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 655 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 656 - log.Println("failed to call XRPC repo.blob", xrpcerr) 657 rp.pages.Error503(w) 658 return 659 } ··· 753 } 754 755 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 756 f, err := rp.repoResolver.Resolve(r) 757 if err != nil { 758 - log.Println("failed to get repo and knot", err) 759 w.WriteHeader(http.StatusBadRequest) 760 return 761 } ··· 787 788 req, err := http.NewRequest("GET", blobURL, nil) 789 if err != nil { 790 - log.Println("failed to create request", err) 791 return 792 } 793 ··· 799 client := &http.Client{} 800 resp, err := client.Do(req) 801 if err != nil { 802 - log.Println("failed to reach knotserver", err) 803 rp.pages.Error503(w) 804 return 805 } ··· 812 } 813 814 if resp.StatusCode != http.StatusOK { 815 - log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 816 w.WriteHeader(resp.StatusCode) 817 _, _ = io.Copy(w, resp.Body) 818 return ··· 821 contentType := resp.Header.Get("Content-Type") 822 body, err := io.ReadAll(resp.Body) 823 if err != nil { 824 - log.Printf("error reading response body from knotserver: %v", err) 825 w.WriteHeader(http.StatusInternalServerError) 826 return 827 } ··· 863 user := rp.oauth.GetUser(r) 864 l := rp.logger.With("handler", "EditSpindle") 865 l = l.With("did", user.Did) 866 - l = l.With("handle", user.Handle) 867 868 errorId := "operation-error" 869 fail := func(msg string, err error) { ··· 916 return 917 } 918 919 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 920 if err != nil { 921 fail("Failed to update spindle, no record found on PDS.", err) 922 return 923 } 924 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 925 Collection: tangled.RepoNSID, 926 Repo: newRepo.Did, 927 Rkey: newRepo.Rkey, ··· 951 user := rp.oauth.GetUser(r) 952 l := rp.logger.With("handler", "AddLabel") 953 l = l.With("did", user.Did) 954 - l = l.With("handle", user.Handle) 955 956 f, err := rp.repoResolver.Resolve(r) 957 if err != nil { ··· 1020 1021 // emit a labelRecord 1022 labelRecord := label.AsRecord() 1023 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1024 Collection: tangled.LabelDefinitionNSID, 1025 Repo: label.Did, 1026 Rkey: label.Rkey, ··· 1043 newRepo.Labels = append(newRepo.Labels, aturi) 1044 repoRecord := newRepo.AsRecord() 1045 1046 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1047 if err != nil { 1048 fail("Failed to update labels, no record found on PDS.", err) 1049 return 1050 } 1051 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1052 Collection: tangled.RepoNSID, 1053 Repo: newRepo.Did, 1054 Rkey: newRepo.Rkey, ··· 1111 user := rp.oauth.GetUser(r) 1112 l := rp.logger.With("handler", "DeleteLabel") 1113 l = l.With("did", user.Did) 1114 - l = l.With("handle", user.Handle) 1115 1116 f, err := rp.repoResolver.Resolve(r) 1117 if err != nil { ··· 1141 } 1142 1143 // delete label record from PDS 1144 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1145 Collection: tangled.LabelDefinitionNSID, 1146 Repo: label.Did, 1147 Rkey: label.Rkey, ··· 1163 newRepo.Labels = updated 1164 repoRecord := newRepo.AsRecord() 1165 1166 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1167 if err != nil { 1168 fail("Failed to update labels, no record found on PDS.", err) 1169 return 1170 } 1171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1172 Collection: tangled.RepoNSID, 1173 Repo: newRepo.Did, 1174 Rkey: newRepo.Rkey, ··· 1220 user := rp.oauth.GetUser(r) 1221 l := rp.logger.With("handler", "SubscribeLabel") 1222 l = l.With("did", user.Did) 1223 - l = l.With("handle", user.Handle) 1224 1225 f, err := rp.repoResolver.Resolve(r) 1226 if err != nil { ··· 1261 return 1262 } 1263 1264 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1265 if err != nil { 1266 fail("Failed to update labels, no record found on PDS.", err) 1267 return 1268 } 1269 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1270 Collection: tangled.RepoNSID, 1271 Repo: newRepo.Did, 1272 Rkey: newRepo.Rkey, ··· 1307 user := rp.oauth.GetUser(r) 1308 l := rp.logger.With("handler", "UnsubscribeLabel") 1309 l = l.With("did", user.Did) 1310 - l = l.With("handle", user.Handle) 1311 1312 f, err := rp.repoResolver.Resolve(r) 1313 if err != nil { ··· 1350 return 1351 } 1352 1353 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1354 if err != nil { 1355 fail("Failed to update labels, no record found on PDS.", err) 1356 return 1357 } 1358 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1359 Collection: tangled.RepoNSID, 1360 Repo: newRepo.Did, 1361 Rkey: newRepo.Rkey, ··· 1401 db.FilterContains("scope", subject.Collection().String()), 1402 ) 1403 if err != nil { 1404 - log.Println("failed to fetch label defs", err) 1405 return 1406 } 1407 ··· 1412 1413 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1414 if err != nil { 1415 - log.Println("failed to build label state", err) 1416 return 1417 } 1418 state := states[subject] ··· 1449 db.FilterContains("scope", subject.Collection().String()), 1450 ) 1451 if err != nil { 1452 - log.Println("failed to fetch labels", err) 1453 return 1454 } 1455 ··· 1460 1461 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1462 if err != nil { 1463 - log.Println("failed to build label state", err) 1464 return 1465 } 1466 state := states[subject] ··· 1479 user := rp.oauth.GetUser(r) 1480 l := rp.logger.With("handler", "AddCollaborator") 1481 l = l.With("did", user.Did) 1482 - l = l.With("handle", user.Handle) 1483 1484 f, err := rp.repoResolver.Resolve(r) 1485 if err != nil { ··· 1526 currentUser := rp.oauth.GetUser(r) 1527 rkey := tid.TID() 1528 createdAt := time.Now() 1529 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1530 Collection: tangled.RepoCollaboratorNSID, 1531 Repo: currentUser.Did, 1532 Rkey: rkey, ··· 1608 1609 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1610 user := rp.oauth.GetUser(r) 1611 1612 noticeId := "operation-error" 1613 f, err := rp.repoResolver.Resolve(r) 1614 if err != nil { 1615 - log.Println("failed to get repo and knot", err) 1616 return 1617 } 1618 1619 // remove record from pds 1620 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1621 if err != nil { 1622 - log.Println("failed to get authorized client", err) 1623 return 1624 } 1625 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1626 Collection: tangled.RepoNSID, 1627 Repo: user.Did, 1628 Rkey: f.Rkey, 1629 }) 1630 if err != nil { 1631 - log.Printf("failed to delete record: %s", err) 1632 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1633 return 1634 } 1635 - log.Println("removed repo record ", f.RepoAt().String()) 1636 1637 client, err := rp.oauth.ServiceClient( 1638 r, ··· 1641 oauth.WithDev(rp.config.Core.Dev), 1642 ) 1643 if err != nil { 1644 - log.Println("failed to connect to knot server:", err) 1645 return 1646 } 1647 ··· 1658 rp.pages.Notice(w, noticeId, err.Error()) 1659 return 1660 } 1661 - log.Println("deleted repo from knot") 1662 1663 tx, err := rp.db.BeginTx(r.Context(), nil) 1664 if err != nil { 1665 - log.Println("failed to start tx") 1666 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1667 return 1668 } ··· 1670 tx.Rollback() 1671 err = rp.enforcer.E.LoadPolicy() 1672 if err != nil { 1673 - log.Println("failed to rollback policies") 1674 } 1675 }() 1676 ··· 1684 did := c[0] 1685 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1686 } 1687 - log.Println("removed collaborators") 1688 1689 // remove repo RBAC 1690 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1699 rp.pages.Notice(w, noticeId, "Failed to update appview") 1700 return 1701 } 1702 - log.Println("removed repo from db") 1703 1704 err = tx.Commit() 1705 if err != nil { 1706 - log.Println("failed to commit changes", err) 1707 http.Error(w, err.Error(), http.StatusInternalServerError) 1708 return 1709 } 1710 1711 err = rp.enforcer.E.SavePolicy() 1712 if err != nil { 1713 - log.Println("failed to update ACLs", err) 1714 http.Error(w, err.Error(), http.StatusInternalServerError) 1715 return 1716 } ··· 1719 } 1720 1721 func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1722 f, err := rp.repoResolver.Resolve(r) 1723 if err != nil { 1724 - log.Println("failed to get repo and knot", err) 1725 return 1726 } 1727 ··· 1739 oauth.WithDev(rp.config.Core.Dev), 1740 ) 1741 if err != nil { 1742 - log.Println("failed to connect to knot server:", err) 1743 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1744 return 1745 } ··· 1753 }, 1754 ) 1755 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1756 - log.Println("xrpc failed", "err", xe) 1757 rp.pages.Notice(w, noticeId, err.Error()) 1758 return 1759 } ··· 1764 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1765 user := rp.oauth.GetUser(r) 1766 l := rp.logger.With("handler", "Secrets") 1767 - l = l.With("handle", user.Handle) 1768 l = l.With("did", user.Did) 1769 1770 f, err := rp.repoResolver.Resolve(r) 1771 if err != nil { 1772 - log.Println("failed to get repo and knot", err) 1773 return 1774 } 1775 1776 if f.Spindle == "" { 1777 - log.Println("empty spindle cannot add/rm secret", err) 1778 return 1779 } 1780 ··· 1791 oauth.WithDev(rp.config.Core.Dev), 1792 ) 1793 if err != nil { 1794 - log.Println("failed to create spindle client", err) 1795 return 1796 } 1797 ··· 1877 } 1878 1879 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1880 f, err := rp.repoResolver.Resolve(r) 1881 user := rp.oauth.GetUser(r) 1882 ··· 1892 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1893 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1894 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1895 - log.Println("failed to call XRPC repo.branches", xrpcerr) 1896 rp.pages.Error503(w) 1897 return 1898 } 1899 1900 var result types.RepoBranchesResponse 1901 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1902 - log.Println("failed to decode XRPC response", err) 1903 rp.pages.Error503(w) 1904 return 1905 } 1906 1907 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1908 if err != nil { 1909 - log.Println("failed to fetch labels", err) 1910 rp.pages.Error503(w) 1911 return 1912 } 1913 1914 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1915 if err != nil { 1916 - log.Println("failed to fetch labels", err) 1917 rp.pages.Error503(w) 1918 return 1919 } ··· 1961 } 1962 1963 func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1964 f, err := rp.repoResolver.Resolve(r) 1965 user := rp.oauth.GetUser(r) 1966 1967 repoCollaborators, err := f.Collaborators(r.Context()) 1968 if err != nil { 1969 - log.Println("failed to get collaborators", err) 1970 } 1971 1972 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ ··· 1979 } 1980 1981 func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1982 f, err := rp.repoResolver.Resolve(r) 1983 user := rp.oauth.GetUser(r) 1984 1985 // all spindles that the repo owner is a member of 1986 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1987 if err != nil { 1988 - log.Println("failed to fetch spindles", err) 1989 return 1990 } 1991 ··· 1998 oauth.WithExp(60), 1999 oauth.WithDev(rp.config.Core.Dev), 2000 ); err != nil { 2001 - log.Println("failed to create spindle client", err) 2002 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2003 - log.Println("failed to fetch secrets", err) 2004 } else { 2005 secrets = resp.Secrets 2006 } ··· 2040 } 2041 2042 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2043 ref := chi.URLParam(r, "ref") 2044 ref, _ = url.PathUnescape(ref) 2045 2046 user := rp.oauth.GetUser(r) 2047 f, err := rp.repoResolver.Resolve(r) 2048 if err != nil { 2049 - log.Printf("failed to resolve source repo: %v", err) 2050 return 2051 } 2052 ··· 2090 } 2091 2092 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2093 user := rp.oauth.GetUser(r) 2094 f, err := rp.repoResolver.Resolve(r) 2095 if err != nil { 2096 - log.Printf("failed to resolve source repo: %v", err) 2097 return 2098 } 2099 ··· 2144 ) 2145 if err != nil { 2146 if !errors.Is(err, sql.ErrNoRows) { 2147 - log.Println("error fetching existing repo from db", "err", err) 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2149 return 2150 } ··· 2179 } 2180 record := repo.AsRecord() 2181 2182 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2183 if err != nil { 2184 l.Error("failed to create xrpcclient", "err", err) 2185 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2186 return 2187 } 2188 2189 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2190 Collection: tangled.RepoNSID, 2191 Repo: user.Did, 2192 Rkey: rkey, ··· 2218 rollback := func() { 2219 err1 := tx.Rollback() 2220 err2 := rp.enforcer.E.LoadPolicy() 2221 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2222 2223 // ignore txn complete errors, this is okay 2224 if errors.Is(err1, sql.ErrTxDone) { ··· 2259 2260 err = db.AddRepo(tx, repo) 2261 if err != nil { 2262 - log.Println(err) 2263 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2264 return 2265 } ··· 2268 p, _ := securejoin.SecureJoin(user.Did, forkName) 2269 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2270 if err != nil { 2271 - log.Println(err) 2272 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2273 return 2274 } 2275 2276 err = tx.Commit() 2277 if err != nil { 2278 - log.Println("failed to commit changes", err) 2279 http.Error(w, err.Error(), http.StatusInternalServerError) 2280 return 2281 } 2282 2283 err = rp.enforcer.E.SavePolicy() 2284 if err != nil { 2285 - log.Println("failed to update ACLs", err) 2286 http.Error(w, err.Error(), http.StatusInternalServerError) 2287 return 2288 } ··· 2291 aturi = "" 2292 2293 rp.notifier.NewRepo(r.Context(), repo) 2294 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2295 } 2296 } 2297 2298 // this is used to rollback changes made to the PDS 2299 // 2300 // it is a no-op if the provided ATURI is empty 2301 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2302 if aturi == "" { 2303 return nil 2304 } ··· 2309 repo := parsed.Authority().String() 2310 rkey := parsed.RecordKey().String() 2311 2312 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2313 Collection: collection, 2314 Repo: repo, 2315 Rkey: rkey, ··· 2318 } 2319 2320 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2321 user := rp.oauth.GetUser(r) 2322 f, err := rp.repoResolver.Resolve(r) 2323 if err != nil { 2324 - log.Println("failed to get repo and knot", err) 2325 return 2326 } 2327 ··· 2337 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2338 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2339 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2340 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2341 rp.pages.Error503(w) 2342 return 2343 } 2344 2345 var branchResult types.RepoBranchesResponse 2346 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2347 - log.Println("failed to decode XRPC branches response", err) 2348 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2349 return 2350 } ··· 2374 2375 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2376 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2377 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2378 rp.pages.Error503(w) 2379 return 2380 } 2381 2382 var tags types.RepoTagsResponse 2383 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2384 - log.Println("failed to decode XRPC tags response", err) 2385 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2386 return 2387 } ··· 2399 } 2400 2401 func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2402 user := rp.oauth.GetUser(r) 2403 f, err := rp.repoResolver.Resolve(r) 2404 if err != nil { 2405 - log.Println("failed to get repo and knot", err) 2406 return 2407 } 2408 ··· 2429 head, _ = url.PathUnescape(head) 2430 2431 if base == "" || head == "" { 2432 - log.Printf("invalid comparison") 2433 rp.pages.Error404(w) 2434 return 2435 } ··· 2447 2448 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2449 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2450 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2451 rp.pages.Error503(w) 2452 return 2453 } 2454 2455 var branches types.RepoBranchesResponse 2456 if err := json.Unmarshal(branchBytes, &branches); err != nil { 2457 - log.Println("failed to decode XRPC branches response", err) 2458 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2459 return 2460 } 2461 2462 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2463 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2464 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2465 rp.pages.Error503(w) 2466 return 2467 } 2468 2469 var tags types.RepoTagsResponse 2470 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2471 - log.Println("failed to decode XRPC tags response", err) 2472 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2473 return 2474 } 2475 2476 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2477 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2478 - log.Println("failed to call XRPC repo.compare", xrpcerr) 2479 rp.pages.Error503(w) 2480 return 2481 } 2482 2483 var formatPatch types.RepoFormatPatchResponse 2484 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2485 - log.Println("failed to decode XRPC compare response", err) 2486 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2487 return 2488 } 2489 2490 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2491 2492 repoinfo := f.RepoInfo(user) 2493
··· 7 "errors" 8 "fmt" 9 "io" 10 "log/slog" 11 "net/http" 12 "net/url" ··· 16 "strings" 17 "time" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" ··· 36 "tangled.org/core/types" 37 "tangled.org/core/xrpc/serviceauth" 38 39 + comatproto "github.com/bluesky-social/indigo/api/atproto" 40 + atpclient "github.com/bluesky-social/indigo/atproto/client" 41 + "github.com/bluesky-social/indigo/atproto/syntax" 42 + lexutil "github.com/bluesky-social/indigo/lex/util" 43 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 securejoin "github.com/cyphar/filepath-securejoin" 45 "github.com/go-chi/chi/v5" 46 "github.com/go-git/go-git/v5/plumbing" 47 ) 48 49 type Repo struct { ··· 89 } 90 91 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 + l := rp.logger.With("handler", "DownloadArchive") 93 + 94 ref := chi.URLParam(r, "ref") 95 ref, _ = url.PathUnescape(ref) 96 97 f, err := rp.repoResolver.Resolve(r) 98 if err != nil { 99 + l.Error("failed to get repo and knot", "err", err) 100 return 101 } 102 ··· 112 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 113 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 114 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 115 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 116 rp.pages.Error503(w) 117 return 118 } ··· 129 } 130 131 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 + l := rp.logger.With("handler", "RepoLog") 133 + 134 f, err := rp.repoResolver.Resolve(r) 135 if err != nil { 136 + l.Error("failed to fully resolve repo", "err", err) 137 return 138 } 139 ··· 168 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 169 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 170 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 171 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 172 rp.pages.Error503(w) 173 return 174 } 175 176 var xrpcResp types.RepoLogResponse 177 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 178 + l.Error("failed to decode XRPC response", "err", err) 179 rp.pages.Error503(w) 180 return 181 } 182 183 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 185 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 186 rp.pages.Error503(w) 187 return 188 } ··· 192 var tagResp types.RepoTagsResponse 193 if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 194 for _, tag := range tagResp.Tags { 195 + hash := tag.Hash 196 + if tag.Tag != nil { 197 + hash = tag.Tag.Target.String() 198 + } 199 + tagMap[hash] = append(tagMap[hash], tag.Name) 200 } 201 } 202 } 203 204 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 205 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 206 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 207 rp.pages.Error503(w) 208 return 209 } ··· 221 222 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 223 if err != nil { 224 + l.Error("failed to fetch email to did mapping", "err", err) 225 } 226 227 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 228 if err != nil { 229 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 230 } 231 232 repoInfo := f.RepoInfo(user) ··· 237 } 238 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 239 if err != nil { 240 + l.Error("failed to getPipelineStatuses", "err", err) 241 // non-fatal 242 } 243 ··· 253 } 254 255 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 + l := rp.logger.With("handler", "RepoDescriptionEdit") 257 + 258 f, err := rp.repoResolver.Resolve(r) 259 if err != nil { 260 + l.Error("failed to get repo and knot", "err", err) 261 w.WriteHeader(http.StatusBadRequest) 262 return 263 } ··· 269 } 270 271 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 + l := rp.logger.With("handler", "RepoDescription") 273 + 274 f, err := rp.repoResolver.Resolve(r) 275 if err != nil { 276 + l.Error("failed to get repo and knot", "err", err) 277 w.WriteHeader(http.StatusBadRequest) 278 return 279 } ··· 281 repoAt := f.RepoAt() 282 rkey := repoAt.RecordKey().String() 283 if rkey == "" { 284 + l.Error("invalid aturi for repo", "err", err) 285 w.WriteHeader(http.StatusInternalServerError) 286 return 287 } ··· 298 newDescription := r.FormValue("description") 299 client, err := rp.oauth.AuthorizedClient(r) 300 if err != nil { 301 + l.Error("failed to get client") 302 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 303 return 304 } ··· 306 // optimistic update 307 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 308 if err != nil { 309 + l.Error("failed to perform update-description query", "err", err) 310 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 311 return 312 } ··· 318 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 319 // 320 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 321 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 322 if err != nil { 323 // failed to get record 324 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 325 return 326 } 327 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 328 Collection: tangled.RepoNSID, 329 Repo: newRepo.Did, 330 Rkey: newRepo.Rkey, ··· 335 }) 336 337 if err != nil { 338 + l.Error("failed to perferom update-description query", "err", err) 339 // failed to get record 340 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 341 return ··· 352 } 353 354 func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 + l := rp.logger.With("handler", "RepoCommit") 356 + 357 f, err := rp.repoResolver.Resolve(r) 358 if err != nil { 359 + l.Error("failed to fully resolve repo", "err", err) 360 return 361 } 362 ref := chi.URLParam(r, "ref") ··· 384 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 385 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 386 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 387 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 388 rp.pages.Error503(w) 389 return 390 } 391 392 var result types.RepoCommitResponse 393 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 394 + l.Error("failed to decode XRPC response", "err", err) 395 rp.pages.Error503(w) 396 return 397 } 398 399 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 400 if err != nil { 401 + l.Error("failed to get email to did mapping", "err", err) 402 } 403 404 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 405 if err != nil { 406 + l.Error("failed to GetVerifiedCommits", "err", err) 407 } 408 409 user := rp.oauth.GetUser(r) 410 repoInfo := f.RepoInfo(user) 411 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 412 if err != nil { 413 + l.Error("failed to getPipelineStatuses", "err", err) 414 // non-fatal 415 } 416 var pipeline *models.Pipeline ··· 430 } 431 432 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 + l := rp.logger.With("handler", "RepoTree") 434 + 435 f, err := rp.repoResolver.Resolve(r) 436 if err != nil { 437 + l.Error("failed to fully resolve repo", "err", err) 438 return 439 } 440 ··· 459 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 460 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 461 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 462 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 463 rp.pages.Error503(w) 464 return 465 } ··· 534 } 535 536 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 + l := rp.logger.With("handler", "RepoTags") 538 + 539 f, err := rp.repoResolver.Resolve(r) 540 if err != nil { 541 + l.Error("failed to get repo and knot", "err", err) 542 return 543 } 544 ··· 554 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 555 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 556 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 557 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 558 rp.pages.Error503(w) 559 return 560 } 561 562 var result types.RepoTagsResponse 563 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 564 + l.Error("failed to decode XRPC response", "err", err) 565 rp.pages.Error503(w) 566 return 567 } 568 569 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 570 if err != nil { 571 + l.Error("failed grab artifacts", "err", err) 572 return 573 } 574 ··· 605 } 606 607 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 + l := rp.logger.With("handler", "RepoBranches") 609 + 610 f, err := rp.repoResolver.Resolve(r) 611 if err != nil { 612 + l.Error("failed to get repo and knot", "err", err) 613 return 614 } 615 ··· 625 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 626 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 627 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 628 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 629 rp.pages.Error503(w) 630 return 631 } 632 633 var result types.RepoBranchesResponse 634 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 635 + l.Error("failed to decode XRPC response", "err", err) 636 rp.pages.Error503(w) 637 return 638 } ··· 647 }) 648 } 649 650 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 651 + l := rp.logger.With("handler", "DeleteBranch") 652 + 653 + f, err := rp.repoResolver.Resolve(r) 654 + if err != nil { 655 + l.Error("failed to get repo and knot", "err", err) 656 + return 657 + } 658 + 659 + noticeId := "delete-branch-error" 660 + fail := func(msg string, err error) { 661 + l.Error(msg, "err", err) 662 + rp.pages.Notice(w, noticeId, msg) 663 + } 664 + 665 + branch := r.FormValue("branch") 666 + if branch == "" { 667 + fail("No branch provided.", nil) 668 + return 669 + } 670 + 671 + client, err := rp.oauth.ServiceClient( 672 + r, 673 + oauth.WithService(f.Knot), 674 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 675 + oauth.WithDev(rp.config.Core.Dev), 676 + ) 677 + if err != nil { 678 + fail("Failed to connect to knotserver", nil) 679 + return 680 + } 681 + 682 + err = tangled.RepoDeleteBranch( 683 + r.Context(), 684 + client, 685 + &tangled.RepoDeleteBranch_Input{ 686 + Branch: branch, 687 + Repo: f.RepoAt().String(), 688 + }, 689 + ) 690 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 691 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 692 + return 693 + } 694 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 695 + 696 + rp.pages.HxRefresh(w) 697 + } 698 + 699 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 + l := rp.logger.With("handler", "RepoBlob") 701 + 702 f, err := rp.repoResolver.Resolve(r) 703 if err != nil { 704 + l.Error("failed to get repo and knot", "err", err) 705 return 706 } 707 ··· 723 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 724 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 725 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 726 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 727 rp.pages.Error503(w) 728 return 729 } ··· 823 } 824 825 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 + l := rp.logger.With("handler", "RepoBlobRaw") 827 + 828 f, err := rp.repoResolver.Resolve(r) 829 if err != nil { 830 + l.Error("failed to get repo and knot", "err", err) 831 w.WriteHeader(http.StatusBadRequest) 832 return 833 } ··· 859 860 req, err := http.NewRequest("GET", blobURL, nil) 861 if err != nil { 862 + l.Error("failed to create request", "err", err) 863 return 864 } 865 ··· 871 client := &http.Client{} 872 resp, err := client.Do(req) 873 if err != nil { 874 + l.Error("failed to reach knotserver", "err", err) 875 rp.pages.Error503(w) 876 return 877 } ··· 884 } 885 886 if resp.StatusCode != http.StatusOK { 887 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 888 w.WriteHeader(resp.StatusCode) 889 _, _ = io.Copy(w, resp.Body) 890 return ··· 893 contentType := resp.Header.Get("Content-Type") 894 body, err := io.ReadAll(resp.Body) 895 if err != nil { 896 + l.Error("error reading response body from knotserver", "err", err) 897 w.WriteHeader(http.StatusInternalServerError) 898 return 899 } ··· 935 user := rp.oauth.GetUser(r) 936 l := rp.logger.With("handler", "EditSpindle") 937 l = l.With("did", user.Did) 938 939 errorId := "operation-error" 940 fail := func(msg string, err error) { ··· 987 return 988 } 989 990 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 991 if err != nil { 992 fail("Failed to update spindle, no record found on PDS.", err) 993 return 994 } 995 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 996 Collection: tangled.RepoNSID, 997 Repo: newRepo.Did, 998 Rkey: newRepo.Rkey, ··· 1022 user := rp.oauth.GetUser(r) 1023 l := rp.logger.With("handler", "AddLabel") 1024 l = l.With("did", user.Did) 1025 1026 f, err := rp.repoResolver.Resolve(r) 1027 if err != nil { ··· 1090 1091 // emit a labelRecord 1092 labelRecord := label.AsRecord() 1093 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1094 Collection: tangled.LabelDefinitionNSID, 1095 Repo: label.Did, 1096 Rkey: label.Rkey, ··· 1113 newRepo.Labels = append(newRepo.Labels, aturi) 1114 repoRecord := newRepo.AsRecord() 1115 1116 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1117 if err != nil { 1118 fail("Failed to update labels, no record found on PDS.", err) 1119 return 1120 } 1121 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1122 Collection: tangled.RepoNSID, 1123 Repo: newRepo.Did, 1124 Rkey: newRepo.Rkey, ··· 1181 user := rp.oauth.GetUser(r) 1182 l := rp.logger.With("handler", "DeleteLabel") 1183 l = l.With("did", user.Did) 1184 1185 f, err := rp.repoResolver.Resolve(r) 1186 if err != nil { ··· 1210 } 1211 1212 // delete label record from PDS 1213 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1214 Collection: tangled.LabelDefinitionNSID, 1215 Repo: label.Did, 1216 Rkey: label.Rkey, ··· 1232 newRepo.Labels = updated 1233 repoRecord := newRepo.AsRecord() 1234 1235 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1236 if err != nil { 1237 fail("Failed to update labels, no record found on PDS.", err) 1238 return 1239 } 1240 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1241 Collection: tangled.RepoNSID, 1242 Repo: newRepo.Did, 1243 Rkey: newRepo.Rkey, ··· 1289 user := rp.oauth.GetUser(r) 1290 l := rp.logger.With("handler", "SubscribeLabel") 1291 l = l.With("did", user.Did) 1292 1293 f, err := rp.repoResolver.Resolve(r) 1294 if err != nil { ··· 1329 return 1330 } 1331 1332 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1333 if err != nil { 1334 fail("Failed to update labels, no record found on PDS.", err) 1335 return 1336 } 1337 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1338 Collection: tangled.RepoNSID, 1339 Repo: newRepo.Did, 1340 Rkey: newRepo.Rkey, ··· 1375 user := rp.oauth.GetUser(r) 1376 l := rp.logger.With("handler", "UnsubscribeLabel") 1377 l = l.With("did", user.Did) 1378 1379 f, err := rp.repoResolver.Resolve(r) 1380 if err != nil { ··· 1417 return 1418 } 1419 1420 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1421 if err != nil { 1422 fail("Failed to update labels, no record found on PDS.", err) 1423 return 1424 } 1425 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1426 Collection: tangled.RepoNSID, 1427 Repo: newRepo.Did, 1428 Rkey: newRepo.Rkey, ··· 1468 db.FilterContains("scope", subject.Collection().String()), 1469 ) 1470 if err != nil { 1471 + l.Error("failed to fetch label defs", "err", err) 1472 return 1473 } 1474 ··· 1479 1480 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1481 if err != nil { 1482 + l.Error("failed to build label state", "err", err) 1483 return 1484 } 1485 state := states[subject] ··· 1516 db.FilterContains("scope", subject.Collection().String()), 1517 ) 1518 if err != nil { 1519 + l.Error("failed to fetch labels", "err", err) 1520 return 1521 } 1522 ··· 1527 1528 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1529 if err != nil { 1530 + l.Error("failed to build label state", "err", err) 1531 return 1532 } 1533 state := states[subject] ··· 1546 user := rp.oauth.GetUser(r) 1547 l := rp.logger.With("handler", "AddCollaborator") 1548 l = l.With("did", user.Did) 1549 1550 f, err := rp.repoResolver.Resolve(r) 1551 if err != nil { ··· 1592 currentUser := rp.oauth.GetUser(r) 1593 rkey := tid.TID() 1594 createdAt := time.Now() 1595 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1596 Collection: tangled.RepoCollaboratorNSID, 1597 Repo: currentUser.Did, 1598 Rkey: rkey, ··· 1674 1675 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1676 user := rp.oauth.GetUser(r) 1677 + l := rp.logger.With("handler", "DeleteRepo") 1678 1679 noticeId := "operation-error" 1680 f, err := rp.repoResolver.Resolve(r) 1681 if err != nil { 1682 + l.Error("failed to get repo and knot", "err", err) 1683 return 1684 } 1685 1686 // remove record from pds 1687 + atpClient, err := rp.oauth.AuthorizedClient(r) 1688 if err != nil { 1689 + l.Error("failed to get authorized client", "err", err) 1690 return 1691 } 1692 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1693 Collection: tangled.RepoNSID, 1694 Repo: user.Did, 1695 Rkey: f.Rkey, 1696 }) 1697 if err != nil { 1698 + l.Error("failed to delete record", "err", err) 1699 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1700 return 1701 } 1702 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1703 1704 client, err := rp.oauth.ServiceClient( 1705 r, ··· 1708 oauth.WithDev(rp.config.Core.Dev), 1709 ) 1710 if err != nil { 1711 + l.Error("failed to connect to knot server", "err", err) 1712 return 1713 } 1714 ··· 1725 rp.pages.Notice(w, noticeId, err.Error()) 1726 return 1727 } 1728 + l.Info("deleted repo from knot") 1729 1730 tx, err := rp.db.BeginTx(r.Context(), nil) 1731 if err != nil { 1732 + l.Error("failed to start tx") 1733 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1734 return 1735 } ··· 1737 tx.Rollback() 1738 err = rp.enforcer.E.LoadPolicy() 1739 if err != nil { 1740 + l.Error("failed to rollback policies") 1741 } 1742 }() 1743 ··· 1751 did := c[0] 1752 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1753 } 1754 + l.Info("removed collaborators") 1755 1756 // remove repo RBAC 1757 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1766 rp.pages.Notice(w, noticeId, "Failed to update appview") 1767 return 1768 } 1769 + l.Info("removed repo from db") 1770 1771 err = tx.Commit() 1772 if err != nil { 1773 + l.Error("failed to commit changes", "err", err) 1774 http.Error(w, err.Error(), http.StatusInternalServerError) 1775 return 1776 } 1777 1778 err = rp.enforcer.E.SavePolicy() 1779 if err != nil { 1780 + l.Error("failed to update ACLs", "err", err) 1781 http.Error(w, err.Error(), http.StatusInternalServerError) 1782 return 1783 } ··· 1786 } 1787 1788 func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 + l := rp.logger.With("handler", "SetDefaultBranch") 1790 + 1791 f, err := rp.repoResolver.Resolve(r) 1792 if err != nil { 1793 + l.Error("failed to get repo and knot", "err", err) 1794 return 1795 } 1796 ··· 1808 oauth.WithDev(rp.config.Core.Dev), 1809 ) 1810 if err != nil { 1811 + l.Error("failed to connect to knot server", "err", err) 1812 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1813 return 1814 } ··· 1822 }, 1823 ) 1824 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1825 + l.Error("xrpc failed", "err", xe) 1826 rp.pages.Notice(w, noticeId, err.Error()) 1827 return 1828 } ··· 1833 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1834 user := rp.oauth.GetUser(r) 1835 l := rp.logger.With("handler", "Secrets") 1836 l = l.With("did", user.Did) 1837 1838 f, err := rp.repoResolver.Resolve(r) 1839 if err != nil { 1840 + l.Error("failed to get repo and knot", "err", err) 1841 return 1842 } 1843 1844 if f.Spindle == "" { 1845 + l.Error("empty spindle cannot add/rm secret", "err", err) 1846 return 1847 } 1848 ··· 1859 oauth.WithDev(rp.config.Core.Dev), 1860 ) 1861 if err != nil { 1862 + l.Error("failed to create spindle client", "err", err) 1863 return 1864 } 1865 ··· 1945 } 1946 1947 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 + l := rp.logger.With("handler", "generalSettings") 1949 + 1950 f, err := rp.repoResolver.Resolve(r) 1951 user := rp.oauth.GetUser(r) 1952 ··· 1962 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1963 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1964 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1965 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1966 rp.pages.Error503(w) 1967 return 1968 } 1969 1970 var result types.RepoBranchesResponse 1971 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1972 + l.Error("failed to decode XRPC response", "err", err) 1973 rp.pages.Error503(w) 1974 return 1975 } 1976 1977 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1978 if err != nil { 1979 + l.Error("failed to fetch labels", "err", err) 1980 rp.pages.Error503(w) 1981 return 1982 } 1983 1984 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1985 if err != nil { 1986 + l.Error("failed to fetch labels", "err", err) 1987 rp.pages.Error503(w) 1988 return 1989 } ··· 2031 } 2032 2033 func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 + l := rp.logger.With("handler", "accessSettings") 2035 + 2036 f, err := rp.repoResolver.Resolve(r) 2037 user := rp.oauth.GetUser(r) 2038 2039 repoCollaborators, err := f.Collaborators(r.Context()) 2040 if err != nil { 2041 + l.Error("failed to get collaborators", "err", err) 2042 } 2043 2044 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ ··· 2051 } 2052 2053 func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 + l := rp.logger.With("handler", "pipelineSettings") 2055 + 2056 f, err := rp.repoResolver.Resolve(r) 2057 user := rp.oauth.GetUser(r) 2058 2059 // all spindles that the repo owner is a member of 2060 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2061 if err != nil { 2062 + l.Error("failed to fetch spindles", "err", err) 2063 return 2064 } 2065 ··· 2072 oauth.WithExp(60), 2073 oauth.WithDev(rp.config.Core.Dev), 2074 ); err != nil { 2075 + l.Error("failed to create spindle client", "err", err) 2076 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2077 + l.Error("failed to fetch secrets", "err", err) 2078 } else { 2079 secrets = resp.Secrets 2080 } ··· 2114 } 2115 2116 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 + l := rp.logger.With("handler", "SyncRepoFork") 2118 + 2119 ref := chi.URLParam(r, "ref") 2120 ref, _ = url.PathUnescape(ref) 2121 2122 user := rp.oauth.GetUser(r) 2123 f, err := rp.repoResolver.Resolve(r) 2124 if err != nil { 2125 + l.Error("failed to resolve source repo", "err", err) 2126 return 2127 } 2128 ··· 2166 } 2167 2168 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2169 + l := rp.logger.With("handler", "ForkRepo") 2170 + 2171 user := rp.oauth.GetUser(r) 2172 f, err := rp.repoResolver.Resolve(r) 2173 if err != nil { 2174 + l.Error("failed to resolve source repo", "err", err) 2175 return 2176 } 2177 ··· 2222 ) 2223 if err != nil { 2224 if !errors.Is(err, sql.ErrNoRows) { 2225 + l.Error("error fetching existing repo from db", "err", err) 2226 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2227 return 2228 } ··· 2257 } 2258 record := repo.AsRecord() 2259 2260 + atpClient, err := rp.oauth.AuthorizedClient(r) 2261 if err != nil { 2262 l.Error("failed to create xrpcclient", "err", err) 2263 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2264 return 2265 } 2266 2267 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2268 Collection: tangled.RepoNSID, 2269 Repo: user.Did, 2270 Rkey: rkey, ··· 2296 rollback := func() { 2297 err1 := tx.Rollback() 2298 err2 := rp.enforcer.E.LoadPolicy() 2299 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2300 2301 // ignore txn complete errors, this is okay 2302 if errors.Is(err1, sql.ErrTxDone) { ··· 2337 2338 err = db.AddRepo(tx, repo) 2339 if err != nil { 2340 + l.Error("failed to AddRepo", "err", err) 2341 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2342 return 2343 } ··· 2346 p, _ := securejoin.SecureJoin(user.Did, forkName) 2347 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2348 if err != nil { 2349 + l.Error("failed to add ACLs", "err", err) 2350 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2351 return 2352 } 2353 2354 err = tx.Commit() 2355 if err != nil { 2356 + l.Error("failed to commit changes", "err", err) 2357 http.Error(w, err.Error(), http.StatusInternalServerError) 2358 return 2359 } 2360 2361 err = rp.enforcer.E.SavePolicy() 2362 if err != nil { 2363 + l.Error("failed to update ACLs", "err", err) 2364 http.Error(w, err.Error(), http.StatusInternalServerError) 2365 return 2366 } ··· 2369 aturi = "" 2370 2371 rp.notifier.NewRepo(r.Context(), repo) 2372 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2373 } 2374 } 2375 2376 // this is used to rollback changes made to the PDS 2377 // 2378 // it is a no-op if the provided ATURI is empty 2379 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2380 if aturi == "" { 2381 return nil 2382 } ··· 2387 repo := parsed.Authority().String() 2388 rkey := parsed.RecordKey().String() 2389 2390 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2391 Collection: collection, 2392 Repo: repo, 2393 Rkey: rkey, ··· 2396 } 2397 2398 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 + l := rp.logger.With("handler", "RepoCompareNew") 2400 + 2401 user := rp.oauth.GetUser(r) 2402 f, err := rp.repoResolver.Resolve(r) 2403 if err != nil { 2404 + l.Error("failed to get repo and knot", "err", err) 2405 return 2406 } 2407 ··· 2417 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2418 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2419 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2420 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2421 rp.pages.Error503(w) 2422 return 2423 } 2424 2425 var branchResult types.RepoBranchesResponse 2426 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2427 + l.Error("failed to decode XRPC branches response", "err", err) 2428 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2429 return 2430 } ··· 2454 2455 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2457 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2458 rp.pages.Error503(w) 2459 return 2460 } 2461 2462 var tags types.RepoTagsResponse 2463 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2464 + l.Error("failed to decode XRPC tags response", "err", err) 2465 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2466 return 2467 } ··· 2479 } 2480 2481 func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 + l := rp.logger.With("handler", "RepoCompare") 2483 + 2484 user := rp.oauth.GetUser(r) 2485 f, err := rp.repoResolver.Resolve(r) 2486 if err != nil { 2487 + l.Error("failed to get repo and knot", "err", err) 2488 return 2489 } 2490 ··· 2511 head, _ = url.PathUnescape(head) 2512 2513 if base == "" || head == "" { 2514 + l.Error("invalid comparison") 2515 rp.pages.Error404(w) 2516 return 2517 } ··· 2529 2530 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2531 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2532 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2533 rp.pages.Error503(w) 2534 return 2535 } 2536 2537 var branches types.RepoBranchesResponse 2538 if err := json.Unmarshal(branchBytes, &branches); err != nil { 2539 + l.Error("failed to decode XRPC branches response", "err", err) 2540 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2541 return 2542 } 2543 2544 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2545 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2546 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2547 rp.pages.Error503(w) 2548 return 2549 } 2550 2551 var tags types.RepoTagsResponse 2552 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2553 + l.Error("failed to decode XRPC tags response", "err", err) 2554 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2555 return 2556 } 2557 2558 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2560 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2561 rp.pages.Error503(w) 2562 return 2563 } 2564 2565 var formatPatch types.RepoFormatPatchResponse 2566 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2567 + l.Error("failed to decode XRPC compare response", "err", err) 2568 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2569 return 2570 } 2571 2572 + var diff types.NiceDiff 2573 + if formatPatch.CombinedPatchRaw != "" { 2574 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2575 + } else { 2576 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2577 + } 2578 2579 repoinfo := f.RepoInfo(user) 2580
+2
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) { ··· 18 }) 19 r.Get("/commit/{ref}", rp.RepoCommit) 20 r.Get("/branches", rp.RepoBranches) 21 r.Route("/tags", func(r chi.Router) { 22 r.Get("/", rp.RepoTags) 23 r.Route("/{tag}", func(r chi.Router) {
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 r.Get("/feed.atom", rp.RepoAtomFeed) 15 r.Get("/commits/{ref}", rp.RepoLog) 16 r.Route("/tree/{ref}", func(r chi.Router) { ··· 19 }) 20 r.Get("/commit/{ref}", rp.RepoCommit) 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 r.Get("/", rp.RepoTags) 25 r.Route("/{tag}", func(r chi.Router) {
+5 -4
appview/settings/settings.go
··· 22 "tangled.org/core/tid" 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/gliderlabs/ssh" 27 "github.com/google/uuid" ··· 91 user := s.OAuth.GetUser(r) 92 did := s.OAuth.GetDid(r) 93 94 - prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 if err != nil { 96 log.Printf("failed to get notification preferences: %s", err) 97 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") ··· 110 did := s.OAuth.GetDid(r) 111 112 prefs := &models.NotificationPreferences{ 113 - UserDid: did, 114 RepoStarred: r.FormValue("repo_starred") == "on", 115 IssueCreated: r.FormValue("issue_created") == "on", 116 IssueCommented: r.FormValue("issue_commented") == "on", ··· 470 } 471 472 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 474 Collection: tangled.PublicKeyNSID, 475 Repo: did, 476 Rkey: rkey, ··· 527 528 if rkey != "" { 529 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 531 Collection: tangled.PublicKeyNSID, 532 Repo: did, 533 Rkey: rkey,
··· 22 "tangled.org/core/tid" 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 lexutil "github.com/bluesky-social/indigo/lex/util" 27 "github.com/gliderlabs/ssh" 28 "github.com/google/uuid" ··· 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.") ··· 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", ··· 471 } 472 473 // store in pds too 474 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 475 Collection: tangled.PublicKeyNSID, 476 Repo: did, 477 Rkey: rkey, ··· 528 529 if rkey != "" { 530 // remove from pds too 531 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 532 Collection: tangled.PublicKeyNSID, 533 Repo: did, 534 Rkey: rkey,
+18
appview/signup/requests.go
··· 102 103 return result.DID, nil 104 }
··· 102 103 return result.DID, nil 104 } 105 + 106 + func (s *Signup) deleteAccountRequest(did string) error { 107 + body := map[string]string{ 108 + "did": did, 109 + } 110 + 111 + resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true) 112 + if err != nil { 113 + return err 114 + } 115 + defer resp.Body.Close() 116 + 117 + if resp.StatusCode != http.StatusOK { 118 + return s.handlePdsError(resp, "delete account") 119 + } 120 + 121 + return nil 122 + }
+95 -40
appview/signup/signup.go
··· 2 3 import ( 4 "bufio" 5 "encoding/json" 6 "errors" 7 "fmt" ··· 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/state/userutil" 23 - "tangled.org/core/appview/xrpcclient" 24 "tangled.org/core/idresolver" 25 ) 26 ··· 29 db *db.DB 30 cf *dns.Cloudflare 31 posthog posthog.Client 32 - xrpc *xrpcclient.Client 33 idResolver *idresolver.Resolver 34 pages *pages.Pages 35 l *slog.Logger ··· 64 disallowed := make(map[string]bool) 65 66 if filepath == "" { 67 - logger.Debug("no disallowed nicknames file configured") 68 return disallowed 69 } 70 ··· 133 noticeId := "signup-msg" 134 135 if err := s.validateCaptcha(cfToken, r); err != nil { 136 - s.l.Warn("turnstile validation failed", "error", err) 137 s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 return 139 } ··· 218 return 219 } 220 221 - did, err := s.createAccountRequest(username, password, email, code) 222 - if err != nil { 223 - s.l.Error("failed to create account", "error", err) 224 - s.pages.Notice(w, "signup-error", err.Error()) 225 - return 226 - } 227 - 228 if s.cf == nil { 229 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 230 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 231 return 232 } 233 234 - err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 235 - Type: "TXT", 236 - Name: "_atproto." + username, 237 - Content: fmt.Sprintf(`"did=%s"`, did), 238 - TTL: 6400, 239 - Proxied: false, 240 - }) 241 if err != nil { 242 - s.l.Error("failed to create DNS record", "error", err) 243 - s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 244 return 245 } 246 247 - err = db.AddEmail(s.db, models.Email{ 248 - Did: did, 249 - Address: email, 250 - Verified: true, 251 - Primary: true, 252 - }) 253 - if err != nil { 254 - s.l.Error("failed to add email", "error", err) 255 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 256 - return 257 } 258 259 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 260 - <a class="underline text-black dark:text-white" href="/login">login</a> 261 - with <code>%s.tngl.sh</code>.`, username)) 262 263 - go func() { 264 - err := db.DeleteInflightSignup(s.db, email) 265 - if err != nil { 266 - s.l.Error("failed to delete inflight signup", "error", err) 267 - } 268 - }() 269 - return 270 } 271 } 272 273 type turnstileResponse struct {
··· 2 3 import ( 4 "bufio" 5 + "context" 6 "encoding/json" 7 "errors" 8 "fmt" ··· 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/state/userutil" 24 "tangled.org/core/idresolver" 25 ) 26 ··· 29 db *db.DB 30 cf *dns.Cloudflare 31 posthog posthog.Client 32 idResolver *idresolver.Resolver 33 pages *pages.Pages 34 l *slog.Logger ··· 63 disallowed := make(map[string]bool) 64 65 if filepath == "" { 66 + logger.Warn("no disallowed nicknames file configured") 67 return disallowed 68 } 69 ··· 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 } ··· 217 return 218 } 219 220 if s.cf == nil { 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 223 return 224 } 225 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 228 if err != nil { 229 + // Error already logged and notice already sent 230 return 231 } 232 + } 233 + } 234 235 + // executeSignupTransaction performs the signup process transactionally with rollback 236 + func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 237 + var recordID string 238 + var did string 239 + var emailAdded bool 240 + 241 + success := false 242 + defer func() { 243 + if !success { 244 + s.l.Info("rolling back signup transaction", "username", username, "did", did) 245 + 246 + // Rollback DNS record 247 + if recordID != "" { 248 + if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 249 + s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 250 + } else { 251 + s.l.Info("successfully rolled back DNS record", "recordID", recordID) 252 + } 253 + } 254 + 255 + // Rollback PDS account 256 + if did != "" { 257 + if err := s.deleteAccountRequest(did); err != nil { 258 + s.l.Error("failed to rollback PDS account", "error", err, "did", did) 259 + } else { 260 + s.l.Info("successfully rolled back PDS account", "did", did) 261 + } 262 + } 263 + 264 + // Rollback email from database 265 + if emailAdded { 266 + if err := db.DeleteEmail(s.db, did, email); err != nil { 267 + s.l.Error("failed to rollback email from database", "error", err, "email", email) 268 + } else { 269 + s.l.Info("successfully rolled back email from database", "email", email) 270 + } 271 + } 272 } 273 + }() 274 275 + // step 1: create account in PDS 276 + did, err := s.createAccountRequest(username, password, email, code) 277 + if err != nil { 278 + s.l.Error("failed to create account", "error", err) 279 + s.pages.Notice(w, "signup-error", err.Error()) 280 + return err 281 + } 282 283 + // step 2: create DNS record with actual DID 284 + recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 + Type: "TXT", 286 + Name: "_atproto." + username, 287 + Content: fmt.Sprintf(`"did=%s"`, did), 288 + TTL: 6400, 289 + Proxied: false, 290 + }) 291 + if err != nil { 292 + s.l.Error("failed to create DNS record", "error", err) 293 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 + return err 295 + } 296 + 297 + // step 3: add email to database 298 + err = db.AddEmail(s.db, models.Email{ 299 + Did: did, 300 + Address: email, 301 + Verified: true, 302 + Primary: true, 303 + }) 304 + if err != nil { 305 + s.l.Error("failed to add email", "error", err) 306 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 307 + return err 308 } 309 + emailAdded = true 310 + 311 + // if we get here, we've successfully created the account and added the email 312 + success = true 313 + 314 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 315 + <a class="underline text-black dark:text-white" href="/login">login</a> 316 + with <code>%s.tngl.sh</code>.`, username)) 317 + 318 + // clean up inflight signup asynchronously 319 + go func() { 320 + if err := db.DeleteInflightSignup(s.db, email); err != nil { 321 + s.l.Error("failed to delete inflight signup", "error", err) 322 + } 323 + }() 324 + 325 + return nil 326 } 327 328 type turnstileResponse struct {
+5 -5
appview/spindles/spindles.go
··· 189 return 190 } 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
··· 189 return 190 } 191 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
+3 -2
appview/state/follow.go
··· 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 if err != nil { 28 log.Println("failed to follow, invalid did") 29 } 30 31 if currentUser.Did == subjectIdent.DID.String() { ··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
··· 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 if err != nil { 28 log.Println("failed to follow, invalid did") 29 + return 30 } 31 32 if currentUser.Did == subjectIdent.DID.String() { ··· 44 case http.MethodPost: 45 createdAt := time.Now().Format(time.RFC3339) 46 rkey := tid.TID() 47 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 Collection: tangled.GraphFollowNSID, 49 Repo: currentUser.Did, 50 Rkey: rkey, ··· 89 return 90 } 91 92 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 93 Collection: tangled.GraphFollowNSID, 94 Repo: currentUser.Did, 95 Rkey: follow.Rkey,
+148
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 := pagination.FromContext(r.Context()) 22 + 23 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 24 + 25 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 26 + if err != nil { 27 + log.Println("failed to get repo labels", err) 28 + s.pages.Error503(w) 29 + return 30 + } 31 + 32 + if len(repoLabels) == 0 { 33 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 34 + LoggedInUser: user, 35 + RepoGroups: []*models.RepoGroup{}, 36 + LabelDefs: make(map[string]*models.LabelDefinition), 37 + Page: page, 38 + }) 39 + return 40 + } 41 + 42 + repoUris := make([]string, 0, len(repoLabels)) 43 + for _, rl := range repoLabels { 44 + repoUris = append(repoUris, rl.RepoAt.String()) 45 + } 46 + 47 + allIssues, err := db.GetIssuesPaginated( 48 + s.db, 49 + pagination.Page{ 50 + Limit: 500, 51 + }, 52 + db.FilterIn("repo_at", repoUris), 53 + db.FilterEq("open", 1), 54 + ) 55 + if err != nil { 56 + log.Println("failed to get issues", err) 57 + s.pages.Error503(w) 58 + return 59 + } 60 + 61 + var goodFirstIssues []models.Issue 62 + for _, issue := range allIssues { 63 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 64 + goodFirstIssues = append(goodFirstIssues, issue) 65 + } 66 + } 67 + 68 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 69 + for _, issue := range goodFirstIssues { 70 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 71 + group.Issues = append(group.Issues, issue) 72 + } else { 73 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 74 + Repo: issue.Repo, 75 + Issues: []models.Issue{issue}, 76 + } 77 + } 78 + } 79 + 80 + var sortedGroups []*models.RepoGroup 81 + for _, group := range repoGroups { 82 + sortedGroups = append(sortedGroups, group) 83 + } 84 + 85 + sort.Slice(sortedGroups, func(i, j int) bool { 86 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 87 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 88 + 89 + // If one is tangled and the other isn't, non-tangled comes first 90 + if iIsTangled != jIsTangled { 91 + return jIsTangled // true if j is tangled (i should come first) 92 + } 93 + 94 + // Both tangled or both not tangled: sort by name 95 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 96 + }) 97 + 98 + groupStart := page.Offset 99 + groupEnd := page.Offset + page.Limit 100 + if groupStart > len(sortedGroups) { 101 + groupStart = len(sortedGroups) 102 + } 103 + if groupEnd > len(sortedGroups) { 104 + groupEnd = len(sortedGroups) 105 + } 106 + 107 + paginatedGroups := sortedGroups[groupStart:groupEnd] 108 + 109 + var allIssuesFromGroups []models.Issue 110 + for _, group := range paginatedGroups { 111 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 112 + } 113 + 114 + var allLabelDefs []models.LabelDefinition 115 + if len(allIssuesFromGroups) > 0 { 116 + labelDefUris := make(map[string]bool) 117 + for _, issue := range allIssuesFromGroups { 118 + for labelDefUri := range issue.Labels.Inner() { 119 + labelDefUris[labelDefUri] = true 120 + } 121 + } 122 + 123 + uriList := make([]string, 0, len(labelDefUris)) 124 + for uri := range labelDefUris { 125 + uriList = append(uriList, uri) 126 + } 127 + 128 + if len(uriList) > 0 { 129 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 130 + if err != nil { 131 + log.Println("failed to fetch labels", err) 132 + } 133 + } 134 + } 135 + 136 + labelDefsMap := make(map[string]*models.LabelDefinition) 137 + for i := range allLabelDefs { 138 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 139 + } 140 + 141 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 142 + LoggedInUser: user, 143 + RepoGroups: paginatedGroups, 144 + LabelDefs: labelDefsMap, 145 + Page: page, 146 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 147 + }) 148 + }
+17 -2
appview/state/knotstream.go
··· 25 ) 26 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 knots, err := db.GetRegistrations( 29 d, 30 db.FilterIsNot("registered", "null"), ··· 39 srcs[s] = struct{}{} 40 } 41 42 - logger := log.New("knotstream") 43 cache := cache.New(c.Redis.Addr) 44 cursorStore := cursor.NewRedisCursorStore(cache) 45 ··· 172 }) 173 } 174 175 - return db.InsertRepoLanguages(d, langs) 176 } 177 178 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
··· 25 ) 26 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 + logger := log.FromContext(ctx) 29 + logger = log.SubLogger(logger, "knotstream") 30 + 31 knots, err := db.GetRegistrations( 32 d, 33 db.FilterIsNot("registered", "null"), ··· 42 srcs[s] = struct{}{} 43 } 44 45 cache := cache.New(c.Redis.Addr) 46 cursorStore := cursor.NewRedisCursorStore(cache) 47 ··· 174 }) 175 } 176 177 + tx, err := d.Begin() 178 + if err != nil { 179 + return err 180 + } 181 + defer tx.Rollback() 182 + 183 + // update appview's cache 184 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 185 + if err != nil { 186 + fmt.Printf("failed; %s\n", err) 187 + // non-fatal 188 + } 189 + 190 + return tx.Commit() 191 } 192 193 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+68
appview/state/login.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + 8 + "tangled.org/core/appview/pages" 9 + ) 10 + 11 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 12 + l := s.logger.With("handler", "Login") 13 + 14 + switch r.Method { 15 + case http.MethodGet: 16 + returnURL := r.URL.Query().Get("return_url") 17 + errorCode := r.URL.Query().Get("error") 18 + s.pages.Login(w, pages.LoginParams{ 19 + ReturnUrl: returnURL, 20 + ErrorCode: errorCode, 21 + }) 22 + case http.MethodPost: 23 + handle := r.FormValue("handle") 24 + 25 + // when users copy their handle from bsky.app, it tends to have these characters around it: 26 + // 27 + // @nelind.dk: 28 + // \u202a ensures that the handle is always rendered left to right and 29 + // \u202c reverts that so the rest of the page renders however it should 30 + handle = strings.TrimPrefix(handle, "\u202a") 31 + handle = strings.TrimSuffix(handle, "\u202c") 32 + 33 + // `@` is harmless 34 + handle = strings.TrimPrefix(handle, "@") 35 + 36 + // basic handle validation 37 + if !strings.Contains(handle, ".") { 38 + l.Error("invalid handle format", "raw", handle) 39 + s.pages.Notice( 40 + w, 41 + "login-msg", 42 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 43 + ) 44 + return 45 + } 46 + 47 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 + if err != nil { 49 + http.Error(w, err.Error(), http.StatusInternalServerError) 50 + return 51 + } 52 + 53 + s.pages.HxRedirect(w, redirectURL) 54 + } 55 + } 56 + 57 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 58 + l := s.logger.With("handler", "Logout") 59 + 60 + err := s.oauth.DeleteSession(w, r) 61 + if err != nil { 62 + l.Error("failed to logout", "err", err) 63 + } else { 64 + l.Info("logged out successfully") 65 + } 66 + 67 + s.pages.HxRedirect(w, "/login") 68 + }
+2 -2
appview/state/profile.go
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 - Count: count, 84 IsReacted: true, 85 }) 86 ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedReactionNSID, 97 Repo: currentUser.Did, 98 Rkey: reaction.Rkey, ··· 109 // this is not an issue, the firehose event might have already done this 110 } 111 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 return 116 } 117 118 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 ThreadAt: subjectUri, 120 Kind: reactionKind, 121 - Count: count, 122 IsReacted: false, 123 }) 124
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 if err != nil { 75 + log.Println("failed to get reactions for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 85 IsReacted: true, 86 }) 87 ··· 93 return 94 } 95 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 Collection: tangled.FeedReactionNSID, 98 Repo: currentUser.Did, 99 Rkey: reaction.Rkey, ··· 110 // this is not an issue, the firehose event might have already done this 111 } 112 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 114 if err != nil { 115 + log.Println("failed to get reactions for ", subjectUri) 116 return 117 } 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 ThreadAt: subjectUri, 121 Kind: reactionKind, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 124 IsReacted: false, 125 }) 126
+69 -23
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 "tangled.org/core/appview/issues" 10 "tangled.org/core/appview/knots" 11 "tangled.org/core/appview/labels" 12 "tangled.org/core/appview/middleware" 13 "tangled.org/core/appview/notifications" 14 - oauthhandler "tangled.org/core/appview/oauth/handler" 15 "tangled.org/core/appview/pipelines" 16 "tangled.org/core/appview/pulls" 17 "tangled.org/core/appview/repo" ··· 34 s.pages, 35 ) 36 37 - router.Use(middleware.TryRefreshSession()) 38 router.Get("/favicon.svg", s.Favicon) 39 router.Get("/favicon.ico", s.Favicon) 40 41 userRouter := s.UserRouter(&middleware) 42 standardRouter := s.StandardRouter(&middleware) ··· 122 // special-case handler for serving tangled.org/core 123 r.Get("/core", s.Core()) 124 125 r.Route("/repo", func(r chi.Router) { 126 r.Route("/new", func(r chi.Router) { 127 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 130 }) 131 // r.Post("/import", s.ImportRepo) 132 }) 133 134 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 135 r.Post("/", s.Follow) ··· 161 r.Mount("/notifications", s.NotificationsRouter(mw)) 162 163 r.Mount("/signup", s.SignupRouter()) 164 - r.Mount("/", s.OAuthRouter()) 165 166 r.Get("/keys/{user}", s.Keys) 167 r.Get("/terms", s.TermsOfService) ··· 188 } 189 } 190 191 - func (s *State) OAuthRouter() http.Handler { 192 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 193 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 194 - return oauth.Router() 195 - } 196 - 197 func (s *State) SettingsRouter() http.Handler { 198 settings := &settings.Settings{ 199 Db: s.db, ··· 206 } 207 208 func (s *State) SpindlesRouter() http.Handler { 209 - logger := log.New("spindles") 210 211 spindles := &spindles.Spindles{ 212 Db: s.db, ··· 222 } 223 224 func (s *State) KnotsRouter() http.Handler { 225 - logger := log.New("knots") 226 227 knots := &knots.Knots{ 228 Db: s.db, ··· 239 } 240 241 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 242 - logger := log.New("strings") 243 244 strs := &avstrings.Strings{ 245 Db: s.db, ··· 254 } 255 256 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 257 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 258 return issues.Router(mw) 259 } 260 261 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 262 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 263 return pulls.Router(mw) 264 } 265 266 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 267 - logger := log.New("repo") 268 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 269 return repo.Router(mw) 270 } 271 272 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 273 - pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 274 return pipes.Router(mw) 275 } 276 277 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 278 - ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 279 return ls.Router(mw) 280 } 281 282 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 283 - notifs := notifications.New(s.db, s.oauth, s.pages) 284 return notifs.Router(mw) 285 } 286 287 func (s *State) SignupRouter() http.Handler { 288 - logger := log.New("signup") 289 - 290 - sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 291 return sig.Router() 292 }
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.org/core/appview/issues" 9 "tangled.org/core/appview/knots" 10 "tangled.org/core/appview/labels" 11 "tangled.org/core/appview/middleware" 12 "tangled.org/core/appview/notifications" 13 "tangled.org/core/appview/pipelines" 14 "tangled.org/core/appview/pulls" 15 "tangled.org/core/appview/repo" ··· 32 s.pages, 33 ) 34 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware) 41 standardRouter := s.StandardRouter(&middleware) ··· 121 // special-case handler for serving tangled.org/core 122 r.Get("/core", s.Core()) 123 124 + r.Get("/login", s.Login) 125 + r.Post("/login", s.Login) 126 + r.Post("/logout", s.Logout) 127 + 128 r.Route("/repo", func(r chi.Router) { 129 r.Route("/new", func(r chi.Router) { 130 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 133 }) 134 // r.Post("/import", s.ImportRepo) 135 }) 136 + 137 + r.Get("/goodfirstissues", s.GoodFirstIssues) 138 139 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 140 r.Post("/", s.Follow) ··· 166 r.Mount("/notifications", s.NotificationsRouter(mw)) 167 168 r.Mount("/signup", s.SignupRouter()) 169 + r.Mount("/", s.oauth.Router()) 170 171 r.Get("/keys/{user}", s.Keys) 172 r.Get("/terms", s.TermsOfService) ··· 193 } 194 } 195 196 func (s *State) SettingsRouter() http.Handler { 197 settings := &settings.Settings{ 198 Db: s.db, ··· 205 } 206 207 func (s *State) SpindlesRouter() http.Handler { 208 + logger := log.SubLogger(s.logger, "spindles") 209 210 spindles := &spindles.Spindles{ 211 Db: s.db, ··· 221 } 222 223 func (s *State) KnotsRouter() http.Handler { 224 + logger := log.SubLogger(s.logger, "knots") 225 226 knots := &knots.Knots{ 227 Db: s.db, ··· 238 } 239 240 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 241 + logger := log.SubLogger(s.logger, "strings") 242 243 strs := &avstrings.Strings{ 244 Db: s.db, ··· 253 } 254 255 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 256 + issues := issues.New( 257 + s.oauth, 258 + s.repoResolver, 259 + s.pages, 260 + s.idResolver, 261 + s.db, 262 + s.config, 263 + s.notifier, 264 + s.validator, 265 + log.SubLogger(s.logger, "issues"), 266 + ) 267 return issues.Router(mw) 268 } 269 270 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 271 + pulls := pulls.New( 272 + s.oauth, 273 + s.repoResolver, 274 + s.pages, 275 + s.idResolver, 276 + s.db, 277 + s.config, 278 + s.notifier, 279 + s.enforcer, 280 + s.validator, 281 + log.SubLogger(s.logger, "pulls"), 282 + ) 283 return pulls.Router(mw) 284 } 285 286 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 287 + repo := repo.New( 288 + s.oauth, 289 + s.repoResolver, 290 + s.pages, 291 + s.spindlestream, 292 + s.idResolver, 293 + s.db, 294 + s.config, 295 + s.notifier, 296 + s.enforcer, 297 + log.SubLogger(s.logger, "repo"), 298 + s.validator, 299 + ) 300 return repo.Router(mw) 301 } 302 303 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 304 + pipes := pipelines.New( 305 + s.oauth, 306 + s.repoResolver, 307 + s.pages, 308 + s.spindlestream, 309 + s.idResolver, 310 + s.db, 311 + s.config, 312 + s.enforcer, 313 + log.SubLogger(s.logger, "pipelines"), 314 + ) 315 return pipes.Router(mw) 316 } 317 318 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 319 + ls := labels.New( 320 + s.oauth, 321 + s.pages, 322 + s.db, 323 + s.validator, 324 + s.enforcer, 325 + log.SubLogger(s.logger, "labels"), 326 + ) 327 return ls.Router(mw) 328 } 329 330 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 331 + notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 332 return notifs.Router(mw) 333 } 334 335 func (s *State) SignupRouter() http.Handler { 336 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 337 return sig.Router() 338 }
+3 -1
appview/state/spindlestream.go
··· 22 ) 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 spindles, err := db.GetSpindles( 26 d, 27 db.FilterIsNot("verified", "null"), ··· 36 srcs[src] = struct{}{} 37 } 38 39 - logger := log.New("spindlestream") 40 cache := cache.New(c.Redis.Addr) 41 cursorStore := cursor.NewRedisCursorStore(cache) 42
··· 22 ) 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 + logger := log.FromContext(ctx) 26 + logger = log.SubLogger(logger, "spindlestream") 27 + 28 spindles, err := db.GetSpindles( 29 d, 30 db.FilterIsNot("verified", "null"), ··· 39 srcs[src] = struct{}{} 40 } 41 42 cache := cache.New(c.Redis.Addr) 43 cursorStore := cursor.NewRedisCursorStore(cache) 44
+2 -2
appview/state/star.go
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
+81 -37
appview/state/state.go
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 - "log" 9 "log/slog" 10 "net/http" 11 "strings" 12 "time" 13 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview" 22 - "tangled.org/core/appview/cache" 23 - "tangled.org/core/appview/cache/session" 24 "tangled.org/core/appview/config" 25 "tangled.org/core/appview/db" 26 "tangled.org/core/appview/models" ··· 35 "tangled.org/core/eventconsumer" 36 "tangled.org/core/idresolver" 37 "tangled.org/core/jetstream" 38 tlog "tangled.org/core/log" 39 "tangled.org/core/rbac" 40 "tangled.org/core/tid" 41 ) 42 43 type State struct { ··· 46 oauth *oauth.OAuth 47 enforcer *rbac.Enforcer 48 pages *pages.Pages 49 - sess *session.SessionStore 50 idResolver *idresolver.Resolver 51 posthog posthog.Client 52 jc *jetstream.JetstreamClient ··· 59 } 60 61 func Make(ctx context.Context, config *config.Config) (*State, error) { 62 - d, err := db.Make(config.Core.DbPath) 63 if err != nil { 64 return nil, fmt.Errorf("failed to create db: %w", err) 65 } ··· 71 72 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 73 if err != nil { 74 - log.Printf("failed to create redis resolver: %v", err) 75 res = idresolver.DefaultResolver() 76 } 77 78 - pgs := pages.NewPages(config, res) 79 - cache := cache.New(config.Redis.Addr) 80 - sess := session.New(cache) 81 - oauth := oauth.NewOAuth(config, sess) 82 - validator := validator.New(d, res, enforcer) 83 - 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 85 if err != nil { 86 return nil, fmt.Errorf("failed to create posthog client: %w", err) 87 } 88 89 repoResolver := reporesolver.New(config, enforcer, res, d) 90 ··· 107 tangled.LabelOpNSID, 108 }, 109 nil, 110 - slog.Default(), 111 wrapper, 112 false, 113 ··· 128 Enforcer: enforcer, 129 IdResolver: res, 130 Config: config, 131 - Logger: tlog.New("ingester"), 132 Validator: validator, 133 } 134 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 164 notifier, 165 oauth, 166 enforcer, 167 - pgs, 168 - sess, 169 res, 170 posthog, 171 jc, ··· 173 repoResolver, 174 knotstream, 175 spindlestream, 176 - slog.Default(), 177 validator, 178 } 179 ··· 198 s.pages.Favicon(w) 199 } 200 201 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 202 user := s.oauth.GetUser(r) 203 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 230 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 231 user := s.oauth.GetUser(r) 232 233 var userDid string 234 if user != nil { 235 userDid = user.Did 236 } 237 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 238 if err != nil { 239 - log.Println(err) 240 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 241 } 242 243 repos, err := db.GetTopStarredReposLastWeek(s.db) 244 if err != nil { 245 - log.Println(err) 246 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 247 return 248 } 249 250 s.pages.Timeline(w, pages.TimelineParams{ 251 LoggedInUser: user, 252 Timeline: timeline, 253 Repos: repos, 254 }) 255 } 256 ··· 262 263 l := s.logger.With("handler", "UpgradeBanner") 264 l = l.With("did", user.Did) 265 - l = l.With("handle", user.Handle) 266 267 regs, err := db.GetRegistrations( 268 s.db, ··· 293 } 294 295 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 296 - timeline, err := db.MakeTimeline(s.db, 5, "") 297 if err != nil { 298 - log.Println(err) 299 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 300 return 301 } 302 303 repos, err := db.GetTopStarredReposLastWeek(s.db) 304 if err != nil { 305 - log.Println(err) 306 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 307 return 308 } ··· 402 403 user := s.oauth.GetUser(r) 404 l = l.With("did", user.Did) 405 - l = l.With("handle", user.Handle) 406 407 // form validation 408 domain := r.FormValue("domain") ··· 466 } 467 record := repo.AsRecord() 468 469 - xrpcClient, err := s.oauth.AuthorizedClient(r) 470 if err != nil { 471 l.Info("PDS write failed", "err", err) 472 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 473 return 474 } 475 476 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 477 Collection: tangled.RepoNSID, 478 Repo: user.Did, 479 Rkey: rkey, ··· 505 rollback := func() { 506 err1 := tx.Rollback() 507 err2 := s.enforcer.E.LoadPolicy() 508 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 509 510 // ignore txn complete errors, this is okay 511 if errors.Is(err1, sql.ErrTxDone) { ··· 578 aturi = "" 579 580 s.notifier.NewRepo(r.Context(), repo) 581 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 582 } 583 } 584 585 // this is used to rollback changes made to the PDS 586 // 587 // it is a no-op if the provided ATURI is empty 588 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 589 if aturi == "" { 590 return nil 591 } ··· 596 repo := parsed.Authority().String() 597 rkey := parsed.RecordKey().String() 598 599 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 600 Collection: collection, 601 Repo: repo, 602 Rkey: rkey,
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "strings" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" ··· 26 "tangled.org/core/eventconsumer" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/jetstream" 29 + "tangled.org/core/log" 30 tlog "tangled.org/core/log" 31 "tangled.org/core/rbac" 32 "tangled.org/core/tid" 33 + 34 + comatproto "github.com/bluesky-social/indigo/api/atproto" 35 + atpclient "github.com/bluesky-social/indigo/atproto/client" 36 + "github.com/bluesky-social/indigo/atproto/syntax" 37 + lexutil "github.com/bluesky-social/indigo/lex/util" 38 + securejoin "github.com/cyphar/filepath-securejoin" 39 + "github.com/go-chi/chi/v5" 40 + "github.com/posthog/posthog-go" 41 ) 42 43 type State struct { ··· 46 oauth *oauth.OAuth 47 enforcer *rbac.Enforcer 48 pages *pages.Pages 49 idResolver *idresolver.Resolver 50 posthog posthog.Client 51 jc *jetstream.JetstreamClient ··· 58 } 59 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 61 + logger := tlog.FromContext(ctx) 62 + 63 + d, err := db.Make(ctx, config.Core.DbPath) 64 if err != nil { 65 return nil, fmt.Errorf("failed to create db: %w", err) 66 } ··· 72 73 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 74 if err != nil { 75 + logger.Error("failed to create redis resolver", "err", err) 76 res = idresolver.DefaultResolver() 77 } 78 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 80 if err != nil { 81 return nil, fmt.Errorf("failed to create posthog client: %w", err) 82 } 83 + 84 + pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 85 + oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 86 + if err != nil { 87 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 88 + } 89 + validator := validator.New(d, res, enforcer) 90 91 repoResolver := reporesolver.New(config, enforcer, res, d) 92 ··· 109 tangled.LabelOpNSID, 110 }, 111 nil, 112 + tlog.SubLogger(logger, "jetstream"), 113 wrapper, 114 false, 115 ··· 130 Enforcer: enforcer, 131 IdResolver: res, 132 Config: config, 133 + Logger: log.SubLogger(logger, "ingester"), 134 Validator: validator, 135 } 136 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 166 notifier, 167 oauth, 168 enforcer, 169 + pages, 170 res, 171 posthog, 172 jc, ··· 174 repoResolver, 175 knotstream, 176 spindlestream, 177 + logger, 178 validator, 179 } 180 ··· 199 s.pages.Favicon(w) 200 } 201 202 + func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 203 + w.Header().Set("Content-Type", "text/plain") 204 + w.Header().Set("Cache-Control", "public, max-age=86400") // one day 205 + 206 + robotsTxt := `User-agent: * 207 + Allow: / 208 + ` 209 + w.Write([]byte(robotsTxt)) 210 + } 211 + 212 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 213 + const manifestJson = `{ 214 + "name": "tangled", 215 + "description": "tightly-knit social coding.", 216 + "icons": [ 217 + { 218 + "src": "/favicon.svg", 219 + "sizes": "144x144" 220 + } 221 + ], 222 + "start_url": "/", 223 + "id": "org.tangled", 224 + 225 + "display": "standalone", 226 + "background_color": "#111827", 227 + "theme_color": "#111827" 228 + }` 229 + 230 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 231 + w.Header().Set("Content-Type", "application/json") 232 + w.Write([]byte(manifestJson)) 233 + } 234 + 235 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 236 user := s.oauth.GetUser(r) 237 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 264 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 265 user := s.oauth.GetUser(r) 266 267 + // TODO: set this flag based on the UI 268 + filtered := false 269 + 270 var userDid string 271 if user != nil { 272 userDid = user.Did 273 } 274 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 275 if err != nil { 276 + s.logger.Error("failed to make timeline", "err", err) 277 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 278 } 279 280 repos, err := db.GetTopStarredReposLastWeek(s.db) 281 if err != nil { 282 + s.logger.Error("failed to get top starred repos", "err", err) 283 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 284 return 285 } 286 287 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 288 + if err != nil { 289 + // non-fatal 290 + } 291 + 292 s.pages.Timeline(w, pages.TimelineParams{ 293 LoggedInUser: user, 294 Timeline: timeline, 295 Repos: repos, 296 + GfiLabel: gfiLabel, 297 }) 298 } 299 ··· 305 306 l := s.logger.With("handler", "UpgradeBanner") 307 l = l.With("did", user.Did) 308 309 regs, err := db.GetRegistrations( 310 s.db, ··· 335 } 336 337 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 338 + // TODO: set this flag based on the UI 339 + filtered := false 340 + 341 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 342 if err != nil { 343 + s.logger.Error("failed to make timeline", "err", err) 344 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 345 return 346 } 347 348 repos, err := db.GetTopStarredReposLastWeek(s.db) 349 if err != nil { 350 + s.logger.Error("failed to get top starred repos", "err", err) 351 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 352 return 353 } ··· 447 448 user := s.oauth.GetUser(r) 449 l = l.With("did", user.Did) 450 451 // form validation 452 domain := r.FormValue("domain") ··· 510 } 511 record := repo.AsRecord() 512 513 + atpClient, err := s.oauth.AuthorizedClient(r) 514 if err != nil { 515 l.Info("PDS write failed", "err", err) 516 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 517 return 518 } 519 520 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 521 Collection: tangled.RepoNSID, 522 Repo: user.Did, 523 Rkey: rkey, ··· 549 rollback := func() { 550 err1 := tx.Rollback() 551 err2 := s.enforcer.E.LoadPolicy() 552 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 553 554 // ignore txn complete errors, this is okay 555 if errors.Is(err1, sql.ErrTxDone) { ··· 622 aturi = "" 623 624 s.notifier.NewRepo(r.Context(), repo) 625 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 626 } 627 } 628 629 // this is used to rollback changes made to the PDS 630 // 631 // it is a no-op if the provided ATURI is empty 632 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 633 if aturi == "" { 634 return nil 635 } ··· 640 repo := parsed.Authority().String() 641 rkey := parsed.RecordKey().String() 642 643 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 644 Collection: collection, 645 Repo: repo, 646 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/go-chi/chi/v5" 27 ) 28 29 type Strings struct { ··· 254 } 255 256 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 if err != nil { 259 fail("Failed to updated existing record.", err) 260 return 261 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 263 Collection: tangled.StringNSID, 264 Repo: entry.Did.String(), 265 Rkey: entry.Rkey, ··· 284 s.Notifier.EditString(r.Context(), &entry) 285 286 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 288 } 289 290 } ··· 336 return 337 } 338 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 340 Collection: tangled.StringNSID, 341 Repo: user.Did, 342 Rkey: string.Rkey, ··· 360 s.Notifier.NewString(r.Context(), &string) 361 362 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 364 } 365 } 366 ··· 403 404 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 407 } 408 409 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 ) 30 31 type Strings struct { ··· 256 } 257 258 // first replace the existing record in the PDS 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 260 if err != nil { 261 fail("Failed to updated existing record.", err) 262 return 263 } 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 265 Collection: tangled.StringNSID, 266 Repo: entry.Did.String(), 267 Rkey: entry.Rkey, ··· 286 s.Notifier.EditString(r.Context(), &entry) 287 288 // if that went okay, redir to the string 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 290 } 291 292 } ··· 338 return 339 } 340 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 342 Collection: tangled.StringNSID, 343 Repo: user.Did, 344 Rkey: string.Rkey, ··· 362 s.Notifier.NewString(r.Context(), &string) 363 364 // successful 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 366 } 367 } 368 ··· 405 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 409 } 410 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+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 + }
-99
appview/xrpcclient/xrpc.go
··· 1 package xrpcclient 2 3 import ( 4 - "bytes" 5 - "context" 6 "errors" 7 - "io" 8 "net/http" 9 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 ) 15 16 var ( ··· 19 ErrXrpcFailed = errors.New("xrpc request failed") 20 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 116 // produces a more manageable error 117 func HandleXrpcErr(err error) error {
··· 1 package xrpcclient 2 3 import ( 4 "errors" 5 "net/http" 6 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 8 ) 9 10 var ( ··· 13 ErrXrpcFailed = errors.New("xrpc request failed") 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 15 ) 16 17 // produces a more manageable error 18 func HandleXrpcErr(err error) error {
+14 -9
cmd/appview/main.go
··· 2 3 import ( 4 "context" 5 - "log" 6 - "log/slog" 7 "net/http" 8 "os" 9 10 "tangled.org/core/appview/config" 11 "tangled.org/core/appview/state" 12 ) 13 14 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 ctx := context.Background() 18 19 c, err := config.LoadConfig(ctx) 20 if err != nil { 21 - log.Println("failed to load config", "error", err) 22 return 23 } 24 25 state, err := state.Make(ctx, c) 26 defer func() { 27 - log.Println(state.Close()) 28 }() 29 30 if err != nil { 31 - log.Fatal(err) 32 } 33 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 }
··· 2 3 import ( 4 "context" 5 "net/http" 6 "os" 7 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 11 ) 12 13 func main() { 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 17 18 c, err := config.LoadConfig(ctx) 19 if err != nil { 20 + logger.Error("failed to load config", "error", err) 21 return 22 } 23 24 state, err := state.Make(ctx, c) 25 defer func() { 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 29 }() 30 31 if err != nil { 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 34 } 35 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 41 }
+62
cmd/cborgen/cborgen.go
···
··· 1 + package main 2 + 3 + import ( 4 + cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.org/core/api/tangled" 6 + ) 7 + 8 + func main() { 9 + 10 + genCfg := cbg.Gen{ 11 + MaxStringLength: 1_000_000, 12 + } 13 + 14 + if err := genCfg.WriteMapEncodersToFile( 15 + "api/tangled/cbor_gen.go", 16 + "tangled", 17 + tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 19 + tangled.FeedStar{}, 20 + tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 + tangled.GitRefUpdate_Meta{}, 26 + tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 + tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 33 + tangled.Pipeline{}, 34 + tangled.Pipeline_CloneOpts{}, 35 + tangled.Pipeline_ManualTriggerData{}, 36 + tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_PullRequestTriggerData{}, 38 + tangled.Pipeline_PushTriggerData{}, 39 + tangled.PipelineStatus{}, 40 + tangled.Pipeline_TriggerMetadata{}, 41 + tangled.Pipeline_TriggerRepo{}, 42 + tangled.Pipeline_Workflow{}, 43 + tangled.PublicKey{}, 44 + tangled.Repo{}, 45 + tangled.RepoArtifact{}, 46 + tangled.RepoCollaborator{}, 47 + tangled.RepoIssue{}, 48 + tangled.RepoIssueComment{}, 49 + tangled.RepoIssueState{}, 50 + tangled.RepoPull{}, 51 + tangled.RepoPullComment{}, 52 + tangled.RepoPull_Source{}, 53 + tangled.RepoPullStatus{}, 54 + tangled.RepoPull_Target{}, 55 + tangled.Spindle{}, 56 + tangled.SpindleMember{}, 57 + tangled.String{}, 58 + ); err != nil { 59 + panic(err) 60 + } 61 + 62 + }
-62
cmd/gen.go
··· 1 - package main 2 - 3 - import ( 4 - cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.org/core/api/tangled" 6 - ) 7 - 8 - func main() { 9 - 10 - genCfg := cbg.Gen{ 11 - MaxStringLength: 1_000_000, 12 - } 13 - 14 - if err := genCfg.WriteMapEncodersToFile( 15 - "api/tangled/cbor_gen.go", 16 - "tangled", 17 - tangled.ActorProfile{}, 18 - tangled.FeedReaction{}, 19 - tangled.FeedStar{}, 20 - tangled.GitRefUpdate{}, 21 - tangled.GitRefUpdate_CommitCountBreakdown{}, 22 - tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_IndividualLanguageSize{}, 24 - tangled.GitRefUpdate_LangBreakdown{}, 25 - tangled.GitRefUpdate_Meta{}, 26 - tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 - tangled.KnotMember{}, 29 - tangled.LabelDefinition{}, 30 - tangled.LabelDefinition_ValueType{}, 31 - tangled.LabelOp{}, 32 - tangled.LabelOp_Operand{}, 33 - tangled.Pipeline{}, 34 - tangled.Pipeline_CloneOpts{}, 35 - tangled.Pipeline_ManualTriggerData{}, 36 - tangled.Pipeline_Pair{}, 37 - tangled.Pipeline_PullRequestTriggerData{}, 38 - tangled.Pipeline_PushTriggerData{}, 39 - tangled.PipelineStatus{}, 40 - tangled.Pipeline_TriggerMetadata{}, 41 - tangled.Pipeline_TriggerRepo{}, 42 - tangled.Pipeline_Workflow{}, 43 - tangled.PublicKey{}, 44 - tangled.Repo{}, 45 - tangled.RepoArtifact{}, 46 - tangled.RepoCollaborator{}, 47 - tangled.RepoIssue{}, 48 - tangled.RepoIssueComment{}, 49 - tangled.RepoIssueState{}, 50 - tangled.RepoPull{}, 51 - tangled.RepoPullComment{}, 52 - tangled.RepoPull_Source{}, 53 - tangled.RepoPullStatus{}, 54 - tangled.RepoPull_Target{}, 55 - tangled.Spindle{}, 56 - tangled.SpindleMember{}, 57 - tangled.String{}, 58 - ); err != nil { 59 - panic(err) 60 - } 61 - 62 - }
···
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 2 3 package main 4
··· 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 3 package main 4
+6 -3
cmd/knot/main.go
··· 2 3 import ( 4 "context" 5 "os" 6 7 "github.com/urfave/cli/v3" ··· 9 "tangled.org/core/hook" 10 "tangled.org/core/keyfetch" 11 "tangled.org/core/knotserver" 12 - "tangled.org/core/log" 13 ) 14 15 func main() { ··· 24 }, 25 } 26 27 ctx := context.Background() 28 - logger := log.New("knot") 29 - ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 30 31 if err := cmd.Run(ctx, os.Args); err != nil { 32 logger.Error(err.Error())
··· 2 3 import ( 4 "context" 5 + "log/slog" 6 "os" 7 8 "github.com/urfave/cli/v3" ··· 10 "tangled.org/core/hook" 11 "tangled.org/core/keyfetch" 12 "tangled.org/core/knotserver" 13 + tlog "tangled.org/core/log" 14 ) 15 16 func main() { ··· 25 }, 26 } 27 28 + logger := tlog.New("knot") 29 + slog.SetDefault(logger) 30 + 31 ctx := context.Background() 32 + ctx = tlog.IntoContext(ctx, logger) 33 34 if err := cmd.Run(ctx, os.Args); err != nil { 35 logger.Error(err.Error())
-49
cmd/punchcardPopulate/main.go
··· 1 - package main 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "log" 7 - "math/rand" 8 - "time" 9 - 10 - _ "github.com/mattn/go-sqlite3" 11 - ) 12 - 13 - func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 - if err != nil { 16 - log.Fatal("Failed to open database:", err) 17 - } 18 - defer db.Close() 19 - 20 - const did = "did:plc:qfpnj4og54vl56wngdriaxug" 21 - 22 - now := time.Now() 23 - start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 24 - 25 - tx, err := db.Begin() 26 - if err != nil { 27 - log.Fatal(err) 28 - } 29 - stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)") 30 - if err != nil { 31 - log.Fatal(err) 32 - } 33 - defer stmt.Close() 34 - 35 - for day := start; !day.After(now); day = day.AddDate(0, 0, 1) { 36 - count := rand.Intn(16) // 0–5 37 - dateStr := day.Format("2006-01-02") 38 - _, err := stmt.Exec(did, dateStr, count) 39 - if err != nil { 40 - log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 - } 42 - } 43 - 44 - if err := tx.Commit(); err != nil { 45 - log.Fatal("Failed to commit:", err) 46 - } 47 - 48 - fmt.Println("Done populating punchcard.") 49 - }
···
+9 -4
cmd/spindle/main.go
··· 2 3 import ( 4 "context" 5 "os" 6 7 - "tangled.org/core/log" 8 "tangled.org/core/spindle" 9 - _ "tangled.org/core/tid" 10 ) 11 12 func main() { 13 - ctx := log.NewContext(context.Background(), "spindle") 14 err := spindle.Run(ctx) 15 if err != nil { 16 - log.FromContext(ctx).Error("error running spindle", "error", err) 17 os.Exit(-1) 18 } 19 }
··· 2 3 import ( 4 "context" 5 + "log/slog" 6 "os" 7 8 + tlog "tangled.org/core/log" 9 "tangled.org/core/spindle" 10 ) 11 12 func main() { 13 + logger := tlog.New("spindle") 14 + slog.SetDefault(logger) 15 + 16 + ctx := context.Background() 17 + ctx = tlog.IntoContext(ctx, logger) 18 + 19 err := spindle.Run(ctx) 20 if err != nil { 21 + logger.Error("error running spindle", "error", err) 22 os.Exit(-1) 23 } 24 }
+2 -1
docs/knot-hosting.md
··· 39 ``` 40 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/knot` is a good choice: 43 44 ``` 45 sudo mv knot /usr/local/bin/knot 46 ``` 47 48 This is necessary because SSH `AuthorizedKeysCommand` requires [really
··· 39 ``` 40 41 Next, move the `knot` binary to a location owned by `root` -- 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 44 ``` 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 47 ``` 48 49 This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1 -1
docs/spindle/pipeline.md
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
+1 -1
flake.nix
··· 262 lexgen --build-file lexicon-build-config.json lexicons 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 266 lexgen --build-file lexicon-build-config.json lexicons 267 rm api/tangled/*.bak 268 '';
··· 262 lexgen --build-file lexicon-build-config.json lexicons 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 + go run ./cmd/cborgen/ 266 lexgen --build-file lexicon-build-config.json lexicons 267 rm api/tangled/*.bak 268 '';
+23 -6
go.mod
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 github.com/gorilla/feeds v1.2.0 26 github.com/gorilla/sessions v1.4.0 ··· 36 github.com/redis/go-redis/v9 v9.7.3 37 github.com/resend/resend-go/v2 v2.15.0 38 github.com/sethvargo/go-envconfig v1.1.0 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 ) 52 53 require ( ··· 56 github.com/ProtonMail/go-crypto v1.3.0 // indirect 57 github.com/alecthomas/repr v0.4.0 // indirect 58 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 59 github.com/aymerick/douceur v0.2.0 // indirect 60 github.com/beorn7/perks v1.0.1 // indirect 61 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 62 github.com/casbin/govaluate v1.3.0 // indirect 63 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 64 github.com/cespare/xxhash/v2 v2.3.0 // indirect 65 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 66 github.com/containerd/errdefs v1.0.0 // indirect 67 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 80 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 81 github.com/go-git/go-billy/v5 v5.6.2 // indirect 82 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 83 github.com/go-logr/logr v1.4.3 // indirect 84 github.com/go-logr/stdr v1.2.2 // indirect 85 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 122 github.com/lestrrat-go/httprc v1.0.6 // indirect 123 github.com/lestrrat-go/iter v1.0.2 // indirect 124 github.com/lestrrat-go/option v1.0.1 // indirect 125 github.com/mattn/go-isatty v0.0.20 // indirect 126 github.com/minio/sha256-simd v1.0.1 // indirect 127 github.com/mitchellh/mapstructure v1.5.0 // indirect 128 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 130 github.com/moby/term v0.5.2 // indirect 131 github.com/morikuni/aec v1.0.0 // indirect 132 github.com/mr-tron/base58 v1.2.0 // indirect 133 github.com/multiformats/go-base32 v0.1.0 // indirect 134 github.com/multiformats/go-base36 v0.2.0 // indirect 135 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 148 github.com/prometheus/client_model v0.6.2 // indirect 149 github.com/prometheus/common v0.64.0 // indirect 150 github.com/prometheus/procfs v0.16.1 // indirect 151 github.com/ryanuber/go-glob v1.0.0 // indirect 152 github.com/segmentio/asm v1.2.0 // indirect 153 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 github.com/wyatt915/treeblood v0.1.15 // indirect 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 168 go.uber.org/atomic v1.11.0 // indirect 169 go.uber.org/multierr v1.11.0 // indirect 170 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect 175 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 25 github.com/google/uuid v1.6.0 26 github.com/gorilla/feeds v1.2.0 27 github.com/gorilla/sessions v1.4.0 ··· 37 github.com/redis/go-redis/v9 v9.7.3 38 github.com/resend/resend-go/v2 v2.15.0 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 42 github.com/stretchr/testify v1.10.0 43 github.com/urfave/cli/v3 v3.3.3 44 github.com/whyrusleeping/cbor-gen v0.3.1 45 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 46 + github.com/yuin/goldmark v1.7.13 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 48 golang.org/x/crypto v0.40.0 49 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 51 golang.org/x/net v0.42.0 52 + golang.org/x/sync v0.17.0 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 54 gopkg.in/yaml.v3 v3.0.1 55 ) 56 57 require ( ··· 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 61 github.com/alecthomas/repr v0.4.0 // indirect 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 64 github.com/aymerick/douceur v0.2.0 // indirect 65 github.com/beorn7/perks v1.0.1 // indirect 66 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 67 github.com/casbin/govaluate v1.3.0 // indirect 68 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 69 github.com/cespare/xxhash/v2 v2.3.0 // indirect 70 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 71 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 72 + github.com/charmbracelet/log v0.4.2 // indirect 73 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 74 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 75 + github.com/charmbracelet/x/term v0.2.1 // indirect 76 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 77 github.com/containerd/errdefs v1.0.0 // indirect 78 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 91 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 92 github.com/go-git/go-billy/v5 v5.6.2 // indirect 93 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 94 + github.com/go-logfmt/logfmt v0.6.0 // indirect 95 github.com/go-logr/logr v1.4.3 // indirect 96 github.com/go-logr/stdr v1.2.2 // indirect 97 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 134 github.com/lestrrat-go/httprc v1.0.6 // indirect 135 github.com/lestrrat-go/iter v1.0.2 // indirect 136 github.com/lestrrat-go/option v1.0.1 // indirect 137 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 138 github.com/mattn/go-isatty v0.0.20 // indirect 139 + github.com/mattn/go-runewidth v0.0.16 // indirect 140 github.com/minio/sha256-simd v1.0.1 // indirect 141 github.com/mitchellh/mapstructure v1.5.0 // indirect 142 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 144 github.com/moby/term v0.5.2 // indirect 145 github.com/morikuni/aec v1.0.0 // indirect 146 github.com/mr-tron/base58 v1.2.0 // indirect 147 + github.com/muesli/termenv v0.16.0 // indirect 148 github.com/multiformats/go-base32 v0.1.0 // indirect 149 github.com/multiformats/go-base36 v0.2.0 // indirect 150 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 163 github.com/prometheus/client_model v0.6.2 // indirect 164 github.com/prometheus/common v0.64.0 // indirect 165 github.com/prometheus/procfs v0.16.1 // indirect 166 + github.com/rivo/uniseg v0.4.7 // indirect 167 github.com/ryanuber/go-glob v1.0.0 // indirect 168 github.com/segmentio/asm v1.2.0 // indirect 169 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 172 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 173 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 174 github.com/wyatt915/treeblood v0.1.15 // indirect 175 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 176 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 177 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 178 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 179 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 186 go.uber.org/atomic v1.11.0 // indirect 187 go.uber.org/multierr v1.11.0 // indirect 188 go.uber.org/zap v1.27.0 // indirect 189 golang.org/x/sys v0.34.0 // indirect 190 + golang.org/x/text v0.29.0 // indirect 191 golang.org/x/time v0.12.0 // indirect 192 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 193 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+45 -12
go.sum
··· 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 48 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 139 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 246 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 247 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 276 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 279 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 300 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 303 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 402 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 430 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 489 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 492 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 531 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 533 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 586 - golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 - golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 588 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 652 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 23 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 24 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 25 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 26 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 27 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 50 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 51 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 52 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 53 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 54 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 55 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 56 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 57 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 58 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 59 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 60 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 61 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 62 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 63 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 64 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 65 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 66 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 67 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 134 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 135 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 136 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 137 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 138 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 139 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 140 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 141 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 152 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 153 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 154 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 155 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 156 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 157 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 158 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 159 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 261 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 262 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 263 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 264 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 265 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 266 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 292 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 293 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 294 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 295 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 296 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 298 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 299 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 300 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 301 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 302 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 303 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 304 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 305 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 320 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 321 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 322 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 323 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 324 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 325 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 326 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 327 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 399 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 400 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 401 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 402 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 403 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 404 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 405 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 406 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 407 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 424 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 425 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 426 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 427 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 428 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 429 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 430 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 431 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 432 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 433 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 459 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 460 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 461 github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 462 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 465 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 466 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 467 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 468 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 469 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 470 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 471 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 472 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 473 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 474 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 475 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 476 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 477 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 478 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 522 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 523 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 524 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 525 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 526 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 527 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 528 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 529 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 563 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 564 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 565 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 566 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 567 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 568 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 569 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 570 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 618 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 619 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 620 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 621 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 622 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 623 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 624 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 625 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 687 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 688 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 689 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 690 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 691 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71 -12
input.css
··· 134 } 135 136 .prose hr { 137 - @apply my-2; 138 } 139 140 .prose li:has(input) { 141 - @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 - @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 - @apply no-underline; 162 } 163 164 .prose li { 165 - @apply my-0 py-0; 166 } 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 170 } 171 172 .prose img { ··· 176 } 177 178 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 } 181 182 .prose input[type="checkbox"] { 183 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 } 185 } 186 @layer utilities { 187 .error { ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 234 /* LineNumbersTable */
··· 134 } 135 136 .prose hr { 137 + @apply my-2; 138 } 139 140 .prose li:has(input) { 141 + @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 + @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 + @apply no-underline; 162 } 163 164 .prose li { 165 + @apply my-0 py-0; 166 } 167 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 171 } 172 173 .prose img { ··· 177 } 178 179 .prose input { 180 + @apply inline-block my-0 mb-1 mx-1; 181 } 182 183 .prose input[type="checkbox"] { 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 244 } 245 @layer utilities { 246 .error { ··· 287 } 288 /* LineHighlight */ 289 .chroma .hl { 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 291 } 292 293 /* LineNumbersTable */
+1 -1
jetstream/jetstream.go
··· 114 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 118 if err != nil { 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 }
··· 114 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 if err != nil { 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 }
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
+2 -3
knotserver/events.go
··· 8 "time" 9 10 "github.com/gorilla/websocket" 11 ) 12 13 var upgrader = websocket.Upgrader{ ··· 16 } 17 18 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 - l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 22 conn, err := upgrader.Upgrade(w, r, nil) ··· 75 } 76 case <-time.After(30 * time.Second): 77 // send a keep-alive 78 - l.Debug("sent keepalive") 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 l.Error("failed to write control", "err", err) 81 } ··· 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 return err 91 } 92 - h.l.Debug("ops", "ops", events) 93 94 for _, event := range events { 95 // first extract the inner json into a map
··· 8 "time" 9 10 "github.com/gorilla/websocket" 11 + "tangled.org/core/log" 12 ) 13 14 var upgrader = websocket.Upgrader{ ··· 17 } 18 19 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 20 + l := log.SubLogger(h.l, "eventstream") 21 l.Debug("received new connection") 22 23 conn, err := upgrader.Upgrade(w, r, nil) ··· 76 } 77 case <-time.After(30 * time.Second): 78 // send a keep-alive 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 l.Error("failed to write control", "err", err) 81 } ··· 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 return err 91 } 92 93 for _, event := range events { 94 // first extract the inner json into a map
+5
knotserver/git/branch.go
··· 110 slices.Reverse(branches) 111 return branches, nil 112 }
··· 110 slices.Reverse(branches) 111 return branches, nil 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+11
knotserver/git/git.go
··· 71 return &g, nil 72 } 73 74 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 75 commits := []*object.Commit{} 76
··· 71 return &g, nil 72 } 73 74 + // re-open a repository and update references 75 + func (g *GitRepo) Refresh() error { 76 + refreshed, err := PlainOpen(g.path) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + *g = *refreshed 82 + return nil 83 + } 84 + 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 86 commits := []*object.Commit{} 87
+21 -2
knotserver/git/last_commit.go
··· 30 commitCache = cache 31 } 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 34 args := []string{} 35 args = append(args, "log") 36 args = append(args, g.h.String()) ··· 48 return nil, err 49 } 50 51 - return stdout, nil 52 } 53 54 type commit struct { ··· 104 if err != nil { 105 return nil, err 106 } 107 108 reader := bufio.NewReader(output) 109 var current commit
··· 30 commitCache = cache 31 } 32 33 + // processReader wraps a reader and ensures the associated process is cleaned up 34 + type processReader struct { 35 + io.Reader 36 + cmd *exec.Cmd 37 + stdout io.ReadCloser 38 + } 39 + 40 + func (pr *processReader) Close() error { 41 + if err := pr.stdout.Close(); err != nil { 42 + return err 43 + } 44 + return pr.cmd.Wait() 45 + } 46 + 47 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 48 args := []string{} 49 args = append(args, "log") 50 args = append(args, g.h.String()) ··· 62 return nil, err 63 } 64 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 70 } 71 72 type commit struct { ··· 122 if err != nil { 123 return nil, err 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 126 127 reader := bufio.NewReader(output) 128 var current commit
+150 -37
knotserver/git/merge.go
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 "os" 8 "os/exec" 9 "regexp" ··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 32 mergeCheckCache = MergeCheckCache{cache} 33 } 34 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 36 sep := byte(':') 37 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 return fmt.Sprintf("%x", hash) ··· 49 } 50 } 51 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 53 key := m.cacheKey(g, patch, targetBranch) 54 val := m.cacheVal(mergeCheck) 55 m.cache.Set(key, val, 0) 56 } 57 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 59 key := m.cacheKey(g, patch, targetBranch) 60 if val, ok := m.cache.Get(key); ok { 61 if val == struct{}{} { ··· 104 return fmt.Sprintf("merge failed: %s", e.Message) 105 } 106 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 108 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 if err != nil { 110 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 } 112 113 - if _, err := tmpFile.Write(patchData); err != nil { 114 tmpFile.Close() 115 os.Remove(tmpFile.Name()) 116 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 return nil 163 } 164 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 var stderr bytes.Buffer 167 var cmd *exec.Cmd 168 169 // configure default git user before merge 170 - exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 - exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 174 // if patch is a format-patch, apply using 'git am' 175 if opts.FormatPatch { 176 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 - } else { 178 - // else, apply using 'git apply' and commit it manually 179 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 - applyCmd.Stderr = &stderr 181 - if err := applyCmd.Run(); err != nil { 182 - return fmt.Errorf("patch application failed: %s", stderr.String()) 183 - } 184 185 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 - if err := stageCmd.Run(); err != nil { 187 - return fmt.Errorf("failed to stage changes: %w", err) 188 - } 189 190 - commitArgs := []string{"-C", tmpDir, "commit"} 191 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 195 196 - if authorName != "" && authorEmail != "" { 197 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 - } 199 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 200 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 206 207 - cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 ··· 216 return nil 217 } 218 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } ··· 244 return result 245 } 246 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 + "log" 8 "os" 9 "os/exec" 10 "regexp" ··· 13 "github.com/dgraph-io/ristretto" 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 18 ) 19 20 type MergeCheckCache struct { ··· 35 mergeCheckCache = MergeCheckCache{cache} 36 } 37 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 39 sep := byte(':') 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 41 return fmt.Sprintf("%x", hash) ··· 52 } 53 } 54 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 56 key := m.cacheKey(g, patch, targetBranch) 57 val := m.cacheVal(mergeCheck) 58 m.cache.Set(key, val, 0) 59 } 60 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 62 key := m.cacheKey(g, patch, targetBranch) 63 if val, ok := m.cache.Get(key); ok { 64 if val == struct{}{} { ··· 107 return fmt.Sprintf("merge failed: %s", e.Message) 108 } 109 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 112 if err != nil { 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 114 } 115 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 117 tmpFile.Close() 118 os.Remove(tmpFile.Name()) 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 165 return nil 166 } 167 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 var stderr bytes.Buffer 170 var cmd *exec.Cmd 171 172 // configure default git user before merge 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 176 177 // if patch is a format-patch, apply using 'git am' 178 if opts.FormatPatch { 179 + return g.applyMailbox(patchData) 180 + } 181 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 188 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 193 194 + commitArgs := []string{"-C", g.path, "commit"} 195 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 199 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 204 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 212 213 cmd.Stderr = &stderr 214 ··· 219 return nil 220 } 221 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 227 + 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 235 + } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 + } 239 + 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 + cmd.Stderr = &stderr 252 + 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 259 + if err := cmd.Run(); err != nil { 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 + } 321 + } 322 + 323 + // new hash of commit 324 + return newHash, nil 325 + } 326 + 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 return val 330 } ··· 352 return result 353 } 354 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 356 patchFile, err := g.createTempFileWithPatch(patchData) 357 if err != nil { 358 return &ErrMerge{ ··· 371 } 372 defer os.RemoveAll(tmpDir) 373 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 380 return err 381 } 382
+18 -18
knotserver/git.go
··· 13 "tangled.org/core/knotserver/git/service" 14 ) 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) 20 if err != nil { 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 return 24 } 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 27 if err != nil { 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 return 31 } 32 ··· 46 47 if err := cmd.InfoRefs(); err != nil { 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 return 51 } 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 54 default: 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 } 57 } 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 63 if err != nil { 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 return 67 } 68 ··· 77 gzipReader, err := gzip.NewReader(r.Body) 78 if err != nil { 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 return 82 } 83 defer gzipReader.Close() ··· 88 w.Header().Set("Connection", "Keep-Alive") 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 93 cmd := service.ServiceCommand{ 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 w.WriteHeader(http.StatusOK) 101 102 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 return 105 } 106 } 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 112 if err != nil { 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 return 116 } 117 118 - d.RejectPush(w, r, name) 119 } 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 }
··· 13 "tangled.org/core/knotserver/git/service" 14 ) 15 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) 20 if err != nil { 21 gitError(w, "repository not found", http.StatusNotFound) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 return 24 } 25 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 if err != nil { 28 gitError(w, "repository not found", http.StatusNotFound) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 return 31 } 32 ··· 46 47 if err := cmd.InfoRefs(); err != nil { 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 return 51 } 52 case "git-receive-pack": 53 + h.RejectPush(w, r, name) 54 default: 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 } 57 } 58 59 + func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 if err != nil { 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 return 67 } 68 ··· 77 gzipReader, err := gzip.NewReader(r.Body) 78 if err != nil { 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 return 82 } 83 defer gzipReader.Close() ··· 88 w.Header().Set("Connection", "Keep-Alive") 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 91 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 93 cmd := service.ServiceCommand{ 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 w.WriteHeader(http.StatusOK) 101 102 if err := cmd.UploadPack(); err != nil { 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 return 105 } 106 } 107 108 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 if err != nil { 113 gitError(w, err.Error(), http.StatusForbidden) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 return 116 } 117 118 + h.RejectPush(w, r, name) 119 } 120 121 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 + hostname := h.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 }
+50 -1
knotserver/internal.go
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/hook" 18 "tangled.org/core/knotserver/config" 19 "tangled.org/core/knotserver/db" 20 "tangled.org/core/knotserver/git" 21 "tangled.org/core/notifier" 22 "tangled.org/core/rbac" 23 "tangled.org/core/workflow" ··· 118 // non-fatal 119 } 120 121 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 if err != nil { 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 128 writeJSON(w, resp) 129 } 130 131 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 132 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 133 if err != nil { ··· 268 return h.db.InsertEvent(event, h.n) 269 } 270 271 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 272 r := chi.NewRouter() 273 274 h := InternalHandle{ 275 db,
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 24 "tangled.org/core/notifier" 25 "tangled.org/core/rbac" 26 "tangled.org/core/workflow" ··· 121 // non-fatal 122 } 123 124 + if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 125 + msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 126 + if err != nil { 127 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 128 + // non-fatal 129 + } else { 130 + for msgLine := range msg { 131 + resp.Messages = append(resp.Messages, msg[msgLine]) 132 + } 133 + } 134 + } 135 + 136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 137 if err != nil { 138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 143 writeJSON(w, resp) 144 } 145 146 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 + l := h.l.With("handler", "replyCompare") 148 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 + user := repoOwner 150 + if err != nil { 151 + l.Error("Failed to fetch user identity", "err", err) 152 + // non-fatal 153 + } else { 154 + user = userIdent.Handle.String() 155 + } 156 + gr, err := git.PlainOpen(gitRelativeDir) 157 + if err != nil { 158 + l.Error("Failed to open git repository", "err", err) 159 + return []string{}, err 160 + } 161 + defaultBranch, err := gr.FindMainBranch() 162 + if err != nil { 163 + l.Error("Failed to fetch default branch", "err", err) 164 + return []string{}, err 165 + } 166 + if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 167 + return []string{}, nil 168 + } 169 + ZWS := "\u200B" 170 + var msg []string 171 + msg = append(msg, ZWS) 172 + msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 173 + msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 174 + msg = append(msg, ZWS) 175 + return msg, nil 176 + } 177 + 178 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 180 if err != nil { ··· 315 return h.db.InsertEvent(event, h.n) 316 } 317 318 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 r := chi.NewRouter() 320 + l := log.FromContext(ctx) 321 + l = log.SubLogger(l, "internal") 322 323 h := InternalHandle{ 324 db,
+35
knotserver/middleware.go
···
··· 1 + package knotserver 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (h *Knot) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + h.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+16 -9
knotserver/router.go
··· 12 "tangled.org/core/knotserver/config" 13 "tangled.org/core/knotserver/db" 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 16 "tangled.org/core/notifier" 17 "tangled.org/core/rbac" 18 "tangled.org/core/xrpc/serviceauth" ··· 28 resolver *idresolver.Resolver 29 } 30 31 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 - r := chi.NewRouter() 33 - 34 h := Knot{ 35 c: c, 36 db: db, 37 e: e, 38 - l: l, 39 jc: jc, 40 n: n, 41 resolver: idresolver.DefaultResolver(), ··· 67 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 } 69 70 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 }) ··· 86 // Socket that streams git oplogs 87 r.Get("/events", h.Events) 88 89 - return r, nil 90 } 91 92 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 94 95 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 97 xrpc := &xrpc.Xrpc{ 98 Config: h.c, 99 Db: h.db, 100 Ingester: h.jc, 101 Enforcer: h.e, 102 - Logger: logger, 103 Notifier: h.n, 104 Resolver: h.resolver, 105 ServiceAuth: serviceAuth, 106 } 107 return xrpc.Router() 108 } 109
··· 12 "tangled.org/core/knotserver/config" 13 "tangled.org/core/knotserver/db" 14 "tangled.org/core/knotserver/xrpc" 15 + "tangled.org/core/log" 16 "tangled.org/core/notifier" 17 "tangled.org/core/rbac" 18 "tangled.org/core/xrpc/serviceauth" ··· 28 resolver *idresolver.Resolver 29 } 30 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 32 h := Knot{ 33 c: c, 34 db: db, 35 e: e, 36 + l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 resolver: idresolver.DefaultResolver(), ··· 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 66 } 67 68 + return h.Router(), nil 69 + } 70 + 71 + func (h *Knot) Router() http.Handler { 72 + r := chi.NewRouter() 73 + 74 + r.Use(h.RequestLogger) 75 + 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 77 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 78 }) ··· 92 // Socket that streams git oplogs 93 r.Get("/events", h.Events) 94 95 + return r 96 } 97 98 func (h *Knot) XrpcRouter() http.Handler { 99 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 100 101 + l := log.SubLogger(h.l, "xrpc") 102 103 xrpc := &xrpc.Xrpc{ 104 Config: h.c, 105 Db: h.db, 106 Ingester: h.jc, 107 Enforcer: h.e, 108 + Logger: l, 109 Notifier: h.n, 110 Resolver: h.resolver, 111 ServiceAuth: serviceAuth, 112 } 113 + 114 return xrpc.Router() 115 } 116
+5 -4
knotserver/server.go
··· 43 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 47 48 c, err := config.Load(ctx) 49 if err != nil { ··· 80 tangled.KnotMemberNSID, 81 tangled.RepoPullNSID, 82 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 if err != nil { 85 logger.Error("failed to setup jetstream", "error", err) 86 } 87 88 notifier := notifier.New() 89 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 if err != nil { 92 return fmt.Errorf("failed to setup server: %w", err) 93 } 94 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 97 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
··· 43 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 logger := log.FromContext(ctx) 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 48 49 c, err := config.Load(ctx) 50 if err != nil { ··· 81 tangled.KnotMemberNSID, 82 tangled.RepoPullNSID, 83 tangled.RepoCollaboratorNSID, 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 85 if err != nil { 86 logger.Error("failed to setup jetstream", "error", err) 87 } 88 89 notifier := notifier.New() 90 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 92 if err != nil { 93 return fmt.Errorf("failed to setup server: %w", err) 94 } 95 96 + imux := Internal(ctx, c, db, e, &notifier) 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 99 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+87
knotserver/xrpc/delete_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDeleteBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(xrpcerr.GenericError(err)) 76 + return 77 + } 78 + 79 + err = gr.DeleteBranch(data.Branch) 80 + if err != nil { 81 + l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
+1 -1
knotserver/xrpc/merge.go
··· 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 if err != nil { 90 var mergeErr *git.ErrMerge 91 if errors.As(err, &mergeErr) {
··· 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 if err != nil { 90 var mergeErr *git.ErrMerge 91 if errors.As(err, &mergeErr) {
+3 -1
knotserver/xrpc/merge_check.go
··· 51 return 52 } 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false, ··· 80 response.Error = &errMsg 81 } 82 } 83 84 w.Header().Set("Content-Type", "application/json") 85 w.WriteHeader(http.StatusOK)
··· 51 return 52 } 53 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false, ··· 80 response.Error = &errMsg 81 } 82 } 83 + 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 85 86 w.Header().Set("Content-Type", "application/json") 87 w.WriteHeader(http.StatusOK)
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 "fmt" 5 "net/http" 6 7 "tangled.org/core/knotserver/git" 8 "tangled.org/core/types" 9 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 return 72 } 73 74 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 79 } 80 81 writeJson(w, response)
··· 4 "fmt" 5 "net/http" 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 "tangled.org/core/knotserver/git" 9 "tangled.org/core/types" 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 72 return 73 } 74 75 + var combinedPatch []*gitdiff.File 76 + var combinedPatchRaw string 77 + // we need the combined patch 78 + if len(formatPatch) >= 2 { 79 + diffTree, err := gr.DiffTree(commit1, commit2) 80 + if err != nil { 81 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 + } else { 83 + combinedPatch = diffTree.Diff 84 + combinedPatchRaw = diffTree.Patch 85 + } 86 + } 87 + 88 response := types.RepoFormatPatchResponse{ 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 95 } 96 97 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
··· 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+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 +
+23 -9
log/log.go
··· 4 "context" 5 "log/slog" 6 "os" 7 ) 8 9 - // NewHandler sets up a new slog.Handler with the service name 10 - // as an attribute 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 - Level: slog.LevelDebug, 14 }) 15 - 16 - var attrs []slog.Attr 17 - attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)}) 18 - handler.WithAttrs(attrs) 19 - return handler 20 } 21 22 func New(name string) *slog.Logger { ··· 49 50 return slog.Default() 51 }
··· 4 "context" 5 "log/slog" 6 "os" 7 + 8 + "github.com/charmbracelet/log" 9 ) 10 11 func NewHandler(name string) slog.Handler { 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 16 }) 17 } 18 19 func New(name string) *slog.Logger { ··· 46 47 return slog.Default() 48 } 49 + 50 + // sublogger derives a new logger from an existing one by appending a suffix to its prefix. 51 + func SubLogger(base *slog.Logger, suffix string) *slog.Logger { 52 + // try to get the underlying charmbracelet logger 53 + if cl, ok := base.Handler().(*log.Logger); ok { 54 + prefix := cl.GetPrefix() 55 + if prefix != "" { 56 + prefix = prefix + "/" + suffix 57 + } else { 58 + prefix = suffix 59 + } 60 + return slog.New(NewHandler(prefix)) 61 + } 62 + 63 + // Fallback: no known handler type 64 + return slog.New(NewHandler(suffix)) 65 + }
+62 -11
nix/gomod2nix.toml
··· 29 [mod."github.com/avast/retry-go/v4"] 30 version = "v4.6.1" 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 [mod."github.com/aymerick/douceur"] 33 version = "v0.2.0" 34 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" ··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 63 [mod."github.com/cespare/xxhash/v2"] 64 version = "v2.3.0" 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 66 [mod."github.com/cloudflare/circl"] 67 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 145 [mod."github.com/go-jose/go-jose/v3"] 146 version = "v3.0.4" 147 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 148 [mod."github.com/go-logr/logr"] 149 version = "v1.4.3" 150 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 163 [mod."github.com/gogo/protobuf"] 164 version = "v1.3.2" 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 [mod."github.com/golang-jwt/jwt/v5"] 167 version = "v5.2.3" 168 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 295 [mod."github.com/lestrrat-go/option"] 296 version = "v1.0.1" 297 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 298 [mod."github.com/mattn/go-isatty"] 299 version = "v0.0.20" 300 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 301 [mod."github.com/mattn/go-sqlite3"] 302 version = "v1.14.24" 303 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 325 [mod."github.com/mr-tron/base58"] 326 version = "v1.2.0" 327 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 328 [mod."github.com/multiformats/go-base32"] 329 version = "v0.1.0" 330 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 391 [mod."github.com/resend/resend-go/v2"] 392 version = "v2.15.0" 393 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 394 [mod."github.com/ryanuber/go-glob"] 395 version = "v1.0.0" 396 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 407 [mod."github.com/spaolacci/murmur3"] 408 version = "v1.1.0" 409 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 410 [mod."github.com/stretchr/testify"] 411 version = "v1.10.0" 412 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 431 [mod."github.com/wyatt915/treeblood"] 432 version = "v0.1.15" 433 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 version = "v2.0.0-20230729083705-37449abec8cc" 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 440 [mod."gitlab.com/yawning/secp256k1-voi"] 441 version = "v0.0.0-20230925100816-f2616030848b" 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 479 [mod."golang.org/x/exp"] 480 version = "v0.0.0-20250620022241-b7579e27df2b" 481 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 482 [mod."golang.org/x/net"] 483 version = "v0.42.0" 484 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 488 [mod."golang.org/x/sys"] 489 version = "v0.34.0" 490 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 494 [mod."golang.org/x/time"] 495 version = "v0.12.0" 496 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 [mod."lukechampine.com/blake3"] 528 version = "v1.4.1" 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 - version = "v0.0.0-20250724194903-28e660378cb1" 532 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
··· 29 [mod."github.com/avast/retry-go/v4"] 30 version = "v4.6.1" 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 + [mod."github.com/aymanbagabas/go-osc52/v2"] 33 + version = "v2.0.1" 34 + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 35 [mod."github.com/aymerick/douceur"] 36 version = "v0.2.0" 37 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" ··· 43 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 44 replaced = "tangled.sh/oppi.li/go-gitdiff" 45 [mod."github.com/bluesky-social/indigo"] 46 + version = "v0.0.0-20251003000214-3259b215110e" 47 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 48 [mod."github.com/bluesky-social/jetstream"] 49 version = "v0.0.0-20241210005130-ea96859b93d1" 50 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 66 [mod."github.com/cespare/xxhash/v2"] 67 version = "v2.3.0" 68 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 69 + [mod."github.com/charmbracelet/colorprofile"] 70 + version = "v0.2.3-0.20250311203215-f60798e515dc" 71 + hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" 72 + [mod."github.com/charmbracelet/lipgloss"] 73 + version = "v1.1.0" 74 + hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" 75 + [mod."github.com/charmbracelet/log"] 76 + version = "v0.4.2" 77 + hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=" 78 + [mod."github.com/charmbracelet/x/ansi"] 79 + version = "v0.8.0" 80 + hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas=" 81 + [mod."github.com/charmbracelet/x/cellbuf"] 82 + version = "v0.0.13-0.20250311204145-2c3ea96c31dd" 83 + hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" 84 + [mod."github.com/charmbracelet/x/term"] 85 + version = "v0.2.1" 86 + hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" 87 [mod."github.com/cloudflare/circl"] 88 version = "v1.6.2-0.20250618153321-aa837fd1539d" 89 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 166 [mod."github.com/go-jose/go-jose/v3"] 167 version = "v3.0.4" 168 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 169 + [mod."github.com/go-logfmt/logfmt"] 170 + version = "v0.6.0" 171 + hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 172 [mod."github.com/go-logr/logr"] 173 version = "v1.4.3" 174 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 187 [mod."github.com/gogo/protobuf"] 188 version = "v1.3.2" 189 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 190 + [mod."github.com/goki/freetype"] 191 + version = "v1.0.5" 192 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 193 [mod."github.com/golang-jwt/jwt/v5"] 194 version = "v5.2.3" 195 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 322 [mod."github.com/lestrrat-go/option"] 323 version = "v1.0.1" 324 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 325 + [mod."github.com/lucasb-eyer/go-colorful"] 326 + version = "v1.2.0" 327 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 328 [mod."github.com/mattn/go-isatty"] 329 version = "v0.0.20" 330 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 331 + [mod."github.com/mattn/go-runewidth"] 332 + version = "v0.0.16" 333 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 334 [mod."github.com/mattn/go-sqlite3"] 335 version = "v1.14.24" 336 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 358 [mod."github.com/mr-tron/base58"] 359 version = "v1.2.0" 360 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 361 + [mod."github.com/muesli/termenv"] 362 + version = "v0.16.0" 363 + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 364 [mod."github.com/multiformats/go-base32"] 365 version = "v0.1.0" 366 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 427 [mod."github.com/resend/resend-go/v2"] 428 version = "v2.15.0" 429 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 430 + [mod."github.com/rivo/uniseg"] 431 + version = "v0.4.7" 432 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 433 [mod."github.com/ryanuber/go-glob"] 434 version = "v1.0.0" 435 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 446 [mod."github.com/spaolacci/murmur3"] 447 version = "v1.1.0" 448 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 449 + [mod."github.com/srwiley/oksvg"] 450 + version = "v0.0.0-20221011165216-be6e8873101c" 451 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 452 + [mod."github.com/srwiley/rasterx"] 453 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 454 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 455 [mod."github.com/stretchr/testify"] 456 version = "v1.10.0" 457 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 476 [mod."github.com/wyatt915/treeblood"] 477 version = "v0.1.15" 478 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 479 + [mod."github.com/xo/terminfo"] 480 + version = "v0.0.0-20220910002029-abceb7e1c41e" 481 + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 482 [mod."github.com/yuin/goldmark"] 483 + version = "v1.7.13" 484 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 485 [mod."github.com/yuin/goldmark-highlighting/v2"] 486 version = "v2.0.0-20230729083705-37449abec8cc" 487 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 488 + [mod."gitlab.com/staticnoise/goldmark-callout"] 489 + version = "v0.0.0-20240609120641-6366b799e4ab" 490 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 491 [mod."gitlab.com/yawning/secp256k1-voi"] 492 version = "v0.0.0-20230925100816-f2616030848b" 493 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 530 [mod."golang.org/x/exp"] 531 version = "v0.0.0-20250620022241-b7579e27df2b" 532 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 533 + [mod."golang.org/x/image"] 534 + version = "v0.31.0" 535 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 536 [mod."golang.org/x/net"] 537 version = "v0.42.0" 538 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 539 [mod."golang.org/x/sync"] 540 + version = "v0.17.0" 541 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 542 [mod."golang.org/x/sys"] 543 version = "v0.34.0" 544 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 545 [mod."golang.org/x/text"] 546 + version = "v0.29.0" 547 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 548 [mod."golang.org/x/time"] 549 version = "v0.12.0" 550 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 581 [mod."lukechampine.com/blake3"] 582 version = "v1.4.1" 583 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
+2 -2
nix/modules/knot.nix
··· 22 23 appviewEndpoint = mkOption { 24 type = types.str; 25 - default = "https://tangled.sh"; 26 description = "Appview endpoint"; 27 }; 28 ··· 107 108 hostname = mkOption { 109 type = types.str; 110 - example = "knot.tangled.sh"; 111 description = "Hostname for the server (required)"; 112 }; 113
··· 22 23 appviewEndpoint = mkOption { 24 type = types.str; 25 + default = "https://tangled.org"; 26 description = "Appview endpoint"; 27 }; 28 ··· 107 108 hostname = mkOption { 109 type = types.str; 110 + example = "my.knot.com"; 111 description = "Hostname for the server (required)"; 112 }; 113
+2 -2
nix/modules/spindle.nix
··· 33 34 hostname = mkOption { 35 type = types.str; 36 - example = "spindle.tangled.sh"; 37 description = "Hostname for the server (required)"; 38 }; 39 ··· 92 pipelines = { 93 nixery = mkOption { 94 type = types.str; 95 - default = "nixery.tangled.sh"; 96 description = "Nixery instance to use"; 97 }; 98
··· 33 34 hostname = mkOption { 35 type = types.str; 36 + example = "my.spindle.com"; 37 description = "Hostname for the server (required)"; 38 }; 39 ··· 92 pipelines = { 93 nixery = mkOption { 94 type = types.str; 95 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 description = "Nixery instance to use"; 97 }; 98
+1
nix/pkgs/appview-static-files.nix
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 # for whatever reason (produces broken css), so we are doing this instead
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 # for whatever reason (produces broken css), so we are doing this instead
+18 -7
patchutil/patchutil.go
··· 1 package patchutil 2 3 import ( 4 "fmt" 5 "log" 6 "os" ··· 42 // IsPatchValid checks if the given patch string is valid. 43 // It performs very basic sniffing for either git-diff or git-format-patch 44 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 if len(patch) == 0 { 47 - return false 48 } 49 50 lines := strings.Split(patch, "\n") 51 if len(lines) < 2 { 52 - return false 53 } 54 55 firstLine := strings.TrimSpace(lines[0]) ··· 60 strings.HasPrefix(firstLine, "Index: ") || 61 strings.HasPrefix(firstLine, "+++ ") || 62 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 64 } 65 66 // check if it's format-patch ··· 70 // it's safe to say it's broken. 71 patches, err := ExtractPatches(patch) 72 if err != nil { 73 - return false 74 } 75 - return len(patches) > 0 76 } 77 78 - return false 79 } 80 81 func IsFormatPatch(patch string) bool {
··· 1 package patchutil 2 3 import ( 4 + "errors" 5 "fmt" 6 "log" 7 "os" ··· 43 // IsPatchValid checks if the given patch string is valid. 44 // It performs very basic sniffing for either git-diff or git-format-patch 45 // header lines. For format patches, it attempts to extract and validate each one. 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 53 if len(patch) == 0 { 54 + return EmptyPatchError 55 } 56 57 lines := strings.Split(patch, "\n") 58 if len(lines) < 2 { 59 + return EmptyPatchError 60 } 61 62 firstLine := strings.TrimSpace(lines[0]) ··· 67 strings.HasPrefix(firstLine, "Index: ") || 68 strings.HasPrefix(firstLine, "+++ ") || 69 strings.HasPrefix(firstLine, "@@ ") { 70 + return nil 71 } 72 73 // check if it's format-patch ··· 77 // it's safe to say it's broken. 78 patches, err := ExtractPatches(patch) 79 if err != nil { 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 81 } 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 87 } 88 89 + return GenericPatchError 90 } 91 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 package patchutil 2 3 import ( 4 "reflect" 5 "testing" 6 ) ··· 9 tests := []struct { 10 name string 11 patch string 12 - expected bool 13 }{ 14 { 15 name: `empty patch`, 16 patch: ``, 17 - expected: false, 18 }, 19 { 20 name: `single line patch`, 21 patch: `single line`, 22 - expected: false, 23 }, 24 { 25 name: `valid diff patch`, ··· 31 -old line 32 +new line 33 context`, 34 - expected: true, 35 }, 36 { 37 name: `valid patch starting with ---`, ··· 41 -old line 42 +new line 43 context`, 44 - expected: true, 45 }, 46 { 47 name: `valid patch starting with Index`, ··· 53 -old line 54 +new line 55 context`, 56 - expected: true, 57 }, 58 { 59 name: `valid patch starting with +++`, ··· 63 -old line 64 +new line 65 context`, 66 - expected: true, 67 }, 68 { 69 name: `valid patch starting with @@`, ··· 72 +new line 73 context 74 `, 75 - expected: true, 76 }, 77 { 78 name: `valid format patch`, ··· 90 +new content 91 -- 92 2.48.1`, 93 - expected: true, 94 }, 95 { 96 name: `invalid format patch`, 97 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 From: Author <author@example.com> 99 This is not a valid patch format`, 100 - expected: false, 101 }, 102 { 103 name: `not a patch at all`, ··· 105 just some 106 random text 107 that isn't a patch`, 108 - expected: false, 109 }, 110 } 111 112 for _, tt := range tests { 113 t.Run(tt.name, func(t *testing.T) { 114 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 } 118 })
··· 1 package patchutil 2 3 import ( 4 + "errors" 5 "reflect" 6 "testing" 7 ) ··· 10 tests := []struct { 11 name string 12 patch string 13 + expected error 14 }{ 15 { 16 name: `empty patch`, 17 patch: ``, 18 + expected: EmptyPatchError, 19 }, 20 { 21 name: `single line patch`, 22 patch: `single line`, 23 + expected: EmptyPatchError, 24 }, 25 { 26 name: `valid diff patch`, ··· 32 -old line 33 +new line 34 context`, 35 + expected: nil, 36 }, 37 { 38 name: `valid patch starting with ---`, ··· 42 -old line 43 +new line 44 context`, 45 + expected: nil, 46 }, 47 { 48 name: `valid patch starting with Index`, ··· 54 -old line 55 +new line 56 context`, 57 + expected: nil, 58 }, 59 { 60 name: `valid patch starting with +++`, ··· 64 -old line 65 +new line 66 context`, 67 + expected: nil, 68 }, 69 { 70 name: `valid patch starting with @@`, ··· 73 +new line 74 context 75 `, 76 + expected: nil, 77 }, 78 { 79 name: `valid format patch`, ··· 91 +new content 92 -- 93 2.48.1`, 94 + expected: nil, 95 }, 96 { 97 name: `invalid format patch`, 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 99 From: Author <author@example.com> 100 This is not a valid patch format`, 101 + expected: FormatPatchError, 102 }, 103 { 104 name: `not a patch at all`, ··· 106 just some 107 random text 108 that isn't a patch`, 109 + expected: GenericPatchError, 110 }, 111 } 112 113 for _, tt := range tests { 114 t.Run(tt.name, func(t *testing.T) { 115 result := IsPatchValid(tt.patch) 116 + if !errors.Is(result, tt.expected) { 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 118 } 119 })
+1 -1
spindle/engines/nixery/engine.go
··· 222 }, 223 ReadonlyRootfs: false, 224 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 SecurityOpt: []string{"no-new-privileges"}, 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 }, nil, nil, "")
··· 222 }, 223 ReadonlyRootfs: false, 224 CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 226 SecurityOpt: []string{"no-new-privileges"}, 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 }, nil, nil, "")
+35
spindle/middleware.go
···
··· 1 + package spindle 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (s *Spindle) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + s.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+6 -6
spindle/server.go
··· 108 tangled.RepoNSID, 109 tangled.RepoCollaboratorNSID, 110 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 112 if err != nil { 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 } ··· 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 // job in the above registered queue. 173 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 175 ccfg.Dev = cfg.Server.Dev 176 ccfg.ProcessFunc = spindle.processPipeline 177 ccfg.CursorStore = cursorStore ··· 210 } 211 212 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 217 x := xrpc.Xrpc{ 218 - Logger: logger, 219 Db: s.db, 220 Enforcer: s.e, 221 Engines: s.engs, ··· 305 306 ok := s.jq.Enqueue(queue.Job{ 307 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 Workflows: workflows,
··· 108 tangled.RepoNSID, 109 tangled.RepoCollaboratorNSID, 110 } 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 if err != nil { 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 } ··· 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 // job in the above registered queue. 173 ccfg := eventconsumer.NewConsumerConfig() 174 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 ccfg.Dev = cfg.Server.Dev 176 ccfg.ProcessFunc = spindle.processPipeline 177 ccfg.CursorStore = cursorStore ··· 210 } 211 212 func (s *Spindle) XrpcRouter() http.Handler { 213 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 214 215 + l := log.SubLogger(s.l, "xrpc") 216 + 217 x := xrpc.Xrpc{ 218 + Logger: l, 219 Db: s.db, 220 Enforcer: s.e, 221 Engines: s.engs, ··· 305 306 ok := s.jq.Enqueue(queue.Job{ 307 Run: func() error { 308 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 Workflows: workflows,
+3 -3
spindle/stream.go
··· 10 "strconv" 11 "time" 12 13 "tangled.org/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" ··· 23 } 24 25 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 l.Debug("received new connection") 28 29 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 } 83 case <-time.After(30 * time.Second): 84 // send a keep-alive 85 - l.Debug("sent keepalive") 86 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 l.Error("failed to write control", "err", err) 88 } ··· 222 s.l.Debug("err", "err", err) 223 return err 224 } 225 - s.l.Debug("ops", "ops", events) 226 227 for _, event := range events { 228 // first extract the inner json into a map
··· 10 "strconv" 11 "time" 12 13 + "tangled.org/core/log" 14 "tangled.org/core/spindle/models" 15 16 "github.com/go-chi/chi/v5" ··· 24 } 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 27 + l := log.SubLogger(s.l, "eventstream") 28 + 29 l.Debug("received new connection") 30 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 84 } 85 case <-time.After(30 * time.Second): 86 // send a keep-alive 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 88 l.Error("failed to write control", "err", err) 89 } ··· 223 s.l.Debug("err", "err", err) 224 return err 225 } 226 227 for _, event := range events { 228 // first extract the inner json into a map
+7 -5
types/repo.go
··· 1 package types 2 3 import ( 4 "github.com/go-git/go-git/v5/plumbing/object" 5 ) 6 ··· 33 } 34 35 type RepoFormatPatchResponse struct { 36 - Rev1 string `json:"rev1,omitempty"` 37 - Rev2 string `json:"rev2,omitempty"` 38 - FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 - MergeBase string `json:"merge_base,omitempty"` // deprecated 40 - Patch string `json:"patch,omitempty"` 41 } 42 43 type RepoTreeResponse struct {
··· 1 package types 2 3 import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 5 "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 ··· 34 } 35 36 type RepoFormatPatchResponse struct { 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 40 + FormatPatchRaw string `json:"patch,omitempty"` 41 + CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 42 + CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 43 } 44 45 type RepoTreeResponse struct {
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 "tangled.org/core/idresolver" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 ··· 22 23 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 return &ServiceAuth{ 25 - logger: logger, 26 resolver: resolver, 27 audienceDid: audienceDid, 28 } ··· 30 31 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 token := r.Header.Get("Authorization") 36 token = strings.TrimPrefix(token, "Bearer ") 37 ··· 42 43 did, err := s.Validate(r.Context(), token, nil) 44 if err != nil { 45 - l.Error("signature verification failed", "err", err) 46 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 return 48 } 49 50 r = r.WithContext( 51 context.WithValue(r.Context(), ActorDid, did),
··· 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 13 xrpcerr "tangled.org/core/xrpc/errors" 14 ) 15 ··· 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 25 return &ServiceAuth{ 26 + logger: log.SubLogger(logger, "serviceauth"), 27 resolver: resolver, 28 audienceDid: audienceDid, 29 } ··· 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 token := r.Header.Get("Authorization") 35 token = strings.TrimPrefix(token, "Bearer ") 36 ··· 41 42 did, err := s.Validate(r.Context(), token, nil) 43 if err != nil { 44 + sa.logger.Error("signature verification failed", "err", err) 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 46 return 47 } 48 + 49 + sa.logger.Debug("valid signature", ActorDid, did) 50 51 r = r.WithContext( 52 context.WithValue(r.Context(), ActorDid, did),