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 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+30
api/tangled/repodeleteBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
-1
appview/db/artifact.go
··· 67 67 ) 68 68 69 69 rows, err := e.Query(query, args...) 70 - 71 70 if err != nil { 72 71 return nil, err 73 72 }
+53
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + "time" 6 7 7 8 "tangled.org/core/appview/models" 8 9 ) ··· 59 60 60 61 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 61 62 } 63 + 64 + func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 + var collaborators []models.Collaborator 66 + var conditions []string 67 + var args []any 68 + for _, filter := range filters { 69 + conditions = append(conditions, filter.Condition()) 70 + args = append(args, filter.Arg()...) 71 + } 72 + whereClause := "" 73 + if conditions != nil { 74 + whereClause = " where " + strings.Join(conditions, " and ") 75 + } 76 + query := fmt.Sprintf(`select 77 + id, 78 + did, 79 + rkey, 80 + subject_did, 81 + repo_at, 82 + created 83 + from collaborators %s`, 84 + whereClause, 85 + ) 86 + rows, err := e.Query(query, args...) 87 + if err != nil { 88 + return nil, err 89 + } 90 + defer rows.Close() 91 + for rows.Next() { 92 + var collaborator models.Collaborator 93 + var createdAt string 94 + if err := rows.Scan( 95 + &collaborator.Id, 96 + &collaborator.Did, 97 + &collaborator.Rkey, 98 + &collaborator.SubjectDid, 99 + &collaborator.RepoAt, 100 + &createdAt, 101 + ); err != nil { 102 + return nil, err 103 + } 104 + collaborator.Created, err = time.Parse(time.RFC3339, createdAt) 105 + if err != nil { 106 + collaborator.Created = time.Now() 107 + } 108 + collaborators = append(collaborators, collaborator) 109 + } 110 + if err := rows.Err(); err != nil { 111 + return nil, err 112 + } 113 + return collaborators, nil 114 + }
+45 -28
appview/db/db.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "log" 7 + "log/slog" 8 8 "reflect" 9 9 "strings" 10 10 11 11 _ "github.com/mattn/go-sqlite3" 12 + "tangled.org/core/log" 12 13 ) 13 14 14 15 type DB struct { 15 16 *sql.DB 17 + logger *slog.Logger 16 18 } 17 19 18 20 type Execer interface { ··· 26 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 29 } 28 30 29 - func Make(dbPath string) (*DB, error) { 31 + func Make(ctx context.Context, dbPath string) (*DB, error) { 30 32 // https://github.com/mattn/go-sqlite3#connection-string 31 33 opts := []string{ 32 34 "_foreign_keys=1", ··· 35 37 "_auto_vacuum=incremental", 36 38 } 37 39 40 + logger := log.FromContext(ctx) 41 + logger = log.SubLogger(logger, "db") 42 + 38 43 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 44 if err != nil { 40 45 return nil, err 41 46 } 42 - 43 - ctx := context.Background() 44 47 45 48 conn, err := db.Conn(ctx) 46 49 if err != nil { ··· 574 577 } 575 578 576 579 // run migrations 577 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 580 + runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 578 581 tx.Exec(` 579 582 alter table repos add column description text check (length(description) <= 200); 580 583 `) 581 584 return nil 582 585 }) 583 586 584 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 587 + runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 585 588 // add unconstrained column 586 589 _, err := tx.Exec(` 587 590 alter table public_keys ··· 604 607 return nil 605 608 }) 606 609 607 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 610 + runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 608 611 _, err := tx.Exec(` 609 612 alter table comments drop column comment_at; 610 613 alter table comments add column rkey text; ··· 612 615 return err 613 616 }) 614 617 615 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 618 + runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 616 619 _, err := tx.Exec(` 617 620 alter table comments add column deleted text; -- timestamp 618 621 alter table comments add column edited text; -- timestamp ··· 620 623 return err 621 624 }) 622 625 623 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 626 + runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 624 627 _, err := tx.Exec(` 625 628 alter table pulls add column source_branch text; 626 629 alter table pulls add column source_repo_at text; ··· 629 632 return err 630 633 }) 631 634 632 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 635 + runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 633 636 _, err := tx.Exec(` 634 637 alter table repos add column source text; 635 638 `) ··· 641 644 // 642 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 643 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 644 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 647 + runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 645 648 _, err := tx.Exec(` 646 649 create table pulls_new ( 647 650 -- identifiers ··· 698 701 }) 699 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 700 703 701 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 704 + runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 702 705 tx.Exec(` 703 706 alter table repos add column spindle text; 704 707 `) ··· 708 711 // drop all knot secrets, add unique constraint to knots 709 712 // 710 713 // knots will henceforth use service auth for signed requests 711 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 714 + runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 712 715 _, err := tx.Exec(` 713 716 create table registrations_new ( 714 717 id integer primary key autoincrement, ··· 731 734 }) 732 735 733 736 // recreate and add rkey + created columns with default constraint 734 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 737 + runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 735 738 // create new table 736 739 // - repo_at instead of repo integer 737 740 // - rkey field ··· 785 788 return err 786 789 }) 787 790 788 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 791 + runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 789 792 _, err := tx.Exec(` 790 793 alter table issues add column rkey text not null default ''; 791 794 ··· 797 800 }) 798 801 799 802 // repurpose the read-only column to "needs-upgrade" 800 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 803 + runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 801 804 _, err := tx.Exec(` 802 805 alter table registrations rename column read_only to needs_upgrade; 803 806 `) ··· 805 808 }) 806 809 807 810 // require all knots to upgrade after the release of total xrpc 808 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 811 + runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 809 812 _, err := tx.Exec(` 810 813 update registrations set needs_upgrade = 1; 811 814 `) ··· 813 816 }) 814 817 815 818 // require all knots to upgrade after the release of total xrpc 816 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 819 + runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 817 820 _, err := tx.Exec(` 818 821 alter table spindles add column needs_upgrade integer not null default 0; 819 822 `) ··· 831 834 // 832 835 // disable foreign-keys for the next migration 833 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 834 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 837 + runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 835 838 _, err := tx.Exec(` 836 839 create table if not exists issues_new ( 837 840 -- identifiers ··· 901 904 // - new columns 902 905 // * column "reply_to" which can be any other comment 903 906 // * column "at-uri" which is a generated column 904 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 907 + runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 905 908 _, err := tx.Exec(` 906 909 create table if not exists issue_comments ( 907 910 -- identifiers ··· 961 964 // 962 965 // disable foreign-keys for the next migration 963 966 conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 - runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 967 + runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 968 _, err := tx.Exec(` 966 969 create table if not exists pulls_new ( 967 970 -- identifiers ··· 1042 1045 // 1043 1046 // disable foreign-keys for the next migration 1044 1047 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 - runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1048 + runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 1049 _, err := tx.Exec(` 1047 1050 create table if not exists pull_submissions_new ( 1048 1051 -- identifiers ··· 1094 1097 }) 1095 1098 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 1099 1097 - return &DB{db}, nil 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 1098 1113 } 1099 1114 1100 1115 type migrationFn = func(*sql.Tx) error 1101 1116 1102 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1117 + func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1118 + logger = logger.With("migration", name) 1119 + 1103 1120 tx, err := c.BeginTx(context.Background(), nil) 1104 1121 if err != nil { 1105 1122 return err ··· 1116 1133 // run migration 1117 1134 err = migrationFn(tx) 1118 1135 if err != nil { 1119 - log.Printf("Failed to run migration %s: %v", name, err) 1136 + logger.Error("failed to run migration", "err", err) 1120 1137 return err 1121 1138 } 1122 1139 1123 1140 // mark migration as complete 1124 1141 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1125 1142 if err != nil { 1126 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 1143 + logger.Error("failed to mark migration as complete", "err", err) 1127 1144 return err 1128 1145 } 1129 1146 ··· 1132 1149 return err 1133 1150 } 1134 1151 1135 - log.Printf("migration %s applied successfully", name) 1152 + logger.Info("migration applied successfully") 1136 1153 } else { 1137 - log.Printf("skipped migration %s, already applied", name) 1154 + logger.Warn("skipped migration, already applied") 1138 1155 } 1139 1156 1140 1157 return nil
+8 -25
appview/db/issues.go
··· 101 101 pLower := FilterGte("row_num", page.Offset+1) 102 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 103 104 - args = append(args, pLower.Arg()...) 105 - args = append(args, pUpper.Arg()...) 106 - pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 104 + pageClause := "" 105 + if page.Limit > 0 { 106 + args = append(args, pLower.Arg()...) 107 + args = append(args, pUpper.Arg()...) 108 + pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 109 + } 107 110 108 111 query := fmt.Sprintf( 109 112 ` ··· 128 131 %s 129 132 `, 130 133 whereClause, 131 - pagination, 134 + pageClause, 132 135 ) 133 136 134 137 rows, err := e.Query(query, args...) ··· 244 247 } 245 248 246 249 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 250 + return GetIssuesPaginated(e, pagination.Page{}, filters...) 268 251 } 269 252 270 253 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+88 -49
appview/db/notifications.go
··· 8 8 "strings" 9 9 "time" 10 10 11 + "github.com/bluesky-social/indigo/atproto/syntax" 11 12 "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/pagination" 13 14 ) 14 15 15 - func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 + func CreateNotification(e Execer, notification *models.Notification) error { 16 17 query := ` 17 18 INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 19 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 20 ` 20 21 21 - result, err := d.DB.ExecContext(ctx, query, 22 + result, err := e.Exec(query, 22 23 notification.RecipientDid, 23 24 notification.ActorDid, 24 25 string(notification.Type), ··· 59 60 whereClause += " AND " + condition 60 61 } 61 62 } 63 + pageClause := "" 64 + if page.Limit > 0 { 65 + pageClause = " limit ? offset ? " 66 + args = append(args, page.Limit, page.Offset) 67 + } 62 68 63 69 query := fmt.Sprintf(` 64 70 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 71 from notifications 66 72 %s 67 73 order by created desc 68 - limit ? offset ? 69 - `, whereClause) 70 - 71 - args = append(args, page.Limit, page.Offset) 74 + %s 75 + `, whereClause, pageClause) 72 76 73 77 rows, err := e.QueryContext(context.Background(), query, args...) 74 78 if err != nil { ··· 274 278 return count, nil 275 279 } 276 280 277 - func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 281 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 278 282 idFilter := FilterEq("id", notificationID) 279 283 recipientFilter := FilterEq("recipient_did", userDID) 280 284 ··· 286 290 287 291 args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 292 289 - result, err := d.DB.ExecContext(ctx, query, args...) 293 + result, err := e.Exec(query, args...) 290 294 if err != nil { 291 295 return fmt.Errorf("failed to mark notification as read: %w", err) 292 296 } ··· 303 307 return nil 304 308 } 305 309 306 - func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 310 + func MarkAllNotificationsRead(e Execer, userDID string) error { 307 311 recipientFilter := FilterEq("recipient_did", userDID) 308 312 readFilter := FilterEq("read", 0) 309 313 ··· 315 319 316 320 args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 321 318 - _, err := d.DB.ExecContext(ctx, query, args...) 322 + _, err := e.Exec(query, args...) 319 323 if err != nil { 320 324 return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 325 } ··· 323 327 return nil 324 328 } 325 329 326 - func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 330 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 327 331 idFilter := FilterEq("id", notificationID) 328 332 recipientFilter := FilterEq("recipient_did", userDID) 329 333 ··· 334 338 335 339 args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 340 337 - result, err := d.DB.ExecContext(ctx, query, args...) 341 + result, err := e.Exec(query, args...) 338 342 if err != nil { 339 343 return fmt.Errorf("failed to delete notification: %w", err) 340 344 } ··· 351 355 return nil 352 356 } 353 357 354 - func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 - userFilter := FilterEq("user_did", userDID) 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 + } 356 386 357 387 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 - ) 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) 378 404 405 + rows, err := e.Query(query, args...) 379 406 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 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 393 427 } 394 - return nil, fmt.Errorf("failed to get notification preferences: %w", err) 428 + 429 + prefsMap[prefs.UserDid] = &prefs 395 430 } 396 431 397 - return &prefs, nil 432 + if err := rows.Err(); err != nil { 433 + return nil, err 434 + } 435 + 436 + return prefsMap, nil 398 437 } 399 438 400 439 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
+23 -20
appview/db/pulls.go
··· 90 90 pull.ID = int(id) 91 91 92 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) 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 96 return err 97 97 } 98 98 ··· 246 246 // collect pull source for all pulls that need it 247 247 var sourceAts []syntax.ATURI 248 248 for _, p := range pulls { 249 - if p.PullSource.RepoAt != nil { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 251 } 252 252 } ··· 259 259 sourceRepoMap[r.RepoAt()] = &r 260 260 } 261 261 for _, p := range pulls { 262 - if p.PullSource.RepoAt != nil { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 263 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 264 p.PullSource.Repo = sourceRepo 265 265 } ··· 313 313 pull_at, 314 314 round_number, 315 315 patch, 316 + combined, 316 317 created, 317 318 source_rev 318 319 from ··· 332 333 333 334 for rows.Next() { 334 335 var submission models.PullSubmission 335 - var createdAt string 336 - var sourceRev sql.NullString 336 + var submissionCreatedStr string 337 + var submissionSourceRev, submissionCombined sql.NullString 337 338 err := rows.Scan( 338 339 &submission.ID, 339 340 &submission.PullAt, 340 341 &submission.RoundNumber, 341 342 &submission.Patch, 342 - &createdAt, 343 - &sourceRev, 343 + &submissionCombined, 344 + &submissionCreatedStr, 345 + &submissionSourceRev, 344 346 ) 345 347 if err != nil { 346 348 return nil, err 347 349 } 348 350 349 - createdTime, err := time.Parse(time.RFC3339, createdAt) 350 - if err != nil { 351 - return nil, err 351 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 352 + submission.Created = t 352 353 } 353 - submission.Created = createdTime 354 354 355 - if sourceRev.Valid { 356 - submission.SourceRev = sourceRev.String 355 + if submissionSourceRev.Valid { 356 + submission.SourceRev = submissionSourceRev.String 357 + } 358 + 359 + if submissionCombined.Valid { 360 + submission.Combined = submissionCombined.String 357 361 } 358 362 359 363 submissionMap[submission.ID] = &submission ··· 590 594 return err 591 595 } 592 596 593 - func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 594 - newRoundNumber := len(pull.Submissions) 597 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 595 598 _, err := e.Exec(` 596 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 - values (?, ?, ?, ?) 598 - `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 599 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 600 + values (?, ?, ?, ?, ?) 601 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 599 602 600 603 return err 601 604 }
+34 -7
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 67 81 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 71 91 } 72 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 73 99 } 74 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 75 102 } 76 103 77 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+38 -10
appview/db/timeline.go
··· 9 9 10 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 13 var events []models.TimelineEvent 14 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 16 29 if err != nil { 17 30 return nil, err 18 31 } 19 32 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 21 34 if err != nil { 22 35 return nil, err 23 36 } 24 37 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 26 39 if err != nil { 27 40 return nil, err 28 41 } ··· 70 83 return isStarred, starCount 71 84 } 72 85 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 86 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 + filters := make([]filter, 0) 88 + if userIsFollowing != nil { 89 + filters = append(filters, FilterIn("did", userIsFollowing)) 90 + } 91 + 92 + repos, err := GetRepos(e, limit, filters...) 75 93 if err != nil { 76 94 return nil, err 77 95 } ··· 125 143 return events, nil 126 144 } 127 145 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 146 + func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 + filters := make([]filter, 0) 148 + if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + } 151 + 152 + stars, err := GetStars(e, limit, filters...) 130 153 if err != nil { 131 154 return nil, err 132 155 } ··· 166 189 return events, nil 167 190 } 168 191 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 192 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 + filters := make([]filter, 0) 194 + if userIsFollowing != nil { 195 + filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 + } 197 + 198 + follows, err := GetFollows(e, limit, filters...) 171 199 if err != nil { 172 200 return nil, err 173 201 }
+4 -4
appview/dns/cloudflare.go
··· 30 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 31 } 32 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 35 Type: record.Type, 36 36 Name: record.Name, 37 37 Content: record.Content, ··· 39 39 Proxied: &record.Proxied, 40 40 }) 41 41 if err != nil { 42 - return fmt.Errorf("failed to create DNS record: %w", err) 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 43 } 44 - return nil 44 + return result.ID, nil 45 45 } 46 46 47 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+1 -1
appview/ingester.go
··· 89 89 } 90 90 91 91 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 92 + l.Warn("refused to ingest record", "err", err) 93 93 } 94 94 95 95 return nil
+41 -43
appview/issues/issues.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "slices" 12 11 "time" 13 12 14 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 17 "github.com/go-chi/chi/v5" ··· 26 26 "tangled.org/core/appview/pagination" 27 27 "tangled.org/core/appview/reporesolver" 28 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 29 "tangled.org/core/idresolver" 31 - tlog "tangled.org/core/log" 32 30 "tangled.org/core/tid" 33 31 ) 34 32 ··· 53 51 config *config.Config, 54 52 notifier notify.Notifier, 55 53 validator *validator.Validator, 54 + logger *slog.Logger, 56 55 ) *Issues { 57 56 return &Issues{ 58 57 oauth: oauth, ··· 62 61 db: db, 63 62 config: config, 64 63 notifier: notifier, 65 - logger: tlog.New("issues"), 64 + logger: logger, 66 65 validator: validator, 67 66 } 68 67 } ··· 72 71 user := rp.oauth.GetUser(r) 73 72 f, err := rp.repoResolver.Resolve(r) 74 73 if err != nil { 75 - log.Println("failed to get repo and knot", err) 74 + l.Error("failed to get repo and knot", "err", err) 76 75 return 77 76 } 78 77 ··· 83 82 return 84 83 } 85 84 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 86 if err != nil { 88 87 l.Error("failed to get issue reactions", "err", err) 89 88 } ··· 99 98 db.FilterContains("scope", tangled.RepoIssueNSID), 100 99 ) 101 100 if err != nil { 102 - log.Println("failed to fetch labels", err) 101 + l.Error("failed to fetch labels", "err", err) 103 102 rp.pages.Error503(w) 104 103 return 105 104 } ··· 115 114 Issue: issue, 116 115 CommentList: issue.CommentList(), 117 116 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 117 + Reactions: reactionMap, 119 118 UserReacted: userReactions, 120 119 LabelDefs: defs, 121 120 }) ··· 126 125 user := rp.oauth.GetUser(r) 127 126 f, err := rp.repoResolver.Resolve(r) 128 127 if err != nil { 129 - log.Println("failed to get repo and knot", err) 128 + l.Error("failed to get repo and knot", "err", err) 130 129 return 131 130 } 132 131 ··· 166 165 return 167 166 } 168 167 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 168 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 169 if err != nil { 171 170 l.Error("failed to get record", "err", err) 172 171 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 172 return 174 173 } 175 174 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 175 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 176 Collection: tangled.RepoIssueNSID, 178 177 Repo: user.Did, 179 178 Rkey: newIssue.Rkey, ··· 199 198 200 199 err = db.PutIssue(tx, newIssue) 201 200 if err != nil { 202 - log.Println("failed to edit issue", err) 201 + l.Error("failed to edit issue", "err", err) 203 202 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 203 return 205 204 } ··· 237 236 // delete from PDS 238 237 client, err := rp.oauth.AuthorizedClient(r) 239 238 if err != nil { 240 - log.Println("failed to get authorized client", err) 239 + l.Error("failed to get authorized client", "err", err) 241 240 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 241 return 243 242 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 243 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 244 Collection: tangled.RepoIssueNSID, 246 245 Repo: issue.Did, 247 246 Rkey: issue.Rkey, ··· 282 281 283 282 collaborators, err := f.Collaborators(r.Context()) 284 283 if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 284 + l.Error("failed to fetch repo collaborators", "err", err) 286 285 } 287 286 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 287 return user.Did == collab.Did ··· 296 295 db.FilterEq("id", issue.Id), 297 296 ) 298 297 if err != nil { 299 - log.Println("failed to close issue", err) 298 + l.Error("failed to close issue", "err", err) 300 299 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 300 return 302 301 } ··· 307 306 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 308 307 return 309 308 } else { 310 - log.Println("user is not permitted to close issue") 309 + l.Error("user is not permitted to close issue") 311 310 http.Error(w, "for biden", http.StatusUnauthorized) 312 311 return 313 312 } ··· 318 317 user := rp.oauth.GetUser(r) 319 318 f, err := rp.repoResolver.Resolve(r) 320 319 if err != nil { 321 - log.Println("failed to get repo and knot", err) 320 + l.Error("failed to get repo and knot", "err", err) 322 321 return 323 322 } 324 323 ··· 331 330 332 331 collaborators, err := f.Collaborators(r.Context()) 333 332 if err != nil { 334 - log.Println("failed to fetch repo collaborators: %w", err) 333 + l.Error("failed to fetch repo collaborators", "err", err) 335 334 } 336 335 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 337 336 return user.Did == collab.Did ··· 344 343 db.FilterEq("id", issue.Id), 345 344 ) 346 345 if err != nil { 347 - log.Println("failed to reopen issue", err) 346 + l.Error("failed to reopen issue", "err", err) 348 347 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 349 348 return 350 349 } 351 350 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 352 351 return 353 352 } else { 354 - log.Println("user is not the owner of the repo") 353 + l.Error("user is not the owner of the repo") 355 354 http.Error(w, "forbidden", http.StatusUnauthorized) 356 355 return 357 356 } ··· 408 407 } 409 408 410 409 // create a record first 411 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 410 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 412 411 Collection: tangled.RepoIssueCommentNSID, 413 412 Repo: comment.Did, 414 413 Rkey: comment.Rkey, ··· 538 537 newBody := r.FormValue("body") 539 538 client, err := rp.oauth.AuthorizedClient(r) 540 539 if err != nil { 541 - log.Println("failed to get authorized client", err) 540 + l.Error("failed to get authorized client", "err", err) 542 541 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 543 542 return 544 543 } ··· 551 550 552 551 _, err = db.AddIssueComment(rp.db, newComment) 553 552 if err != nil { 554 - log.Println("failed to perferom update-description query", err) 553 + l.Error("failed to perferom update-description query", "err", err) 555 554 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 556 555 return 557 556 } ··· 559 558 // rkey is optional, it was introduced later 560 559 if newComment.Rkey != "" { 561 560 // update the record on pds 562 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 561 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 562 if err != nil { 564 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 563 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 564 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 565 return 567 566 } 568 567 569 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 568 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 570 569 Collection: tangled.RepoIssueCommentNSID, 571 570 Repo: user.Did, 572 571 Rkey: newComment.Rkey, ··· 729 728 if comment.Rkey != "" { 730 729 client, err := rp.oauth.AuthorizedClient(r) 731 730 if err != nil { 732 - log.Println("failed to get authorized client", err) 731 + l.Error("failed to get authorized client", "err", err) 733 732 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 733 return 735 734 } 736 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 735 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 737 736 Collection: tangled.RepoIssueCommentNSID, 738 737 Repo: user.Did, 739 738 Rkey: comment.Rkey, 740 739 }) 741 740 if err != nil { 742 - log.Println(err) 741 + l.Error("failed to delete from PDS", "err", err) 743 742 } 744 743 } 745 744 ··· 757 756 } 758 757 759 758 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 759 + l := rp.logger.With("handler", "RepoIssues") 760 + 760 761 params := r.URL.Query() 761 762 state := params.Get("state") 762 763 isOpen := true ··· 769 770 isOpen = true 770 771 } 771 772 772 - page, ok := r.Context().Value("page").(pagination.Page) 773 - if !ok { 774 - log.Println("failed to get page") 775 - page = pagination.FirstPage() 776 - } 773 + page := pagination.FromContext(r.Context()) 777 774 778 775 user := rp.oauth.GetUser(r) 779 776 f, err := rp.repoResolver.Resolve(r) 780 777 if err != nil { 781 - log.Println("failed to get repo and knot", err) 778 + l.Error("failed to get repo and knot", "err", err) 782 779 return 783 780 } 784 781 ··· 793 790 db.FilterEq("open", openVal), 794 791 ) 795 792 if err != nil { 796 - log.Println("failed to get issues", err) 793 + l.Error("failed to get issues", "err", err) 797 794 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 798 795 return 799 796 } ··· 804 801 db.FilterContains("scope", tangled.RepoIssueNSID), 805 802 ) 806 803 if err != nil { 807 - log.Println("failed to fetch labels", err) 804 + l.Error("failed to fetch labels", "err", err) 808 805 rp.pages.Error503(w) 809 806 return 810 807 } ··· 848 845 Body: r.FormValue("body"), 849 846 Did: user.Did, 850 847 Created: time.Now(), 848 + Repo: &f.Repo, 851 849 } 852 850 853 851 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 865 863 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 864 return 867 865 } 868 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 866 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 869 867 Collection: tangled.RepoIssueNSID, 870 868 Repo: user.Did, 871 869 Rkey: issue.Rkey, ··· 901 899 902 900 err = db.PutIssue(tx, issue) 903 901 if err != nil { 904 - log.Println("failed to create issue", err) 902 + l.Error("failed to create issue", "err", err) 905 903 rp.pages.Notice(w, "issues", "Failed to create issue.") 906 904 return 907 905 } 908 906 909 907 if err = tx.Commit(); err != nil { 910 - log.Println("failed to create issue", err) 908 + l.Error("failed to create issue", "err", err) 911 909 rp.pages.Notice(w, "issues", "Failed to create issue.") 912 910 return 913 911 } ··· 923 921 // this is used to rollback changes made to the PDS 924 922 // 925 923 // it is a no-op if the provided ATURI is empty 926 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 924 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 927 925 if aturi == "" { 928 926 return nil 929 927 } ··· 934 932 repo := parsed.Authority().String() 935 933 rkey := parsed.RecordKey().String() 936 934 937 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 935 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 938 936 Collection: collection, 939 937 Repo: repo, 940 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 16 r.Route("/{issue}", func(r chi.Router) { 17 17 r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 19 20 20 21 // authenticated routes 21 22 r.Group(func(r chi.Router) {
+6 -6
appview/knots/knots.go
··· 185 185 return 186 186 } 187 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 189 var exCid *string 190 190 if ex != nil { 191 191 exCid = ex.Cid 192 192 } 193 193 194 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 196 Collection: tangled.KnotNSID, 197 197 Repo: user.Did, 198 198 Rkey: domain, ··· 323 323 return 324 324 } 325 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 327 Collection: tangled.KnotNSID, 328 328 Repo: user.Did, 329 329 Rkey: domain, ··· 431 431 return 432 432 } 433 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 435 var exCid *string 436 436 if ex != nil { 437 437 exCid = ex.Cid 438 438 } 439 439 440 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 442 Collection: tangled.KnotNSID, 443 443 Repo: user.Did, 444 444 Rkey: domain, ··· 555 555 556 556 rkey := tid.TID() 557 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 559 Collection: tangled.KnotMemberNSID, 560 560 Repo: user.Did, 561 561 Rkey: rkey,
+10 -12
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 12 "tangled.org/core/api/tangled" 18 13 "tangled.org/core/appview/db" 19 14 "tangled.org/core/appview/middleware" ··· 21 16 "tangled.org/core/appview/oauth" 22 17 "tangled.org/core/appview/pages" 23 18 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 - "tangled.org/core/log" 26 19 "tangled.org/core/rbac" 27 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" 28 27 ) 29 28 30 29 type Labels struct { ··· 42 41 db *db.DB, 43 42 validator *validator.Validator, 44 43 enforcer *rbac.Enforcer, 44 + logger *slog.Logger, 45 45 ) *Labels { 46 - logger := log.New("labels") 47 - 48 46 return &Labels{ 49 47 oauth: oauth, 50 48 pages: pages, ··· 196 194 return 197 195 } 198 196 199 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 198 Collection: tangled.LabelOpNSID, 201 199 Repo: did, 202 200 Rkey: rkey, ··· 252 250 // this is used to rollback changes made to the PDS 253 251 // 254 252 // it is a no-op if the provided ATURI is empty 255 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 253 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 254 if aturi == "" { 257 255 return nil 258 256 } ··· 263 261 repo := parsed.Authority().String() 264 262 rkey := parsed.RecordKey().String() 265 263 266 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 264 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 265 Collection: collection, 268 266 Repo: repo, 269 267 Rkey: rkey,
+6 -15
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 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 { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 56 47 return func(next http.Handler) http.Handler { 57 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 49 returnURL := "/" ··· 72 63 } 73 64 } 74 65 75 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 76 67 if err != nil { 77 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 78 69 redirectFunc(w, r) 79 70 return 80 71 } 81 72 82 - if !auth { 83 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 84 75 redirectFunc(w, r) 85 76 return 86 77 } ··· 114 105 } 115 106 } 116 107 117 - ctx := context.WithValue(r.Context(), "page", page) 108 + ctx := pagination.IntoContext(r.Context(), page) 118 109 next.ServeHTTP(w, r.WithContext(ctx)) 119 110 }) 120 111 }
+24
appview/models/issue.go
··· 54 54 Replies []*IssueComment 55 55 } 56 56 57 + func (it *CommentListItem) Participants() []syntax.DID { 58 + participantSet := make(map[syntax.DID]struct{}) 59 + participants := []syntax.DID{} 60 + 61 + addParticipant := func(did syntax.DID) { 62 + if _, exists := participantSet[did]; !exists { 63 + participantSet[did] = struct{}{} 64 + participants = append(participants, did) 65 + } 66 + } 67 + 68 + addParticipant(syntax.DID(it.Self.Did)) 69 + 70 + for _, c := range it.Replies { 71 + addParticipant(syntax.DID(c.Did)) 72 + } 73 + 74 + return participants 75 + } 76 + 57 77 func (i *Issue) CommentList() []CommentListItem { 58 78 // Create a map to quickly find comments by their aturi 59 79 toplevel := make(map[string]*CommentListItem) ··· 167 187 168 188 func (i *IssueComment) IsTopLevel() bool { 169 189 return i.ReplyTo == nil 190 + } 191 + 192 + func (i *IssueComment) IsReply() bool { 193 + return i.ReplyTo != nil 170 194 } 171 195 172 196 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+14 -13
appview/models/label.go
··· 461 461 return result 462 462 } 463 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 + 464 472 func DefaultLabelDefs() []string { 465 - rkeys := []string{ 466 - "wontfix", 467 - "duplicate", 468 - "assignee", 469 - "good-first-issue", 470 - "documentation", 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 471 479 } 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 } 480 481 481 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+43 -1
appview/models/notifications.go
··· 2 2 3 3 import ( 4 4 "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 5 7 ) 6 8 7 9 type NotificationType string ··· 69 71 70 72 type NotificationPreferences struct { 71 73 ID int64 72 - UserDid string 74 + UserDid syntax.DID 73 75 RepoStarred bool 74 76 IssueCreated bool 75 77 IssueCommented bool ··· 80 82 IssueClosed bool 81 83 EmailNotifications bool 82 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 84 func (p Pull) AsRecord() tangled.RepoPull { 85 85 var source *tangled.RepoPull_Source 86 86 if p.PullSource != nil { 87 - s := p.PullSource.AsRecord() 88 - source = &s 87 + source = &tangled.RepoPull_Source{} 88 + source.Branch = p.PullSource.Branch 89 89 source.Sha = p.LatestSha() 90 + if p.PullSource.RepoAt != nil { 91 + s := p.PullSource.RepoAt.String() 92 + source.Repo = &s 93 + } 90 94 } 91 95 92 96 record := tangled.RepoPull{ ··· 111 115 Repo *Repo 112 116 } 113 117 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 118 type PullSubmission struct { 128 119 // ids 129 120 ID int ··· 134 125 // content 135 126 RoundNumber int 136 127 Patch string 128 + Combined string 137 129 Comments []PullComment 138 130 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 131 ··· 159 151 Created time.Time 160 152 } 161 153 154 + func (p *Pull) LastRoundNumber() int { 155 + return len(p.Submissions) - 1 156 + } 157 + 158 + func (p *Pull) LatestSubmission() *PullSubmission { 159 + return p.Submissions[p.LastRoundNumber()] 160 + } 161 + 162 162 func (p *Pull) LatestPatch() string { 163 - latestSubmission := p.Submissions[p.LastRoundNumber()] 164 - return latestSubmission.Patch 163 + return p.LatestSubmission().Patch 165 164 } 166 165 167 166 func (p *Pull) LatestSha() string { 168 - latestSubmission := p.Submissions[p.LastRoundNumber()] 169 - return latestSubmission.SourceRev 167 + return p.LatestSubmission().SourceRev 170 168 } 171 169 172 170 func (p *Pull) PullAt() syntax.ATURI { 173 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 - } 175 - 176 - func (p *Pull) LastRoundNumber() int { 177 - return len(p.Submissions) - 1 178 172 } 179 173 180 174 func (p *Pull) IsPatchBased() bool { ··· 263 257 return participants 264 258 } 265 259 260 + func (s PullSubmission) CombinedPatch() string { 261 + if s.Combined == "" { 262 + return s.Patch 263 + } 264 + 265 + return s.Combined 266 + } 267 + 266 268 type Stack []*Pull 267 269 268 270 // position of this pull in the stack ··· 350 352 351 353 return mergeable 352 354 } 355 + 356 + type BranchDeleteStatus struct { 357 + Repo *Repo 358 + Branch string 359 + }
+5
appview/models/reaction.go
··· 55 55 Rkey string 56 56 Kind ReactionKind 57 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+5
appview/models/repo.go
··· 86 86 RepoAt syntax.ATURI 87 87 LabelAt syntax.ATURI 88 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+36 -39
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "fmt" 5 - "log" 4 + "log/slog" 6 5 "net/http" 7 6 "strconv" 8 7 ··· 15 14 ) 16 15 17 16 type Notifications struct { 18 - db *db.DB 19 - oauth *oauth.OAuth 20 - pages *pages.Pages 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + logger *slog.Logger 21 21 } 22 22 23 - func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 24 24 return &Notifications{ 25 - db: database, 26 - oauth: oauthHandler, 27 - pages: pagesHandler, 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + logger: logger, 28 29 } 29 30 } 30 31 31 32 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 33 r := chi.NewRouter() 33 34 34 - r.Use(middleware.AuthMiddleware(n.oauth)) 35 + r.Get("/count", n.getUnreadCount) 35 36 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) 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 + }) 42 44 43 45 return r 44 46 } 45 47 46 48 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 - userDid := n.oauth.GetDid(r) 49 + l := n.logger.With("handler", "notificationsPage") 50 + user := n.oauth.GetUser(r) 48 51 49 - page, ok := r.Context().Value("page").(pagination.Page) 50 - if !ok { 51 - log.Println("failed to get page") 52 - page = pagination.FirstPage() 53 - } 52 + page := pagination.FromContext(r.Context()) 54 53 55 54 total, err := db.CountNotifications( 56 55 n.db, 57 - db.FilterEq("recipient_did", userDid), 56 + db.FilterEq("recipient_did", user.Did), 58 57 ) 59 58 if err != nil { 60 - log.Println("failed to get total notifications:", err) 59 + l.Error("failed to get total notifications", "err", err) 61 60 n.pages.Error500(w) 62 61 return 63 62 } ··· 65 64 notifications, err := db.GetNotificationsWithEntities( 66 65 n.db, 67 66 page, 68 - db.FilterEq("recipient_did", userDid), 67 + db.FilterEq("recipient_did", user.Did), 69 68 ) 70 69 if err != nil { 71 - log.Println("failed to get notifications:", err) 70 + l.Error("failed to get notifications", "err", err) 72 71 n.pages.Error500(w) 73 72 return 74 73 } 75 74 76 - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 75 + err = db.MarkAllNotificationsRead(n.db, user.Did) 77 76 if err != nil { 78 - log.Println("failed to mark notifications as read:", err) 77 + l.Error("failed to mark notifications as read", "err", err) 79 78 } 80 79 81 80 unreadCount := 0 82 81 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{ 82 + n.pages.Notifications(w, pages.NotificationsParams{ 90 83 LoggedInUser: user, 91 84 Notifications: notifications, 92 85 UnreadCount: unreadCount, 93 86 Page: page, 94 87 Total: total, 95 - })) 88 + }) 96 89 } 97 90 98 91 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 92 user := n.oauth.GetUser(r) 93 + if user == nil { 94 + return 95 + } 96 + 100 97 count, err := db.CountNotifications( 101 98 n.db, 102 99 db.FilterEq("recipient_did", user.Did), ··· 127 124 return 128 125 } 129 126 130 - err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 127 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 131 128 if err != nil { 132 129 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 133 130 return ··· 139 136 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 140 137 userDid := n.oauth.GetDid(r) 141 138 142 - err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 139 + err := db.MarkAllNotificationsRead(n.db, userDid) 143 140 if err != nil { 144 141 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 145 142 return ··· 158 155 return 159 156 } 160 157 161 - err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 158 + err = db.DeleteNotification(n.db, notificationID, userDid) 162 159 if err != nil { 163 160 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 164 161 return
+303 -251
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 + "maps" 7 + "slices" 6 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 7 10 "tangled.org/core/appview/db" 8 11 "tangled.org/core/appview/models" 9 12 "tangled.org/core/appview/notify" ··· 36 39 return 37 40 } 38 41 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 - } 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 53 50 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 - } 51 + n.notifyEvent( 52 + actorDid, 53 + recipients, 54 + eventType, 55 + entityType, 56 + entityId, 57 + repoId, 58 + issueId, 59 + pullId, 60 + ) 67 61 } 68 62 69 63 func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { ··· 71 65 } 72 66 73 67 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 68 80 - if repo.Did == issue.Did { 81 - return 82 - } 83 - 84 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 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())) 85 75 if err != nil { 86 - log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 76 + log.Printf("failed to fetch collaborators: %v", err) 87 77 return 88 78 } 89 - if !prefs.IssueCreated { 90 - return 79 + for _, c := range collaborators { 80 + recipients = append(recipients, c.SubjectDid) 91 81 } 92 82 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 - } 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 102 90 103 - err = n.db.CreateNotification(ctx, notification) 104 - if err != nil { 105 - log.Printf("NewIssue: failed to create notification: %v", err) 106 - return 107 - } 91 + n.notifyEvent( 92 + actorDid, 93 + recipients, 94 + eventType, 95 + entityType, 96 + entityId, 97 + repoId, 98 + issueId, 99 + pullId, 100 + ) 108 101 } 109 102 110 103 func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { ··· 119 112 } 120 113 issue := issues[0] 121 114 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 - } 115 + var recipients []syntax.DID 116 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 127 117 128 - recipients := make(map[string]bool) 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() 129 122 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) 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 + } 137 128 } 129 + } else { 130 + // not a reply, notify just the issue author 131 + recipients = append(recipients, syntax.DID(issue.Did)) 138 132 } 139 133 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 - } 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 149 141 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 - } 142 + n.notifyEvent( 143 + actorDid, 144 + recipients, 145 + eventType, 146 + entityType, 147 + entityId, 148 + repoId, 149 + issueId, 150 + pullId, 151 + ) 167 152 } 168 153 169 154 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 - } 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 186 161 187 - err = n.db.CreateNotification(ctx, notification) 188 - if err != nil { 189 - log.Printf("NewFollow: failed to create notification: %v", err) 190 - return 191 - } 162 + n.notifyEvent( 163 + actorDid, 164 + recipients, 165 + eventType, 166 + entityType, 167 + entityId, 168 + repoId, 169 + issueId, 170 + pullId, 171 + ) 192 172 } 193 173 194 174 func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { ··· 202 182 return 203 183 } 204 184 205 - if repo.Did == pull.OwnerDid { 206 - return 207 - } 208 - 209 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 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())) 210 191 if err != nil { 211 - log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 192 + log.Printf("failed to fetch collaborators: %v", err) 212 193 return 213 194 } 214 - if !prefs.PullCreated { 215 - return 195 + for _, c := range collaborators { 196 + recipients = append(recipients, c.SubjectDid) 216 197 } 217 198 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 - } 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 227 207 228 - err = n.db.CreateNotification(ctx, notification) 229 - if err != nil { 230 - log.Printf("NewPull: failed to create notification: %v", err) 231 - return 232 - } 208 + n.notifyEvent( 209 + actorDid, 210 + recipients, 211 + eventType, 212 + entityType, 213 + entityId, 214 + repoId, 215 + issueId, 216 + pullId, 217 + ) 233 218 } 234 219 235 220 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)) 221 + pull, err := db.GetPull(n.db, 222 + syntax.ATURI(comment.RepoAt), 223 + comment.PullId, 224 + ) 239 225 if err != nil { 240 226 log.Printf("NewPullComment: failed to get pulls: %v", err) 241 227 return 242 228 } 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 229 249 230 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 231 if err != nil { ··· 252 233 return 253 234 } 254 235 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 - } 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)) 265 243 } 266 244 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 - } 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 287 253 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 - } 254 + n.notifyEvent( 255 + actorDid, 256 + recipients, 257 + eventType, 258 + entityType, 259 + entityId, 260 + repoId, 261 + issueId, 262 + pullId, 263 + ) 293 264 } 294 265 295 266 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 309 280 } 310 281 311 282 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))) 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())) 314 290 if err != nil { 315 - log.Printf("NewIssueClosed: failed to get repos: %v", err) 291 + log.Printf("failed to fetch collaborators: %v", err) 316 292 return 317 293 } 318 - 319 - // Don't notify yourself 320 - if repo.Did == issue.Did { 321 - return 294 + for _, c := range collaborators { 295 + recipients = append(recipients, c.SubjectDid) 322 296 } 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 297 + for _, p := range issue.Participants() { 298 + recipients = append(recipients, syntax.DID(p)) 332 299 } 333 300 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 - } 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 343 308 344 - err = n.db.CreateNotification(ctx, notification) 345 - if err != nil { 346 - log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 - return 348 - } 309 + n.notifyEvent( 310 + actorDid, 311 + recipients, 312 + eventType, 313 + entityType, 314 + entityId, 315 + repoId, 316 + issueId, 317 + pullId, 318 + ) 349 319 } 350 320 351 321 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { ··· 356 326 return 357 327 } 358 328 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) 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())) 366 335 if err != nil { 367 - log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 336 + log.Printf("failed to fetch collaborators: %v", err) 368 337 return 369 338 } 370 - if !prefs.PullMerged { 371 - return 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)) 372 344 } 373 345 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 - } 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 383 354 384 - err = n.db.CreateNotification(ctx, notification) 385 - if err != nil { 386 - log.Printf("NewPullMerged: failed to create notification: %v", err) 387 - return 388 - } 355 + n.notifyEvent( 356 + actorDid, 357 + recipients, 358 + eventType, 359 + entityType, 360 + entityId, 361 + repoId, 362 + issueId, 363 + pullId, 364 + ) 389 365 } 390 366 391 367 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 368 // Get repo details 393 369 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 370 if err != nil { 395 - log.Printf("NewPullClosed: failed to get repos: %v", err) 371 + log.Printf("NewPullMerged: failed to get repos: %v", err) 396 372 return 397 373 } 398 374 399 - // Don't notify yourself 400 - if repo.Did == pull.OwnerDid { 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) 401 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)) 402 390 } 403 391 404 - // Check if user wants these notifications - reuse pull_merged preference for now 405 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 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 + ) 406 435 if err != nil { 407 - log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 436 + // failed to get prefs for users 408 437 return 409 438 } 410 - if !prefs.PullMerged { 439 + 440 + // create a transaction for bulk notification storage 441 + tx, err := n.db.Begin() 442 + if err != nil { 443 + // failed to start tx 411 444 return 412 445 } 446 + defer tx.Rollback() 413 447 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 }(), 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 + } 422 475 } 423 476 424 - err = n.db.CreateNotification(ctx, notification) 425 - if err != nil { 426 - log.Printf("NewPullClosed: failed to create notification: %v", err) 477 + if err := tx.Commit(); err != nil { 478 + // failed to commit 427 479 return 428 480 } 429 481 }
+42 -50
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "reflect" 6 + "sync" 5 7 6 8 "tangled.org/core/appview/models" 7 9 ) ··· 16 18 17 19 var _ Notifier = &mergedNotifier{} 18 20 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 21 + // fanout calls the same method on all notifiers concurrently 22 + func (m *mergedNotifier) fanout(method string, args ...any) { 23 + var wg sync.WaitGroup 24 + for _, n := range m.notifiers { 25 + wg.Add(1) 26 + go func(notifier Notifier) { 27 + defer wg.Done() 28 + v := reflect.ValueOf(notifier).MethodByName(method) 29 + in := make([]reflect.Value, len(args)) 30 + for i, arg := range args { 31 + in[i] = reflect.ValueOf(arg) 32 + } 33 + v.Call(in) 34 + }(n) 22 35 } 36 + wg.Wait() 37 + } 38 + 39 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 40 + m.fanout("NewRepo", ctx, repo) 23 41 } 24 42 25 43 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 44 + m.fanout("NewStar", ctx, star) 29 45 } 46 + 30 47 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 48 + m.fanout("DeleteStar", ctx, star) 34 49 } 35 50 36 51 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 52 + m.fanout("NewIssue", ctx, issue) 40 53 } 54 + 41 55 func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 - for _, notifier := range m.notifiers { 43 - notifier.NewIssueComment(ctx, comment) 44 - } 56 + m.fanout("NewIssueComment", ctx, comment) 45 57 } 46 58 47 59 func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 - for _, notifier := range m.notifiers { 49 - notifier.NewIssueClosed(ctx, issue) 50 - } 60 + m.fanout("NewIssueClosed", ctx, issue) 51 61 } 52 62 53 63 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewFollow(ctx, follow) 56 - } 64 + m.fanout("NewFollow", ctx, follow) 57 65 } 66 + 58 67 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 59 - for _, notifier := range m.notifiers { 60 - notifier.DeleteFollow(ctx, follow) 61 - } 68 + m.fanout("DeleteFollow", ctx, follow) 62 69 } 63 70 64 71 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 65 - for _, notifier := range m.notifiers { 66 - notifier.NewPull(ctx, pull) 67 - } 72 + m.fanout("NewPull", ctx, pull) 68 73 } 74 + 69 75 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 - for _, notifier := range m.notifiers { 71 - notifier.NewPullComment(ctx, comment) 72 - } 76 + m.fanout("NewPullComment", ctx, comment) 73 77 } 74 78 75 79 func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 - for _, notifier := range m.notifiers { 77 - notifier.NewPullMerged(ctx, pull) 78 - } 80 + m.fanout("NewPullMerged", ctx, pull) 79 81 } 80 82 81 83 func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 - for _, notifier := range m.notifiers { 83 - notifier.NewPullClosed(ctx, pull) 84 - } 84 + m.fanout("NewPullClosed", ctx, pull) 85 85 } 86 86 87 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 - for _, notifier := range m.notifiers { 89 - notifier.UpdateProfile(ctx, profile) 90 - } 88 + m.fanout("UpdateProfile", ctx, profile) 91 89 } 92 90 93 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 - for _, notifier := range m.notifiers { 95 - notifier.NewString(ctx, string) 96 - } 91 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 92 + m.fanout("NewString", ctx, s) 97 93 } 98 94 99 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 - for _, notifier := range m.notifiers { 101 - notifier.EditString(ctx, string) 102 - } 95 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 96 + m.fanout("EditString", ctx, s) 103 97 } 104 98 105 99 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 - for _, notifier := range m.notifiers { 107 - notifier.DeleteString(ctx, did, rkey) 108 - } 100 + m.fanout("DeleteString", ctx, did, rkey) 109 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 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/consts" 28 - "tangled.org/core/idresolver" 29 - "tangled.org/core/rbac" 30 - "tangled.org/core/tid" 31 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
+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 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 + "log/slog" 6 7 "net/http" 7 - "net/url" 8 8 "time" 9 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 15 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 16 + "github.com/lestrrat-go/jwx/v2/jwk" 17 + "github.com/posthog/posthog-go" 13 18 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 18 22 ) 19 23 20 24 type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + Posthog posthog.Client 30 + Db *db.DB 31 + Enforcer *rbac.Enforcer 32 + IdResolver *idresolver.Resolver 33 + Logger *slog.Logger 24 34 } 25 35 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 36 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 37 + 38 + var oauthConfig oauth.ClientConfig 39 + var clientUri string 40 + 41 + if config.Core.Dev { 42 + clientUri = "http://127.0.0.1:3000" 43 + callbackUri := clientUri + "/oauth/callback" 44 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 45 + } else { 46 + clientUri = config.Core.AppviewHost 47 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 48 + callbackUri := clientUri + "/oauth/callback" 49 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 31 50 } 32 - } 51 + 52 + jwksUri := clientUri + "/oauth/jwks.json" 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() 33 63 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 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 36 75 } 37 76 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 77 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 78 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 79 + userSession, err := o.SessStore.Get(r, SessionName) 41 80 if err != nil { 42 81 return err 43 82 } 44 83 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 84 + userSession.Values[SessionDid] = sessData.AccountDID.String() 85 + userSession.Values[SessionPds] = sessData.HostURL 86 + userSession.Values[SessionId] = sessData.SessionID 48 87 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 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) 50 93 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 94 + return nil, fmt.Errorf("error getting user session: %w", err) 52 95 } 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), 96 + if userSession.IsNew { 97 + return nil, fmt.Errorf("no session available for user") 65 98 } 66 99 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) 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) 74 104 } 75 105 76 - did := userSession.Values[SessionDid].(string) 106 + sessId := userSession.Values[SessionId].(string) 77 107 78 - err = o.sess.DeleteSession(r.Context(), did) 108 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 109 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 110 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 111 } 82 112 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 113 + return clientSess, nil 86 114 } 87 115 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) 116 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 117 + userSession, err := o.SessStore.Get(r, SessionName) 98 118 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 119 + return fmt.Errorf("error getting user session: %w", err) 120 + } 121 + if userSession.IsNew { 122 + return fmt.Errorf("no session available for user") 100 123 } 101 124 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 125 + d := userSession.Values[SessionDid].(string) 126 + sessDid, err := syntax.ParseDID(d) 103 127 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 128 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 129 } 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 130 112 - self := o.ClientMetadata() 131 + sessId := userSession.Values[SessionId].(string) 113 132 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 133 + // delete the session 134 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 135 120 - if err != nil { 121 - return nil, false, err 122 - } 136 + // remove the cookie 137 + userSession.Options.MaxAge = -1 138 + err2 := o.SessStore.Save(r, w, userSession) 123 139 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 140 + return errors.Join(err1, err2) 141 + } 128 142 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 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 140 151 } 141 - 142 - return session, auth, nil 152 + return pubKey, nil 143 153 } 144 154 145 155 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 156 + Did string 157 + Pds string 149 158 } 150 159 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 160 + func (o *OAuth) GetUser(r *http.Request) *User { 161 + sess, err := o.SessStore.Get(r, SessionName) 153 162 154 - if err != nil || clientSession.IsNew { 163 + if err != nil || sess.IsNew { 155 164 return nil 156 165 } 157 166 158 167 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 168 + Did: sess.Values[SessionDid].(string), 169 + Pds: sess.Values[SessionPds].(string), 162 170 } 163 171 } 164 172 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 "" 173 + func (o *OAuth) GetDid(r *http.Request) string { 174 + if u := o.GetUser(r); u != nil { 175 + return u.Did 170 176 } 171 177 172 - return clientSession.Values[SessionDid].(string) 178 + return "" 173 179 } 174 180 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 181 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 182 + session, err := o.ResumeSession(r) 177 183 if err != nil { 178 184 return nil, fmt.Errorf("error getting session: %w", err) 179 185 } 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 186 + return session.APIClient(), nil 208 187 } 209 188 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 189 // this is a higher level abstraction on ServerGetServiceAuth 213 190 type ServiceClientOpts struct { 214 191 service string ··· 259 236 return scheme + s.service 260 237 } 261 238 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 239 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 240 opts := ServiceClientOpts{} 264 241 for _, o := range os { 265 242 o(&opts) 266 243 } 267 244 268 - authorizedClient, err := o.AuthorizedClient(r) 245 + client, err := o.AuthorizedClient(r) 269 246 if err != nil { 270 247 return nil, err 271 248 } ··· 276 253 opts.exp = sixty 277 254 } 278 255 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 256 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 257 if err != nil { 281 258 return nil, err 282 259 } 283 260 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 261 + return &xrpc.Client{ 262 + Auth: &xrpc.AuthInfo{ 286 263 AccessJwt: resp.Token, 287 264 }, 288 265 Host: opts.Host(), ··· 291 268 }, 292 269 }, nil 293 270 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+535
appview/ogcard/card.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 630 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + 398 + body := resp.Body 399 + bodyBytes, err := io.ReadAll(body) 400 + if err != nil { 401 + log.Printf("error when fetching external image from %s: %v", url, err) 402 + return nil, false 403 + } 404 + 405 + // Handle SVG separately 406 + if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 407 + return c.convertSVGToPNG(bodyBytes) 408 + } 409 + 410 + // Support content types are in-sync with the allowed custom avatar file types 411 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 412 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 413 + return nil, false 414 + } 415 + 416 + bodyBuffer := bytes.NewReader(bodyBytes) 417 + _, imgType, err := image.DecodeConfig(bodyBuffer) 418 + if err != nil { 419 + log.Printf("error when decoding external image from %s: %v", url, err) 420 + return nil, false 421 + } 422 + 423 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 424 + if (contentType == "image/png" && imgType != "png") || 425 + (contentType == "image/jpeg" && imgType != "jpeg") || 426 + (contentType == "image/gif" && imgType != "gif") || 427 + (contentType == "image/webp" && imgType != "webp") { 428 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 429 + return nil, false 430 + } 431 + 432 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 433 + if err != nil { 434 + log.Printf("error w/ bodyBuffer.Seek") 435 + return nil, false 436 + } 437 + img, _, err := image.Decode(bodyBuffer) 438 + if err != nil { 439 + log.Printf("error when decoding external image from %s: %v", url, err) 440 + return nil, false 441 + } 442 + 443 + return img, true 444 + } 445 + 446 + // convertSVGToPNG converts SVG data to a PNG image 447 + func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 448 + // Parse the SVG 449 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 450 + if err != nil { 451 + log.Printf("error parsing SVG: %v", err) 452 + return nil, false 453 + } 454 + 455 + // Set a reasonable size for the rasterized image 456 + width := 256 457 + height := 256 458 + icon.SetTarget(0, 0, float64(width), float64(height)) 459 + 460 + // Create an image to draw on 461 + rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 462 + 463 + // Fill with white background 464 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 465 + 466 + // Create a scanner and rasterize the SVG 467 + scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 468 + raster := rasterx.NewDasher(width, height, scanner) 469 + 470 + icon.Draw(raster, 1.0) 471 + 472 + return rgba, true 473 + } 474 + 475 + func (c *Card) DrawExternalImage(url string) { 476 + image, ok := c.fetchExternalImage(url) 477 + if !ok { 478 + image = fallbackImage() 479 + } 480 + c.DrawImage(image) 481 + } 482 + 483 + // DrawCircularExternalImage draws an external image as a circle at the specified position 484 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 485 + img, ok := c.fetchExternalImage(url) 486 + if !ok { 487 + img = fallbackImage() 488 + } 489 + 490 + // Create a circular mask 491 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 492 + center := size / 2 493 + radius := float64(size / 2) 494 + 495 + // Scale the source image to fit the circle 496 + srcBounds := img.Bounds() 497 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 498 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 499 + 500 + // Draw the image with circular clipping 501 + for cy := 0; cy < size; cy++ { 502 + for cx := 0; cx < size; cx++ { 503 + // Calculate distance from center 504 + dx := float64(cx - center) 505 + dy := float64(cy - center) 506 + distance := math.Sqrt(dx*dx + dy*dy) 507 + 508 + // Only draw pixels within the circle 509 + if distance <= radius { 510 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 511 + } 512 + } 513 + } 514 + 515 + // Draw the circle onto the card 516 + bounds := c.Img.Bounds() 517 + destRect := image.Rect(x, y, x+size, y+size) 518 + 519 + // Make sure we don't draw outside the card bounds 520 + if destRect.Max.X > bounds.Max.X { 521 + destRect.Max.X = bounds.Max.X 522 + } 523 + if destRect.Max.Y > bounds.Max.Y { 524 + destRect.Max.Y = bounds.Max.Y 525 + } 526 + 527 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 528 + 529 + return nil 530 + } 531 + 532 + // DrawRect draws a rect with the given color 533 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 534 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 535 + }
+10 -9
appview/pages/funcmap.go
··· 265 265 return nil 266 266 }, 267 267 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 268 + data, err := p.icon(name, classes) 269 269 if err != nil { 270 270 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 271 + data, _ = p.icon("airplay", classes) 272 272 } 273 273 return template.HTML(data) 274 274 }, 275 - "cssContentHash": CssContentHash, 275 + "cssContentHash": p.CssContentHash, 276 276 "fileTree": filetree.FileTree, 277 277 "pathEscape": func(s string) string { 278 278 return url.PathEscape(s) ··· 283 283 }, 284 284 285 285 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 286 + return p.AvatarUrl(handle, "tiny") 287 287 }, 288 288 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 289 + return p.AvatarUrl(handle, "") 290 290 }, 291 291 "langColor": enry.GetColor, 292 292 "layoutSide": func() string { ··· 297 297 }, 298 298 299 299 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 300 + normalized := strings.ReplaceAll(s, ":", "_") 301 + normalized = strings.ReplaceAll(normalized, ".", "_") 302 + return normalized 302 303 }, 303 304 "sshFingerprint": func(pubKey string) string { 304 305 fp, err := crypto.SSHFingerprint(pubKey) ··· 310 311 } 311 312 } 312 313 313 - func (p *Pages) avatarUri(handle, size string) string { 314 + func (p *Pages) AvatarUrl(handle, size string) string { 314 315 handle = strings.TrimPrefix(handle, "@") 315 316 316 317 secret := p.avatar.SharedSecret ··· 325 326 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 327 } 327 328 328 - func icon(name string, classes []string) (template.HTML, error) { 329 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 329 330 iconPath := filepath.Join("static", "icons", name) 330 331 331 332 if filepath.Ext(name) == "" {
+5 -2
appview/pages/funcmap_test.go
··· 2 2 3 3 import ( 4 4 "html/template" 5 + "log/slog" 6 + "testing" 7 + 5 8 "tangled.org/core/appview/config" 6 9 "tangled.org/core/idresolver" 7 - "testing" 8 10 ) 9 11 10 12 func TestPages_funcMap(t *testing.T) { ··· 13 15 // Named input parameters for receiver constructor. 14 16 config *config.Config 15 17 res *idresolver.Resolver 18 + l *slog.Logger 16 19 want template.FuncMap 17 20 }{ 18 21 // TODO: Add test cases. 19 22 } 20 23 for _, tt := range tests { 21 24 t.Run(tt.name, func(t *testing.T) { 22 - p := NewPages(tt.config, tt.res) 25 + p := NewPages(tt.config, tt.res, tt.l) 23 26 got := p.funcMap() 24 27 // TODO: update the condition below to compare got with tt.want. 25 28 if true {
+6 -1
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 + "io/fs" 8 9 "net/url" 9 10 "path" 10 11 "strings" ··· 20 21 "github.com/yuin/goldmark/renderer/html" 21 22 "github.com/yuin/goldmark/text" 22 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 23 25 htmlparse "golang.org/x/net/html" 24 26 25 27 "tangled.org/core/api/tangled" ··· 45 47 IsDev bool 46 48 RendererType RendererType 47 49 Sanitizer Sanitizer 50 + Files fs.FS 48 51 } 49 52 50 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 66 ), 64 67 treeblood.MathML(), 68 + callout.CalloutExtention, 65 69 ), 66 70 goldmark.WithParserOptions( 67 71 parser.WithAutoHeadingID(), ··· 140 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 145 switch node.Type { 142 146 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 147 + switch node.Data { 148 + case "img", "source": 144 149 for i, attr := range node.Attr { 145 150 if attr.Key != "src" { 146 151 continue
+3
appview/pages/markup/sanitizer.go
··· 114 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 117 120 return policy 118 121 } 119 122
+42 -23
appview/pages/pages.go
··· 54 54 logger *slog.Logger 55 55 } 56 56 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 57 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 58 // initialized with safe defaults, can be overriden per use 59 59 rctx := &markup.RenderContext{ 60 60 IsDev: config.Core.Dev, 61 61 CamoUrl: config.Camo.Host, 62 62 CamoSecret: config.Camo.SharedSecret, 63 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 64 65 } 65 66 66 67 p := &Pages{ ··· 71 72 rctx: rctx, 72 73 resolver: res, 73 74 templateDir: "appview/pages", 74 - logger: slog.Default().With("component", "pages"), 75 + logger: logger, 75 76 } 76 77 77 78 if p.dev { ··· 220 221 221 222 type LoginParams struct { 222 223 ReturnUrl string 224 + ErrorCode string 223 225 } 224 226 225 227 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 306 308 LoggedInUser *oauth.User 307 309 Timeline []models.TimelineEvent 308 310 Repos []models.Repo 311 + GfiLabel *models.LabelDefinition 309 312 } 310 313 311 314 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 312 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) 313 329 } 314 330 315 331 type UserProfileSettingsParams struct { ··· 971 987 LabelDefs map[string]*models.LabelDefinition 972 988 973 989 OrderedReactionKinds []models.ReactionKind 974 - Reactions map[models.ReactionKind]int 990 + Reactions map[models.ReactionKind]models.ReactionDisplayData 975 991 UserReacted map[models.ReactionKind]bool 976 992 } 977 993 ··· 996 1012 ThreadAt syntax.ATURI 997 1013 Kind models.ReactionKind 998 1014 Count int 1015 + Users []string 999 1016 IsReacted bool 1000 1017 } 1001 1018 ··· 1113 1130 } 1114 1131 1115 1132 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 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 1125 1143 1126 1144 OrderedReactionKinds []models.ReactionKind 1127 - Reactions map[models.ReactionKind]int 1145 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1128 1146 UserReacted map[models.ReactionKind]bool 1129 1147 1130 1148 LabelDefs map[string]*models.LabelDefinition ··· 1217 1235 } 1218 1236 1219 1237 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 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 1227 1246 } 1228 1247 1229 1248 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1460 1479 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1461 1480 } 1462 1481 1463 - sub, err := fs.Sub(Files, "static") 1482 + sub, err := fs.Sub(p.embedFS, "static") 1464 1483 if err != nil { 1465 1484 p.logger.Error("no static dir found? that's crazy", "err", err) 1466 1485 panic(err) ··· 1483 1502 }) 1484 1503 } 1485 1504 1486 - func CssContentHash() string { 1487 - cssFile, err := Files.Open("static/tw.css") 1505 + func (p *Pages) CssContentHash() string { 1506 + cssFile, err := p.embedFS.Open("static/tw.css") 1488 1507 if err != nil { 1489 1508 slog.Debug("Error opening CSS file", "err", err) 1490 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 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 26 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 64 {{ end }}
+87 -34
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 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> 10 33 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> 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 19 46 </div> 20 47 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 27 51 </div> 52 + </div> 28 53 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> 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> 34 64 </div> 35 65 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> 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> 40 93 </div> 41 - </div> 42 94 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> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 45 98 </div> 46 99 </div> 47 100 </div>
+2 -2
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 51 51 <summary 52 52 class="cursor-pointer list-none flex items-center gap-1" 53 53 > 54 - {{ $user := didOrHandle .Did .Handle }} 54 + {{ $user := .Did }} 55 55 <img 56 56 src="{{ tinyAvatar $user }}" 57 57 alt=""
+9
appview/pages/templates/layouts/profilebase.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 + {{ $avatarUrl := fullAvatar .Card.UserHandle }} 4 5 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 6 <meta property="og:type" content="profile" /> 6 7 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 8 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 9 + <meta property="og:image" content="{{ $avatarUrl }}" /> 10 + <meta property="og:image:width" content="512" /> 11 + <meta property="og:image:height" content="512" /> 12 + 13 + <meta name="twitter:card" content="summary" /> 14 + <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 + <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 16 + <meta name="twitter:image" content="{{ $avatarUrl }}" /> 8 17 {{ end }} 9 18 10 19 {{ define "content" }}
+5 -2
appview/pages/templates/notifications/fragments/item.html
··· 8 8 "> 9 9 {{ template "notificationIcon" . }} 10 10 <div class="flex-1 w-full flex flex-col gap-1"> 11 - <span>{{ template "notificationHeader" . }}</span> 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> 12 15 <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 16 </div> 14 17 ··· 19 22 {{ define "notificationIcon" }} 20 23 <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 24 <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"> 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"> 23 26 {{ i .Icon "size-3 text-black dark:text-white" }} 24 27 </div> 25 28 </div>
+3 -3
appview/pages/templates/repo/commit.html
··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 83 + <header class="col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 90 {{ block "contentLayout" . }} 91 91 {{ block "content" . }}{{ end }} 92 92 {{ end }} ··· 105 105 {{ end }} 106 106 107 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 108 + <footer class="col-span-full mt-12"> 109 109 {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+9 -1
appview/pages/templates/repo/fragments/og.html
··· 2 2 {{ $title := or .Title .RepoInfo.FullName }} 3 3 {{ $description := or .Description .RepoInfo.Description }} 4 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 6 7 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 8 <meta property="og:type" content="object" /> 9 9 <meta property="og:url" content="{{ $url }}" /> 10 10 <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 11 19 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/participants.html
··· 1 1 {{ define "repo/fragments/participants" }} 2 2 {{ $all := . }} 3 3 {{ $ps := take $all 5 }} 4 - <div class="px-6 md:px-0"> 4 + <div class="px-2 md:px-0"> 5 5 <div class="py-1 flex items-center text-sm"> 6 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 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 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 34 34 35 35 {{ define "editIssueComment" }} 36 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 39 hx-swap="outerHTML" 40 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 44 45 45 {{ define "deleteIssueComment" }} 46 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 49 hx-confirm="Are you sure you want to delete your comment?" 50 50 hx-swap="outerHTML"
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+19
appview/pages/templates/repo/issues/fragments/og.html
··· 1 + {{ define "repo/issues/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 + {{ $description := or .Issue.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+7 -8
appview/pages/templates/repo/issues/issue.html
··· 2 2 3 3 4 4 {{ define "extrameta" }} 5 - {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 - {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 - 8 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 9 6 {{ end }} 10 7 11 8 {{ define "repoContentLayout" }} ··· 87 84 88 85 {{ define "editIssue" }} 89 86 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 87 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 91 88 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 89 hx-swap="innerHTML" 93 90 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 94 98 95 {{ define "deleteIssue" }} 99 96 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 97 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 101 98 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 99 hx-confirm="Are you sure you want to delete your issue?" 103 100 hx-swap="none"> ··· 110 107 <div class="flex items-center gap-2"> 111 108 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 109 {{ range $kind := .OrderedReactionKinds }} 110 + {{ $reactionData := index $.Reactions $kind }} 113 111 {{ 114 112 template "repo/fragments/reaction" 115 113 (dict 116 114 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 115 + "Count" $reactionData.Count 118 116 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 117 + "ThreadAt" $.Issue.AtUri 118 + "Users" $reactionData.Users) 120 119 }} 121 120 {{ end }} 122 121 </div>
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 42 </div> 93 43 {{ block "pagination" . }} {{ end }} 94 44 {{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
··· 1 + {{ define "repo/pulls/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }} 3 + {{ $description := or .Pull.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 33 33 <span>comment</span> 34 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 35 </button> 36 + {{ if .BranchDeleteStatus }} 37 + <button 38 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 + hx-swap="none" 41 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 42 + {{ i "git-branch" "w-4 h-4" }} 43 + <span>delete branch</span> 44 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 + </button> 46 + {{ end }} 36 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 48 {{ $disabled := "" }} 38 49 {{ if $isConflicted }}
+15 -11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 42 {{ if not .Pull.IsPatchBased }} 43 43 from 44 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - {{ if .Pull.IsForkBased }} 46 - {{ if .Pull.PullSource.Repo }} 47 - {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 - <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 - {{- else -}} 50 - <span class="italic">[deleted fork]</span> 51 - {{- end -}} 52 - {{- end -}} 53 - {{- .Pull.PullSource.Branch -}} 45 + {{ if not .Pull.IsForkBased }} 46 + {{ $repoPath := .RepoInfo.FullName }} 47 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 + {{ else if .Pull.PullSource.Repo }} 49 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 + <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 + {{ else }} 53 + <span class="italic">[deleted fork]</span>: 54 + {{ .Pull.PullSource.Branch }} 55 + {{ end }} 54 56 </span> 55 57 {{ end }} 56 58 </span> ··· 66 68 <div class="flex items-center gap-2 mt-2"> 67 69 {{ template "repo/fragments/reactionsPopUp" . }} 68 70 {{ range $kind := . }} 71 + {{ $reactionData := index $.Reactions $kind }} 69 72 {{ 70 73 template "repo/fragments/reaction" 71 74 (dict 72 75 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 76 + "Count" $reactionData.Count 74 77 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 78 + "ThreadAt" $.Pull.PullAt 79 + "Users" $reactionData.Users) 76 80 }} 77 81 {{ end }} 78 82 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 28 29 29 {{ end }} 30 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 31 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 33 {{ block "contentLayout" . }} 40 34 {{ block "content" . }}{{ end }} 41 35 {{ end }} ··· 52 46 {{ end }} 53 47 </div> 54 48 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 49 63 50 {{ define "contentAfter" }} 64 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
appview/pages/templates/repo/pulls/patch.html
··· 34 34 </section> 35 35 {{ end }} 36 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/fragments/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 37 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 45 39 {{ block "contentLayout" . }} 46 40 {{ block "content" . }}{{ end }} 47 41 {{ end }} ··· 57 51 </div> 58 52 {{ end }} 59 53 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 54 {{ end }} 67 55 68 56 {{ define "contentAfter" }}
+18 -8
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "extrameta" }} 6 - {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 10 7 {{ end }} 11 8 12 9 {{ define "repoContentLayout" }} ··· 187 184 {{ end }} 188 185 189 186 {{ if $.LoggedInUser }} 190 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 187 + {{ template "repo/pulls/fragments/pullActions" 188 + (dict 189 + "LoggedInUser" $.LoggedInUser 190 + "Pull" $.Pull 191 + "RepoInfo" $.RepoInfo 192 + "RoundNumber" .RoundNumber 193 + "MergeCheck" $.MergeCheck 194 + "ResubmitCheck" $.ResubmitCheck 195 + "BranchDeleteStatus" $.BranchDeleteStatus 196 + "Stack" $.Stack) }} 191 197 {{ 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 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 195 205 </div> 196 206 {{ end }} 197 207 </div>
+2
appview/pages/templates/repo/settings/access.html
··· 83 83 </label> 84 84 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 85 <input 86 + autocapitalize="none" 87 + autocorrect="off" 86 88 type="text" 87 89 id="add-collaborator" 88 90 name="collaborator"
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 30 30 </label> 31 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 32 <input 33 + autocapitalize="none" 34 + autocorrect="off" 33 35 type="text" 34 36 id="member-did-{{ .Id }}" 35 37 name="member"
+1 -1
appview/pages/templates/strings/string.html
··· 47 47 </span> 48 48 </section> 49 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 - <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 50 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 51 <span> 52 52 {{ .String.Filename }} 53 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+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 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 3 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+24 -2
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> 14 15 <body class="flex items-center justify-center min-h-screen"> 15 - <main class="max-w-md px-6 -mt-4"> 16 + <main class="max-w-md px-7 mt-4"> 16 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 17 18 {{ template "fragments/logotype" }} 18 19 </h1> ··· 20 21 tightly-knit social coding. 21 22 </h2> 22 23 <form 23 - class="mt-4 max-w-sm mx-auto" 24 + class="mt-4" 24 25 hx-post="/login" 25 26 hx-swap="none" 26 27 hx-disabled-elt="#login-button" ··· 28 29 <div class="flex flex-col"> 29 30 <label for="handle">handle</label> 30 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 31 35 type="text" 32 36 id="handle" 33 37 name="handle" ··· 52 56 <span>login</span> 53 57 </button> 54 58 </form> 59 + {{ if .ErrorCode }} 60 + <div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300"> 61 + <span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span> 62 + <div> 63 + <h5 class="font-medium">Login error</h5> 64 + <p class="text-sm"> 65 + {{ if eq .ErrorCode "access_denied" }} 66 + You have not authorized the app. 67 + {{ else if eq .ErrorCode "session" }} 68 + Server failed to create user session. 69 + {{ else }} 70 + Internal Server error. 71 + {{ end }} 72 + Please try again. 73 + </p> 74 + </div> 75 + </div> 76 + {{ end }} 55 77 <p class="text-sm text-gray-500"> 56 78 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 57 79 </p>
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 13 14
+23
appview/pagination/page.go
··· 1 1 package pagination 2 2 3 + import "context" 4 + 3 5 type Page struct { 4 6 Offset int // where to start from 5 7 Limit int // number of items in a page ··· 10 12 Offset: 0, 11 13 Limit: 30, 12 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 13 36 } 14 37 15 38 func (p Page) Previous() Page {
+3 -4
appview/pipelines/pipelines.go
··· 16 16 "tangled.org/core/appview/reporesolver" 17 17 "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/idresolver" 19 - "tangled.org/core/log" 20 19 "tangled.org/core/rbac" 21 20 spindlemodel "tangled.org/core/spindle/models" 22 21 ··· 45 44 db *db.DB, 46 45 config *config.Config, 47 46 enforcer *rbac.Enforcer, 47 + logger *slog.Logger, 48 48 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 - return &Pipelines{oauth: oauth, 49 + return &Pipelines{ 50 + oauth: oauth, 52 51 repoResolver: repoResolver, 53 52 pages: pages, 54 53 idResolver: idResolver,
+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 6 "errors" 7 7 "fmt" 8 8 "log" 9 + "log/slog" 9 10 "net/http" 11 + "slices" 10 12 "sort" 11 13 "strconv" 12 14 "strings" ··· 21 23 "tangled.org/core/appview/pages" 22 24 "tangled.org/core/appview/pages/markup" 23 25 "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/validator" 24 27 "tangled.org/core/appview/xrpcclient" 25 28 "tangled.org/core/idresolver" 26 29 "tangled.org/core/patchutil" 30 + "tangled.org/core/rbac" 27 31 "tangled.org/core/tid" 28 32 "tangled.org/core/types" 29 33 30 - "github.com/bluekeyes/go-gitdiff/gitdiff" 31 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 35 lexutil "github.com/bluesky-social/indigo/lex/util" 33 36 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 43 46 db *db.DB 44 47 config *config.Config 45 48 notifier notify.Notifier 49 + enforcer *rbac.Enforcer 50 + logger *slog.Logger 51 + validator *validator.Validator 46 52 } 47 53 48 54 func New( ··· 53 59 db *db.DB, 54 60 config *config.Config, 55 61 notifier notify.Notifier, 62 + enforcer *rbac.Enforcer, 63 + validator *validator.Validator, 64 + logger *slog.Logger, 56 65 ) *Pulls { 57 66 return &Pulls{ 58 67 oauth: oauth, ··· 62 71 db: db, 63 72 config: config, 64 73 notifier: notifier, 74 + enforcer: enforcer, 75 + logger: logger, 76 + validator: validator, 65 77 } 66 78 } 67 79 ··· 98 110 } 99 111 100 112 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 113 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 101 114 resubmitResult := pages.Unknown 102 115 if user.Did == pull.OwnerDid { 103 116 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 117 } 105 118 106 119 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 107 - LoggedInUser: user, 108 - RepoInfo: f.RepoInfo(user), 109 - Pull: pull, 110 - RoundNumber: roundNumber, 111 - MergeCheck: mergeCheckResponse, 112 - ResubmitCheck: resubmitResult, 113 - Stack: stack, 120 + LoggedInUser: user, 121 + RepoInfo: f.RepoInfo(user), 122 + Pull: pull, 123 + RoundNumber: roundNumber, 124 + MergeCheck: mergeCheckResponse, 125 + ResubmitCheck: resubmitResult, 126 + BranchDeleteStatus: branchDeleteStatus, 127 + Stack: stack, 114 128 }) 115 129 return 116 130 } ··· 135 149 stack, _ := r.Context().Value("stack").(models.Stack) 136 150 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 151 138 - totalIdents := 1 139 - for _, submission := range pull.Submissions { 140 - totalIdents += len(submission.Comments) 141 - } 142 - 143 - identsToResolve := make([]string, totalIdents) 144 - 145 - // populate idents 146 - identsToResolve[0] = pull.OwnerDid 147 - idx := 1 148 - for _, submission := range pull.Submissions { 149 - for _, comment := range submission.Comments { 150 - identsToResolve[idx] = comment.OwnerDid 151 - idx += 1 152 - } 153 - } 154 - 155 152 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 153 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 156 154 resubmitResult := pages.Unknown 157 155 if user != nil && user.Did == pull.OwnerDid { 158 156 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 189 187 m[p.Sha] = p 190 188 } 191 189 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 190 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 193 191 if err != nil { 194 192 log.Println("failed to get pull reactions") 195 193 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 217 215 } 218 216 219 217 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, 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, 228 227 229 228 OrderedReactionKinds: models.OrderedReactionKinds, 230 - Reactions: reactionCountMap, 229 + Reactions: reactionMap, 231 230 UserReacted: userReactions, 232 231 233 232 LabelDefs: defs, ··· 301 300 return result 302 301 } 303 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 + 304 358 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 305 359 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 306 360 return pages.Unknown ··· 348 402 349 403 targetBranch := branchResp 350 404 351 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 405 + latestSourceRev := pull.LatestSha() 352 406 353 407 if pull.IsStacked() && stack != nil { 354 408 top := stack[0] 355 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 409 + latestSourceRev = top.LatestSha() 356 410 } 357 411 358 412 if latestSourceRev != targetBranch.Hash { ··· 392 446 return 393 447 } 394 448 395 - patch := pull.Submissions[roundIdInt].Patch 449 + patch := pull.Submissions[roundIdInt].CombinedPatch() 396 450 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 397 451 398 452 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 443 497 return 444 498 } 445 499 446 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 500 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 447 501 if err != nil { 448 502 log.Println("failed to interdiff; current patch malformed") 449 503 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 450 504 return 451 505 } 452 506 453 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 507 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 454 508 if err != nil { 455 509 log.Println("failed to interdiff; previous patch malformed") 456 510 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 652 706 653 707 createdAt := time.Now().Format(time.RFC3339) 654 708 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 709 client, err := s.oauth.AuthorizedClient(r) 663 710 if err != nil { 664 711 log.Println("failed to get authorized client", err) 665 712 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 666 713 return 667 714 } 668 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 715 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 669 716 Collection: tangled.RepoPullCommentNSID, 670 717 Repo: user.Did, 671 718 Rkey: tid.TID(), 672 719 Record: &lexutil.LexiconTypeDecoder{ 673 720 Val: &tangled.RepoPullComment{ 674 - Pull: string(pullAt), 721 + Pull: pull.PullAt().String(), 675 722 Body: body, 676 723 CreatedAt: createdAt, 677 724 }, ··· 919 966 } 920 967 921 968 sourceRev := comparison.Rev2 922 - patch := comparison.Patch 969 + patch := comparison.FormatPatchRaw 970 + combined := comparison.CombinedPatchRaw 923 971 924 - if !patchutil.IsPatchValid(patch) { 972 + if err := s.validator.ValidatePatch(&patch); err != nil { 973 + s.logger.Error("failed to validate patch", "err", err) 925 974 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 926 975 return 927 976 } ··· 934 983 Sha: comparison.Rev2, 935 984 } 936 985 937 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 986 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 938 987 } 939 988 940 989 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) { 990 + if err := s.validator.ValidatePatch(&patch); err != nil { 991 + s.logger.Error("patch validation failed", "err", err) 942 992 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 943 993 return 944 994 } 945 995 946 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 996 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 947 997 } 948 998 949 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) { ··· 1026 1076 } 1027 1077 1028 1078 sourceRev := comparison.Rev2 1029 - patch := comparison.Patch 1079 + patch := comparison.FormatPatchRaw 1080 + combined := comparison.CombinedPatchRaw 1030 1081 1031 - if !patchutil.IsPatchValid(patch) { 1082 + if err := s.validator.ValidatePatch(&patch); err != nil { 1083 + s.logger.Error("failed to validate patch", "err", err) 1032 1084 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1033 1085 return 1034 1086 } ··· 1046 1098 Sha: sourceRev, 1047 1099 } 1048 1100 1049 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1101 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1050 1102 } 1051 1103 1052 1104 func (s *Pulls) createPullRequest( ··· 1056 1108 user *oauth.User, 1057 1109 title, body, targetBranch string, 1058 1110 patch string, 1111 + combined string, 1059 1112 sourceRev string, 1060 1113 pullSource *models.PullSource, 1061 1114 recordPullSource *tangled.RepoPull_Source, ··· 1093 1146 1094 1147 // We've already checked earlier if it's diff-based and title is empty, 1095 1148 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1096 - if title == "" { 1149 + if title == "" || body == "" { 1097 1150 formatPatches, err := patchutil.ExtractPatches(patch) 1098 1151 if err != nil { 1099 1152 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1104 1157 return 1105 1158 } 1106 1159 1107 - title = formatPatches[0].Title 1108 - body = formatPatches[0].Body 1160 + if title == "" { 1161 + title = formatPatches[0].Title 1162 + } 1163 + if body == "" { 1164 + body = formatPatches[0].Body 1165 + } 1109 1166 } 1110 1167 1111 1168 rkey := tid.TID() 1112 1169 initialSubmission := models.PullSubmission{ 1113 1170 Patch: patch, 1171 + Combined: combined, 1114 1172 SourceRev: sourceRev, 1115 1173 } 1116 1174 pull := &models.Pull{ ··· 1138 1196 return 1139 1197 } 1140 1198 1141 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1142 1200 Collection: tangled.RepoPullNSID, 1143 1201 Repo: user.Did, 1144 1202 Rkey: rkey, ··· 1149 1207 Repo: string(f.RepoAt()), 1150 1208 Branch: targetBranch, 1151 1209 }, 1152 - Patch: patch, 1153 - Source: recordPullSource, 1210 + Patch: patch, 1211 + Source: recordPullSource, 1212 + CreatedAt: time.Now().Format(time.RFC3339), 1154 1213 }, 1155 1214 }, 1156 1215 }) ··· 1235 1294 } 1236 1295 writes = append(writes, &write) 1237 1296 } 1238 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1297 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1239 1298 Repo: user.Did, 1240 1299 Writes: writes, 1241 1300 }) ··· 1285 1344 return 1286 1345 } 1287 1346 1288 - if patch == "" || !patchutil.IsPatchValid(patch) { 1347 + if err := s.validator.ValidatePatch(&patch); err != nil { 1348 + s.logger.Error("faield to validate patch", "err", err) 1289 1349 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1290 1350 return 1291 1351 } ··· 1539 1599 1540 1600 patch := r.FormValue("patch") 1541 1601 1542 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1602 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1543 1603 } 1544 1604 1545 1605 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1600 1660 } 1601 1661 1602 1662 sourceRev := comparison.Rev2 1603 - patch := comparison.Patch 1663 + patch := comparison.FormatPatchRaw 1664 + combined := comparison.CombinedPatchRaw 1604 1665 1605 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1666 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1606 1667 } 1607 1668 1608 1669 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1634 1695 return 1635 1696 } 1636 1697 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 1698 // update the hidden tracking branch to latest 1664 1699 client, err := s.oauth.ServiceClient( 1665 1700 r, ··· 1691 1726 return 1692 1727 } 1693 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 + 1694 1756 // Use the fork comparison we already made 1695 1757 comparison := forkComparison 1696 1758 1697 1759 sourceRev := comparison.Rev2 1698 - patch := comparison.Patch 1760 + patch := comparison.FormatPatchRaw 1761 + combined := comparison.CombinedPatchRaw 1699 1762 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 1763 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1718 1764 } 1719 1765 1720 1766 func (s *Pulls) resubmitPullHelper( ··· 1724 1770 user *oauth.User, 1725 1771 pull *models.Pull, 1726 1772 patch string, 1773 + combined string, 1727 1774 sourceRev string, 1728 1775 ) { 1729 1776 if pull.IsStacked() { ··· 1732 1779 return 1733 1780 } 1734 1781 1735 - if err := validateResubmittedPatch(pull, patch); err != nil { 1782 + if err := s.validator.ValidatePatch(&patch); err != nil { 1736 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.") 1737 1789 return 1738 1790 } 1739 1791 1740 1792 // validate sourceRev if branch/fork based 1741 1793 if pull.IsBranchBased() || pull.IsForkBased() { 1742 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1794 + if sourceRev == pull.LatestSha() { 1743 1795 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1744 1796 return 1745 1797 } ··· 1753 1805 } 1754 1806 defer tx.Rollback() 1755 1807 1756 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 1808 + pullAt := pull.PullAt() 1809 + newRoundNumber := len(pull.Submissions) 1810 + newPatch := patch 1811 + newSourceRev := sourceRev 1812 + combinedPatch := combined 1813 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1757 1814 if err != nil { 1758 1815 log.Println("failed to create pull request", err) 1759 1816 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1766 1823 return 1767 1824 } 1768 1825 1769 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1826 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1770 1827 if err != nil { 1771 1828 // failed to get record 1772 1829 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1789 1846 } 1790 1847 } 1791 1848 1792 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1849 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1793 1850 Collection: tangled.RepoPullNSID, 1794 1851 Repo: user.Did, 1795 1852 Rkey: pull.Rkey, ··· 1801 1858 Repo: string(f.RepoAt()), 1802 1859 Branch: pull.TargetBranch, 1803 1860 }, 1804 - Patch: patch, // new patch 1805 - Source: recordPullSource, 1861 + Patch: patch, // new patch 1862 + Source: recordPullSource, 1863 + CreatedAt: time.Now().Format(time.RFC3339), 1806 1864 }, 1807 1865 }, 1808 1866 }) ··· 1853 1911 // commits that got deleted: corresponding pull is closed 1854 1912 // commits that got added: new pull is created 1855 1913 // 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 1914 additions := make(map[string]*models.Pull) 1859 1915 deletions := make(map[string]*models.Pull) 1860 - unchanged := make(map[string]struct{}) 1861 1916 updated := make(map[string]struct{}) 1862 1917 1863 1918 // pulls in orignal stack but not in new one ··· 1879 1934 for _, np := range newStack { 1880 1935 if op, ok := origById[np.ChangeId]; ok { 1881 1936 // 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 - } 1937 + updated[op.ChangeId] = struct{}{} 1902 1938 } 1903 1939 } 1904 1940 ··· 1965 2001 continue 1966 2002 } 1967 2003 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 - 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) 2012 2011 if err != nil { 2013 2012 log.Println("failed to update pull", err, op.PullId) 2014 2013 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2015 2014 return 2016 2015 } 2017 2016 2018 - record := op.AsRecord() 2019 - record.Patch = newSubmission.Patch 2017 + record := np.AsRecord() 2020 2018 2021 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2022 2020 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2061 2059 return 2062 2060 } 2063 2061 2064 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2062 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2065 2063 Repo: user.Did, 2066 2064 Writes: writes, 2067 2065 }) ··· 2357 2355 initialSubmission := models.PullSubmission{ 2358 2356 Patch: fp.Raw, 2359 2357 SourceRev: fp.SHA, 2358 + Combined: fp.Raw, 2360 2359 } 2361 2360 pull := models.Pull{ 2362 2361 Title: title,
+1
appview/pulls/router.go
··· 23 23 r.Route("/{pull}", func(r chi.Router) { 24 24 r.Use(mw.ResolvePull()) 25 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 26 27 27 28 r.Route("/round/{round}", func(r chi.Router) { 28 29 r.Get("/", s.RepoPullPatch)
+11 -10
appview/repo/artifact.go
··· 10 10 "net/url" 11 11 "time" 12 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview/db" 22 15 "tangled.org/core/appview/models" ··· 25 18 "tangled.org/core/appview/xrpcclient" 26 19 "tangled.org/core/tid" 27 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 28 29 ) 29 30 30 31 // TODO: proper statuses here on early exit ··· 60 61 return 61 62 } 62 63 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 64 65 if err != nil { 65 66 log.Println("failed to upload blob", err) 66 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 73 rkey := tid.TID() 73 74 createdAt := time.Now() 74 75 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 76 77 Collection: tangled.RepoArtifactNSID, 77 78 Repo: user.Did, 78 79 Rkey: rkey, ··· 249 250 return 250 251 } 251 252 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 253 254 Collection: tangled.RepoArtifactNSID, 254 255 Repo: user.Did, 255 256 Rkey: artifact.Rkey,
+26 -12
appview/repo/index.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 6 + "log/slog" 7 7 "net/http" 8 8 "net/url" 9 9 "slices" ··· 31 31 ) 32 32 33 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 34 36 ref := chi.URLParam(r, "ref") 35 37 ref, _ = url.PathUnescape(ref) 36 38 37 39 f, err := rp.repoResolver.Resolve(r) 38 40 if err != nil { 39 - log.Println("failed to fully resolve repo", err) 41 + l.Error("failed to fully resolve repo", "err", err) 40 42 return 41 43 } 42 44 ··· 56 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 60 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 - log.Println("failed to call XRPC repo.index", err) 61 + l.Error("failed to call XRPC repo.index", "err", err) 60 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 63 LoggedInUser: user, 62 64 NeedsKnotUpgrade: true, ··· 66 68 } 67 69 68 70 rp.pages.Error503(w) 69 - log.Println("failed to build index response", err) 71 + l.Error("failed to build index response", "err", err) 70 72 return 71 73 } 72 74 ··· 119 121 emails := uniqueEmails(commitsTrunc) 120 122 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 121 123 if err != nil { 122 - log.Println("failed to get email to did map", err) 124 + l.Error("failed to get email to did map", "err", err) 123 125 } 124 126 125 127 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 126 128 if err != nil { 127 - log.Println(err) 129 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 128 130 } 129 131 130 132 // TODO: a bit dirty 131 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 133 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 132 134 if err != nil { 133 - log.Printf("failed to compute language percentages: %s", err) 135 + l.Warn("failed to compute language percentages", "err", err) 134 136 // non-fatal 135 137 } 136 138 ··· 140 142 } 141 143 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 142 144 if err != nil { 143 - log.Printf("failed to fetch pipeline statuses: %s", err) 145 + l.Error("failed to fetch pipeline statuses", "err", err) 144 146 // non-fatal 145 147 } 146 148 ··· 162 164 163 165 func (rp *Repo) getLanguageInfo( 164 166 ctx context.Context, 167 + l *slog.Logger, 165 168 f *reporesolver.ResolvedRepo, 166 169 xrpcc *indigoxrpc.Client, 167 170 currentRef string, ··· 180 183 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 184 if err != nil { 182 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 - log.Println("failed to call XRPC repo.languages", xrpcerr) 186 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 184 187 return nil, xrpcerr 185 188 } 186 189 return nil, err ··· 199 202 Bytes: lang.Size, 200 203 }) 201 204 } 205 + 206 + tx, err := rp.db.Begin() 207 + if err != nil { 208 + return nil, err 209 + } 210 + defer tx.Rollback() 202 211 203 212 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 213 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 205 214 if err != nil { 206 215 // non-fatal 207 - log.Println("failed to cache lang results", err) 216 + l.Error("failed to cache lang results", "err", err) 217 + } 218 + 219 + err = tx.Commit() 220 + if err != nil { 221 + return nil, err 208 222 } 209 223 } 210 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 7 "errors" 8 8 "fmt" 9 9 "io" 10 - "log" 11 10 "log/slog" 12 11 "net/http" 13 12 "net/url" ··· 17 16 "strings" 18 17 "time" 19 18 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 19 "tangled.org/core/api/tangled" 24 20 "tangled.org/core/appview/commitverify" 25 21 "tangled.org/core/appview/config" ··· 40 36 "tangled.org/core/types" 41 37 "tangled.org/core/xrpc/serviceauth" 42 38 39 + comatproto "github.com/bluesky-social/indigo/api/atproto" 40 + atpclient "github.com/bluesky-social/indigo/atproto/client" 41 + "github.com/bluesky-social/indigo/atproto/syntax" 42 + lexutil "github.com/bluesky-social/indigo/lex/util" 43 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 43 44 securejoin "github.com/cyphar/filepath-securejoin" 44 45 "github.com/go-chi/chi/v5" 45 46 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 47 ) 49 48 50 49 type Repo struct { ··· 90 89 } 91 90 92 91 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 + l := rp.logger.With("handler", "DownloadArchive") 93 + 93 94 ref := chi.URLParam(r, "ref") 94 95 ref, _ = url.PathUnescape(ref) 95 96 96 97 f, err := rp.repoResolver.Resolve(r) 97 98 if err != nil { 98 - log.Println("failed to get repo and knot", err) 99 + l.Error("failed to get repo and knot", "err", err) 99 100 return 100 101 } 101 102 ··· 111 112 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 112 113 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 113 114 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 114 - log.Println("failed to call XRPC repo.archive", xrpcerr) 115 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 115 116 rp.pages.Error503(w) 116 117 return 117 118 } ··· 128 129 } 129 130 130 131 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 + l := rp.logger.With("handler", "RepoLog") 133 + 131 134 f, err := rp.repoResolver.Resolve(r) 132 135 if err != nil { 133 - log.Println("failed to fully resolve repo", err) 136 + l.Error("failed to fully resolve repo", "err", err) 134 137 return 135 138 } 136 139 ··· 165 168 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 166 169 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 167 170 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 - log.Println("failed to call XRPC repo.log", xrpcerr) 171 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 169 172 rp.pages.Error503(w) 170 173 return 171 174 } 172 175 173 176 var xrpcResp types.RepoLogResponse 174 177 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 - log.Println("failed to decode XRPC response", err) 178 + l.Error("failed to decode XRPC response", "err", err) 176 179 rp.pages.Error503(w) 177 180 return 178 181 } 179 182 180 183 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 181 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 - log.Println("failed to call XRPC repo.tags", xrpcerr) 185 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 183 186 rp.pages.Error503(w) 184 187 return 185 188 } ··· 189 192 var tagResp types.RepoTagsResponse 190 193 if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 191 194 for _, tag := range tagResp.Tags { 192 - tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 195 + hash := tag.Hash 196 + if tag.Tag != nil { 197 + hash = tag.Tag.Target.String() 198 + } 199 + tagMap[hash] = append(tagMap[hash], tag.Name) 193 200 } 194 201 } 195 202 } 196 203 197 204 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 205 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 - log.Println("failed to call XRPC repo.branches", xrpcerr) 206 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 200 207 rp.pages.Error503(w) 201 208 return 202 209 } ··· 214 221 215 222 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 216 223 if err != nil { 217 - log.Println("failed to fetch email to did mapping", err) 224 + l.Error("failed to fetch email to did mapping", "err", err) 218 225 } 219 226 220 227 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 221 228 if err != nil { 222 - log.Println(err) 229 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 223 230 } 224 231 225 232 repoInfo := f.RepoInfo(user) ··· 230 237 } 231 238 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 232 239 if err != nil { 233 - log.Println(err) 240 + l.Error("failed to getPipelineStatuses", "err", err) 234 241 // non-fatal 235 242 } 236 243 ··· 246 253 } 247 254 248 255 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 + l := rp.logger.With("handler", "RepoDescriptionEdit") 257 + 249 258 f, err := rp.repoResolver.Resolve(r) 250 259 if err != nil { 251 - log.Println("failed to get repo and knot", err) 260 + l.Error("failed to get repo and knot", "err", err) 252 261 w.WriteHeader(http.StatusBadRequest) 253 262 return 254 263 } ··· 260 269 } 261 270 262 271 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 + l := rp.logger.With("handler", "RepoDescription") 273 + 263 274 f, err := rp.repoResolver.Resolve(r) 264 275 if err != nil { 265 - log.Println("failed to get repo and knot", err) 276 + l.Error("failed to get repo and knot", "err", err) 266 277 w.WriteHeader(http.StatusBadRequest) 267 278 return 268 279 } ··· 270 281 repoAt := f.RepoAt() 271 282 rkey := repoAt.RecordKey().String() 272 283 if rkey == "" { 273 - log.Println("invalid aturi for repo", err) 284 + l.Error("invalid aturi for repo", "err", err) 274 285 w.WriteHeader(http.StatusInternalServerError) 275 286 return 276 287 } ··· 287 298 newDescription := r.FormValue("description") 288 299 client, err := rp.oauth.AuthorizedClient(r) 289 300 if err != nil { 290 - log.Println("failed to get client") 301 + l.Error("failed to get client") 291 302 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 292 303 return 293 304 } ··· 295 306 // optimistic update 296 307 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 297 308 if err != nil { 298 - log.Println("failed to perferom update-description query", err) 309 + l.Error("failed to perform update-description query", "err", err) 299 310 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 300 311 return 301 312 } ··· 307 318 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 319 // 309 320 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 321 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 322 if err != nil { 312 323 // failed to get record 313 324 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 325 return 315 326 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 327 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 328 Collection: tangled.RepoNSID, 318 329 Repo: newRepo.Did, 319 330 Rkey: newRepo.Rkey, ··· 324 335 }) 325 336 326 337 if err != nil { 327 - log.Println("failed to perferom update-description query", err) 338 + l.Error("failed to perferom update-description query", "err", err) 328 339 // failed to get record 329 340 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 330 341 return ··· 341 352 } 342 353 343 354 func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 + l := rp.logger.With("handler", "RepoCommit") 356 + 344 357 f, err := rp.repoResolver.Resolve(r) 345 358 if err != nil { 346 - log.Println("failed to fully resolve repo", err) 359 + l.Error("failed to fully resolve repo", "err", err) 347 360 return 348 361 } 349 362 ref := chi.URLParam(r, "ref") ··· 371 384 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 372 385 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 373 386 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 374 - log.Println("failed to call XRPC repo.diff", xrpcerr) 387 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 375 388 rp.pages.Error503(w) 376 389 return 377 390 } 378 391 379 392 var result types.RepoCommitResponse 380 393 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 381 - log.Println("failed to decode XRPC response", err) 394 + l.Error("failed to decode XRPC response", "err", err) 382 395 rp.pages.Error503(w) 383 396 return 384 397 } 385 398 386 399 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 387 400 if err != nil { 388 - log.Println("failed to get email to did mapping:", err) 401 + l.Error("failed to get email to did mapping", "err", err) 389 402 } 390 403 391 404 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 392 405 if err != nil { 393 - log.Println(err) 406 + l.Error("failed to GetVerifiedCommits", "err", err) 394 407 } 395 408 396 409 user := rp.oauth.GetUser(r) 397 410 repoInfo := f.RepoInfo(user) 398 411 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 399 412 if err != nil { 400 - log.Println(err) 413 + l.Error("failed to getPipelineStatuses", "err", err) 401 414 // non-fatal 402 415 } 403 416 var pipeline *models.Pipeline ··· 417 430 } 418 431 419 432 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 + l := rp.logger.With("handler", "RepoTree") 434 + 420 435 f, err := rp.repoResolver.Resolve(r) 421 436 if err != nil { 422 - log.Println("failed to fully resolve repo", err) 437 + l.Error("failed to fully resolve repo", "err", err) 423 438 return 424 439 } 425 440 ··· 444 459 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 445 460 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 446 461 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 - log.Println("failed to call XRPC repo.tree", xrpcerr) 462 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 448 463 rp.pages.Error503(w) 449 464 return 450 465 } ··· 519 534 } 520 535 521 536 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 + l := rp.logger.With("handler", "RepoTags") 538 + 522 539 f, err := rp.repoResolver.Resolve(r) 523 540 if err != nil { 524 - log.Println("failed to get repo and knot", err) 541 + l.Error("failed to get repo and knot", "err", err) 525 542 return 526 543 } 527 544 ··· 537 554 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 538 555 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 539 556 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 540 - log.Println("failed to call XRPC repo.tags", xrpcerr) 557 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 541 558 rp.pages.Error503(w) 542 559 return 543 560 } 544 561 545 562 var result types.RepoTagsResponse 546 563 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 547 - log.Println("failed to decode XRPC response", err) 564 + l.Error("failed to decode XRPC response", "err", err) 548 565 rp.pages.Error503(w) 549 566 return 550 567 } 551 568 552 569 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 553 570 if err != nil { 554 - log.Println("failed grab artifacts", err) 571 + l.Error("failed grab artifacts", "err", err) 555 572 return 556 573 } 557 574 ··· 588 605 } 589 606 590 607 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 + l := rp.logger.With("handler", "RepoBranches") 609 + 591 610 f, err := rp.repoResolver.Resolve(r) 592 611 if err != nil { 593 - log.Println("failed to get repo and knot", err) 612 + l.Error("failed to get repo and knot", "err", err) 594 613 return 595 614 } 596 615 ··· 606 625 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 607 626 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 608 627 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 609 - log.Println("failed to call XRPC repo.branches", xrpcerr) 628 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 610 629 rp.pages.Error503(w) 611 630 return 612 631 } 613 632 614 633 var result types.RepoBranchesResponse 615 634 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 616 - log.Println("failed to decode XRPC response", err) 635 + l.Error("failed to decode XRPC response", "err", err) 617 636 rp.pages.Error503(w) 618 637 return 619 638 } ··· 628 647 }) 629 648 } 630 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 + 631 699 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 + l := rp.logger.With("handler", "RepoBlob") 701 + 632 702 f, err := rp.repoResolver.Resolve(r) 633 703 if err != nil { 634 - log.Println("failed to get repo and knot", err) 704 + l.Error("failed to get repo and knot", "err", err) 635 705 return 636 706 } 637 707 ··· 653 723 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 654 724 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 655 725 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 656 - log.Println("failed to call XRPC repo.blob", xrpcerr) 726 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 657 727 rp.pages.Error503(w) 658 728 return 659 729 } ··· 753 823 } 754 824 755 825 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 + l := rp.logger.With("handler", "RepoBlobRaw") 827 + 756 828 f, err := rp.repoResolver.Resolve(r) 757 829 if err != nil { 758 - log.Println("failed to get repo and knot", err) 830 + l.Error("failed to get repo and knot", "err", err) 759 831 w.WriteHeader(http.StatusBadRequest) 760 832 return 761 833 } ··· 787 859 788 860 req, err := http.NewRequest("GET", blobURL, nil) 789 861 if err != nil { 790 - log.Println("failed to create request", err) 862 + l.Error("failed to create request", "err", err) 791 863 return 792 864 } 793 865 ··· 799 871 client := &http.Client{} 800 872 resp, err := client.Do(req) 801 873 if err != nil { 802 - log.Println("failed to reach knotserver", err) 874 + l.Error("failed to reach knotserver", "err", err) 803 875 rp.pages.Error503(w) 804 876 return 805 877 } ··· 812 884 } 813 885 814 886 if resp.StatusCode != http.StatusOK { 815 - log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 887 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 816 888 w.WriteHeader(resp.StatusCode) 817 889 _, _ = io.Copy(w, resp.Body) 818 890 return ··· 821 893 contentType := resp.Header.Get("Content-Type") 822 894 body, err := io.ReadAll(resp.Body) 823 895 if err != nil { 824 - log.Printf("error reading response body from knotserver: %v", err) 896 + l.Error("error reading response body from knotserver", "err", err) 825 897 w.WriteHeader(http.StatusInternalServerError) 826 898 return 827 899 } ··· 863 935 user := rp.oauth.GetUser(r) 864 936 l := rp.logger.With("handler", "EditSpindle") 865 937 l = l.With("did", user.Did) 866 - l = l.With("handle", user.Handle) 867 938 868 939 errorId := "operation-error" 869 940 fail := func(msg string, err error) { ··· 916 987 return 917 988 } 918 989 919 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 990 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 920 991 if err != nil { 921 992 fail("Failed to update spindle, no record found on PDS.", err) 922 993 return 923 994 } 924 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 995 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 925 996 Collection: tangled.RepoNSID, 926 997 Repo: newRepo.Did, 927 998 Rkey: newRepo.Rkey, ··· 951 1022 user := rp.oauth.GetUser(r) 952 1023 l := rp.logger.With("handler", "AddLabel") 953 1024 l = l.With("did", user.Did) 954 - l = l.With("handle", user.Handle) 955 1025 956 1026 f, err := rp.repoResolver.Resolve(r) 957 1027 if err != nil { ··· 1020 1090 1021 1091 // emit a labelRecord 1022 1092 labelRecord := label.AsRecord() 1023 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1093 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1024 1094 Collection: tangled.LabelDefinitionNSID, 1025 1095 Repo: label.Did, 1026 1096 Rkey: label.Rkey, ··· 1043 1113 newRepo.Labels = append(newRepo.Labels, aturi) 1044 1114 repoRecord := newRepo.AsRecord() 1045 1115 1046 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1116 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1047 1117 if err != nil { 1048 1118 fail("Failed to update labels, no record found on PDS.", err) 1049 1119 return 1050 1120 } 1051 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1121 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1052 1122 Collection: tangled.RepoNSID, 1053 1123 Repo: newRepo.Did, 1054 1124 Rkey: newRepo.Rkey, ··· 1111 1181 user := rp.oauth.GetUser(r) 1112 1182 l := rp.logger.With("handler", "DeleteLabel") 1113 1183 l = l.With("did", user.Did) 1114 - l = l.With("handle", user.Handle) 1115 1184 1116 1185 f, err := rp.repoResolver.Resolve(r) 1117 1186 if err != nil { ··· 1141 1210 } 1142 1211 1143 1212 // delete label record from PDS 1144 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1213 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1145 1214 Collection: tangled.LabelDefinitionNSID, 1146 1215 Repo: label.Did, 1147 1216 Rkey: label.Rkey, ··· 1163 1232 newRepo.Labels = updated 1164 1233 repoRecord := newRepo.AsRecord() 1165 1234 1166 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1235 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1167 1236 if err != nil { 1168 1237 fail("Failed to update labels, no record found on PDS.", err) 1169 1238 return 1170 1239 } 1171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1240 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1172 1241 Collection: tangled.RepoNSID, 1173 1242 Repo: newRepo.Did, 1174 1243 Rkey: newRepo.Rkey, ··· 1220 1289 user := rp.oauth.GetUser(r) 1221 1290 l := rp.logger.With("handler", "SubscribeLabel") 1222 1291 l = l.With("did", user.Did) 1223 - l = l.With("handle", user.Handle) 1224 1292 1225 1293 f, err := rp.repoResolver.Resolve(r) 1226 1294 if err != nil { ··· 1261 1329 return 1262 1330 } 1263 1331 1264 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1332 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1265 1333 if err != nil { 1266 1334 fail("Failed to update labels, no record found on PDS.", err) 1267 1335 return 1268 1336 } 1269 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1337 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1270 1338 Collection: tangled.RepoNSID, 1271 1339 Repo: newRepo.Did, 1272 1340 Rkey: newRepo.Rkey, ··· 1307 1375 user := rp.oauth.GetUser(r) 1308 1376 l := rp.logger.With("handler", "UnsubscribeLabel") 1309 1377 l = l.With("did", user.Did) 1310 - l = l.With("handle", user.Handle) 1311 1378 1312 1379 f, err := rp.repoResolver.Resolve(r) 1313 1380 if err != nil { ··· 1350 1417 return 1351 1418 } 1352 1419 1353 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1420 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1354 1421 if err != nil { 1355 1422 fail("Failed to update labels, no record found on PDS.", err) 1356 1423 return 1357 1424 } 1358 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1425 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1359 1426 Collection: tangled.RepoNSID, 1360 1427 Repo: newRepo.Did, 1361 1428 Rkey: newRepo.Rkey, ··· 1401 1468 db.FilterContains("scope", subject.Collection().String()), 1402 1469 ) 1403 1470 if err != nil { 1404 - log.Println("failed to fetch label defs", err) 1471 + l.Error("failed to fetch label defs", "err", err) 1405 1472 return 1406 1473 } 1407 1474 ··· 1412 1479 1413 1480 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1414 1481 if err != nil { 1415 - log.Println("failed to build label state", err) 1482 + l.Error("failed to build label state", "err", err) 1416 1483 return 1417 1484 } 1418 1485 state := states[subject] ··· 1449 1516 db.FilterContains("scope", subject.Collection().String()), 1450 1517 ) 1451 1518 if err != nil { 1452 - log.Println("failed to fetch labels", err) 1519 + l.Error("failed to fetch labels", "err", err) 1453 1520 return 1454 1521 } 1455 1522 ··· 1460 1527 1461 1528 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1462 1529 if err != nil { 1463 - log.Println("failed to build label state", err) 1530 + l.Error("failed to build label state", "err", err) 1464 1531 return 1465 1532 } 1466 1533 state := states[subject] ··· 1479 1546 user := rp.oauth.GetUser(r) 1480 1547 l := rp.logger.With("handler", "AddCollaborator") 1481 1548 l = l.With("did", user.Did) 1482 - l = l.With("handle", user.Handle) 1483 1549 1484 1550 f, err := rp.repoResolver.Resolve(r) 1485 1551 if err != nil { ··· 1526 1592 currentUser := rp.oauth.GetUser(r) 1527 1593 rkey := tid.TID() 1528 1594 createdAt := time.Now() 1529 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1595 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1530 1596 Collection: tangled.RepoCollaboratorNSID, 1531 1597 Repo: currentUser.Did, 1532 1598 Rkey: rkey, ··· 1608 1674 1609 1675 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1610 1676 user := rp.oauth.GetUser(r) 1677 + l := rp.logger.With("handler", "DeleteRepo") 1611 1678 1612 1679 noticeId := "operation-error" 1613 1680 f, err := rp.repoResolver.Resolve(r) 1614 1681 if err != nil { 1615 - log.Println("failed to get repo and knot", err) 1682 + l.Error("failed to get repo and knot", "err", err) 1616 1683 return 1617 1684 } 1618 1685 1619 1686 // remove record from pds 1620 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1687 + atpClient, err := rp.oauth.AuthorizedClient(r) 1621 1688 if err != nil { 1622 - log.Println("failed to get authorized client", err) 1689 + l.Error("failed to get authorized client", "err", err) 1623 1690 return 1624 1691 } 1625 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1692 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1626 1693 Collection: tangled.RepoNSID, 1627 1694 Repo: user.Did, 1628 1695 Rkey: f.Rkey, 1629 1696 }) 1630 1697 if err != nil { 1631 - log.Printf("failed to delete record: %s", err) 1698 + l.Error("failed to delete record", "err", err) 1632 1699 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1633 1700 return 1634 1701 } 1635 - log.Println("removed repo record ", f.RepoAt().String()) 1702 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1636 1703 1637 1704 client, err := rp.oauth.ServiceClient( 1638 1705 r, ··· 1641 1708 oauth.WithDev(rp.config.Core.Dev), 1642 1709 ) 1643 1710 if err != nil { 1644 - log.Println("failed to connect to knot server:", err) 1711 + l.Error("failed to connect to knot server", "err", err) 1645 1712 return 1646 1713 } 1647 1714 ··· 1658 1725 rp.pages.Notice(w, noticeId, err.Error()) 1659 1726 return 1660 1727 } 1661 - log.Println("deleted repo from knot") 1728 + l.Info("deleted repo from knot") 1662 1729 1663 1730 tx, err := rp.db.BeginTx(r.Context(), nil) 1664 1731 if err != nil { 1665 - log.Println("failed to start tx") 1732 + l.Error("failed to start tx") 1666 1733 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1667 1734 return 1668 1735 } ··· 1670 1737 tx.Rollback() 1671 1738 err = rp.enforcer.E.LoadPolicy() 1672 1739 if err != nil { 1673 - log.Println("failed to rollback policies") 1740 + l.Error("failed to rollback policies") 1674 1741 } 1675 1742 }() 1676 1743 ··· 1684 1751 did := c[0] 1685 1752 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1686 1753 } 1687 - log.Println("removed collaborators") 1754 + l.Info("removed collaborators") 1688 1755 1689 1756 // remove repo RBAC 1690 1757 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1699 1766 rp.pages.Notice(w, noticeId, "Failed to update appview") 1700 1767 return 1701 1768 } 1702 - log.Println("removed repo from db") 1769 + l.Info("removed repo from db") 1703 1770 1704 1771 err = tx.Commit() 1705 1772 if err != nil { 1706 - log.Println("failed to commit changes", err) 1773 + l.Error("failed to commit changes", "err", err) 1707 1774 http.Error(w, err.Error(), http.StatusInternalServerError) 1708 1775 return 1709 1776 } 1710 1777 1711 1778 err = rp.enforcer.E.SavePolicy() 1712 1779 if err != nil { 1713 - log.Println("failed to update ACLs", err) 1780 + l.Error("failed to update ACLs", "err", err) 1714 1781 http.Error(w, err.Error(), http.StatusInternalServerError) 1715 1782 return 1716 1783 } ··· 1719 1786 } 1720 1787 1721 1788 func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 + l := rp.logger.With("handler", "SetDefaultBranch") 1790 + 1722 1791 f, err := rp.repoResolver.Resolve(r) 1723 1792 if err != nil { 1724 - log.Println("failed to get repo and knot", err) 1793 + l.Error("failed to get repo and knot", "err", err) 1725 1794 return 1726 1795 } 1727 1796 ··· 1739 1808 oauth.WithDev(rp.config.Core.Dev), 1740 1809 ) 1741 1810 if err != nil { 1742 - log.Println("failed to connect to knot server:", err) 1811 + l.Error("failed to connect to knot server", "err", err) 1743 1812 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1744 1813 return 1745 1814 } ··· 1753 1822 }, 1754 1823 ) 1755 1824 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1756 - log.Println("xrpc failed", "err", xe) 1825 + l.Error("xrpc failed", "err", xe) 1757 1826 rp.pages.Notice(w, noticeId, err.Error()) 1758 1827 return 1759 1828 } ··· 1764 1833 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1765 1834 user := rp.oauth.GetUser(r) 1766 1835 l := rp.logger.With("handler", "Secrets") 1767 - l = l.With("handle", user.Handle) 1768 1836 l = l.With("did", user.Did) 1769 1837 1770 1838 f, err := rp.repoResolver.Resolve(r) 1771 1839 if err != nil { 1772 - log.Println("failed to get repo and knot", err) 1840 + l.Error("failed to get repo and knot", "err", err) 1773 1841 return 1774 1842 } 1775 1843 1776 1844 if f.Spindle == "" { 1777 - log.Println("empty spindle cannot add/rm secret", err) 1845 + l.Error("empty spindle cannot add/rm secret", "err", err) 1778 1846 return 1779 1847 } 1780 1848 ··· 1791 1859 oauth.WithDev(rp.config.Core.Dev), 1792 1860 ) 1793 1861 if err != nil { 1794 - log.Println("failed to create spindle client", err) 1862 + l.Error("failed to create spindle client", "err", err) 1795 1863 return 1796 1864 } 1797 1865 ··· 1877 1945 } 1878 1946 1879 1947 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 + l := rp.logger.With("handler", "generalSettings") 1949 + 1880 1950 f, err := rp.repoResolver.Resolve(r) 1881 1951 user := rp.oauth.GetUser(r) 1882 1952 ··· 1892 1962 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1893 1963 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1894 1964 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1895 - log.Println("failed to call XRPC repo.branches", xrpcerr) 1965 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1896 1966 rp.pages.Error503(w) 1897 1967 return 1898 1968 } 1899 1969 1900 1970 var result types.RepoBranchesResponse 1901 1971 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1902 - log.Println("failed to decode XRPC response", err) 1972 + l.Error("failed to decode XRPC response", "err", err) 1903 1973 rp.pages.Error503(w) 1904 1974 return 1905 1975 } 1906 1976 1907 1977 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1908 1978 if err != nil { 1909 - log.Println("failed to fetch labels", err) 1979 + l.Error("failed to fetch labels", "err", err) 1910 1980 rp.pages.Error503(w) 1911 1981 return 1912 1982 } 1913 1983 1914 1984 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1915 1985 if err != nil { 1916 - log.Println("failed to fetch labels", err) 1986 + l.Error("failed to fetch labels", "err", err) 1917 1987 rp.pages.Error503(w) 1918 1988 return 1919 1989 } ··· 1961 2031 } 1962 2032 1963 2033 func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 + l := rp.logger.With("handler", "accessSettings") 2035 + 1964 2036 f, err := rp.repoResolver.Resolve(r) 1965 2037 user := rp.oauth.GetUser(r) 1966 2038 1967 2039 repoCollaborators, err := f.Collaborators(r.Context()) 1968 2040 if err != nil { 1969 - log.Println("failed to get collaborators", err) 2041 + l.Error("failed to get collaborators", "err", err) 1970 2042 } 1971 2043 1972 2044 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ ··· 1979 2051 } 1980 2052 1981 2053 func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 + l := rp.logger.With("handler", "pipelineSettings") 2055 + 1982 2056 f, err := rp.repoResolver.Resolve(r) 1983 2057 user := rp.oauth.GetUser(r) 1984 2058 1985 2059 // all spindles that the repo owner is a member of 1986 2060 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1987 2061 if err != nil { 1988 - log.Println("failed to fetch spindles", err) 2062 + l.Error("failed to fetch spindles", "err", err) 1989 2063 return 1990 2064 } 1991 2065 ··· 1998 2072 oauth.WithExp(60), 1999 2073 oauth.WithDev(rp.config.Core.Dev), 2000 2074 ); err != nil { 2001 - log.Println("failed to create spindle client", err) 2075 + l.Error("failed to create spindle client", "err", err) 2002 2076 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2003 - log.Println("failed to fetch secrets", err) 2077 + l.Error("failed to fetch secrets", "err", err) 2004 2078 } else { 2005 2079 secrets = resp.Secrets 2006 2080 } ··· 2040 2114 } 2041 2115 2042 2116 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 + l := rp.logger.With("handler", "SyncRepoFork") 2118 + 2043 2119 ref := chi.URLParam(r, "ref") 2044 2120 ref, _ = url.PathUnescape(ref) 2045 2121 2046 2122 user := rp.oauth.GetUser(r) 2047 2123 f, err := rp.repoResolver.Resolve(r) 2048 2124 if err != nil { 2049 - log.Printf("failed to resolve source repo: %v", err) 2125 + l.Error("failed to resolve source repo", "err", err) 2050 2126 return 2051 2127 } 2052 2128 ··· 2090 2166 } 2091 2167 2092 2168 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2169 + l := rp.logger.With("handler", "ForkRepo") 2170 + 2093 2171 user := rp.oauth.GetUser(r) 2094 2172 f, err := rp.repoResolver.Resolve(r) 2095 2173 if err != nil { 2096 - log.Printf("failed to resolve source repo: %v", err) 2174 + l.Error("failed to resolve source repo", "err", err) 2097 2175 return 2098 2176 } 2099 2177 ··· 2144 2222 ) 2145 2223 if err != nil { 2146 2224 if !errors.Is(err, sql.ErrNoRows) { 2147 - log.Println("error fetching existing repo from db", "err", err) 2225 + l.Error("error fetching existing repo from db", "err", err) 2148 2226 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2149 2227 return 2150 2228 } ··· 2179 2257 } 2180 2258 record := repo.AsRecord() 2181 2259 2182 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2260 + atpClient, err := rp.oauth.AuthorizedClient(r) 2183 2261 if err != nil { 2184 2262 l.Error("failed to create xrpcclient", "err", err) 2185 2263 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2186 2264 return 2187 2265 } 2188 2266 2189 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2267 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2190 2268 Collection: tangled.RepoNSID, 2191 2269 Repo: user.Did, 2192 2270 Rkey: rkey, ··· 2218 2296 rollback := func() { 2219 2297 err1 := tx.Rollback() 2220 2298 err2 := rp.enforcer.E.LoadPolicy() 2221 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2299 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2222 2300 2223 2301 // ignore txn complete errors, this is okay 2224 2302 if errors.Is(err1, sql.ErrTxDone) { ··· 2259 2337 2260 2338 err = db.AddRepo(tx, repo) 2261 2339 if err != nil { 2262 - log.Println(err) 2340 + l.Error("failed to AddRepo", "err", err) 2263 2341 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2264 2342 return 2265 2343 } ··· 2268 2346 p, _ := securejoin.SecureJoin(user.Did, forkName) 2269 2347 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2270 2348 if err != nil { 2271 - log.Println(err) 2349 + l.Error("failed to add ACLs", "err", err) 2272 2350 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2273 2351 return 2274 2352 } 2275 2353 2276 2354 err = tx.Commit() 2277 2355 if err != nil { 2278 - log.Println("failed to commit changes", err) 2356 + l.Error("failed to commit changes", "err", err) 2279 2357 http.Error(w, err.Error(), http.StatusInternalServerError) 2280 2358 return 2281 2359 } 2282 2360 2283 2361 err = rp.enforcer.E.SavePolicy() 2284 2362 if err != nil { 2285 - log.Println("failed to update ACLs", err) 2363 + l.Error("failed to update ACLs", "err", err) 2286 2364 http.Error(w, err.Error(), http.StatusInternalServerError) 2287 2365 return 2288 2366 } ··· 2291 2369 aturi = "" 2292 2370 2293 2371 rp.notifier.NewRepo(r.Context(), repo) 2294 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2372 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2295 2373 } 2296 2374 } 2297 2375 2298 2376 // this is used to rollback changes made to the PDS 2299 2377 // 2300 2378 // it is a no-op if the provided ATURI is empty 2301 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2379 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2302 2380 if aturi == "" { 2303 2381 return nil 2304 2382 } ··· 2309 2387 repo := parsed.Authority().String() 2310 2388 rkey := parsed.RecordKey().String() 2311 2389 2312 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2390 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2313 2391 Collection: collection, 2314 2392 Repo: repo, 2315 2393 Rkey: rkey, ··· 2318 2396 } 2319 2397 2320 2398 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 + l := rp.logger.With("handler", "RepoCompareNew") 2400 + 2321 2401 user := rp.oauth.GetUser(r) 2322 2402 f, err := rp.repoResolver.Resolve(r) 2323 2403 if err != nil { 2324 - log.Println("failed to get repo and knot", err) 2404 + l.Error("failed to get repo and knot", "err", err) 2325 2405 return 2326 2406 } 2327 2407 ··· 2337 2417 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2338 2418 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2339 2419 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2340 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2420 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2341 2421 rp.pages.Error503(w) 2342 2422 return 2343 2423 } 2344 2424 2345 2425 var branchResult types.RepoBranchesResponse 2346 2426 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2347 - log.Println("failed to decode XRPC branches response", err) 2427 + l.Error("failed to decode XRPC branches response", "err", err) 2348 2428 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2349 2429 return 2350 2430 } ··· 2374 2454 2375 2455 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2376 2456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2377 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2457 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2378 2458 rp.pages.Error503(w) 2379 2459 return 2380 2460 } 2381 2461 2382 2462 var tags types.RepoTagsResponse 2383 2463 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2384 - log.Println("failed to decode XRPC tags response", err) 2464 + l.Error("failed to decode XRPC tags response", "err", err) 2385 2465 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2386 2466 return 2387 2467 } ··· 2399 2479 } 2400 2480 2401 2481 func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 + l := rp.logger.With("handler", "RepoCompare") 2483 + 2402 2484 user := rp.oauth.GetUser(r) 2403 2485 f, err := rp.repoResolver.Resolve(r) 2404 2486 if err != nil { 2405 - log.Println("failed to get repo and knot", err) 2487 + l.Error("failed to get repo and knot", "err", err) 2406 2488 return 2407 2489 } 2408 2490 ··· 2429 2511 head, _ = url.PathUnescape(head) 2430 2512 2431 2513 if base == "" || head == "" { 2432 - log.Printf("invalid comparison") 2514 + l.Error("invalid comparison") 2433 2515 rp.pages.Error404(w) 2434 2516 return 2435 2517 } ··· 2447 2529 2448 2530 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2449 2531 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2450 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2532 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2451 2533 rp.pages.Error503(w) 2452 2534 return 2453 2535 } 2454 2536 2455 2537 var branches types.RepoBranchesResponse 2456 2538 if err := json.Unmarshal(branchBytes, &branches); err != nil { 2457 - log.Println("failed to decode XRPC branches response", err) 2539 + l.Error("failed to decode XRPC branches response", "err", err) 2458 2540 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2459 2541 return 2460 2542 } 2461 2543 2462 2544 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2463 2545 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2464 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2546 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2465 2547 rp.pages.Error503(w) 2466 2548 return 2467 2549 } 2468 2550 2469 2551 var tags types.RepoTagsResponse 2470 2552 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2471 - log.Println("failed to decode XRPC tags response", err) 2553 + l.Error("failed to decode XRPC tags response", "err", err) 2472 2554 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2473 2555 return 2474 2556 } 2475 2557 2476 2558 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2477 2559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2478 - log.Println("failed to call XRPC repo.compare", xrpcerr) 2560 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2479 2561 rp.pages.Error503(w) 2480 2562 return 2481 2563 } 2482 2564 2483 2565 var formatPatch types.RepoFormatPatchResponse 2484 2566 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2485 - log.Println("failed to decode XRPC compare response", err) 2567 + l.Error("failed to decode XRPC compare response", "err", err) 2486 2568 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2487 2569 return 2488 2570 } 2489 2571 2490 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2572 + var diff types.NiceDiff 2573 + if formatPatch.CombinedPatchRaw != "" { 2574 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2575 + } else { 2576 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2577 + } 2491 2578 2492 2579 repoinfo := f.RepoInfo(user) 2493 2580
+2
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 13 14 r.Get("/feed.atom", rp.RepoAtomFeed) 14 15 r.Get("/commits/{ref}", rp.RepoLog) 15 16 r.Route("/tree/{ref}", func(r chi.Router) { ··· 18 19 }) 19 20 r.Get("/commit/{ref}", rp.RepoCommit) 20 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 21 23 r.Route("/tags", func(r chi.Router) { 22 24 r.Get("/", rp.RepoTags) 23 25 r.Route("/{tag}", func(r chi.Router) {
+5 -4
appview/settings/settings.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 25 26 lexutil "github.com/bluesky-social/indigo/lex/util" 26 27 "github.com/gliderlabs/ssh" 27 28 "github.com/google/uuid" ··· 91 92 user := s.OAuth.GetUser(r) 92 93 did := s.OAuth.GetDid(r) 93 94 94 - prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 + prefs, err := db.GetNotificationPreference(s.Db, did) 95 96 if err != nil { 96 97 log.Printf("failed to get notification preferences: %s", err) 97 98 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") ··· 110 111 did := s.OAuth.GetDid(r) 111 112 112 113 prefs := &models.NotificationPreferences{ 113 - UserDid: did, 114 + UserDid: syntax.DID(did), 114 115 RepoStarred: r.FormValue("repo_starred") == "on", 115 116 IssueCreated: r.FormValue("issue_created") == "on", 116 117 IssueCommented: r.FormValue("issue_commented") == "on", ··· 470 471 } 471 472 472 473 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 474 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 475 Collection: tangled.PublicKeyNSID, 475 476 Repo: did, 476 477 Rkey: rkey, ··· 527 528 528 529 if rkey != "" { 529 530 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 531 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 532 Collection: tangled.PublicKeyNSID, 532 533 Repo: did, 533 534 Rkey: rkey,
+18
appview/signup/requests.go
··· 102 102 103 103 return result.DID, nil 104 104 } 105 + 106 + func (s *Signup) deleteAccountRequest(did string) error { 107 + body := map[string]string{ 108 + "did": did, 109 + } 110 + 111 + resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true) 112 + if err != nil { 113 + return err 114 + } 115 + defer resp.Body.Close() 116 + 117 + if resp.StatusCode != http.StatusOK { 118 + return s.handlePdsError(resp, "delete account") 119 + } 120 + 121 + return nil 122 + }
+95 -40
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 5 6 "encoding/json" 6 7 "errors" 7 8 "fmt" ··· 20 21 "tangled.org/core/appview/models" 21 22 "tangled.org/core/appview/pages" 22 23 "tangled.org/core/appview/state/userutil" 23 - "tangled.org/core/appview/xrpcclient" 24 24 "tangled.org/core/idresolver" 25 25 ) 26 26 ··· 29 29 db *db.DB 30 30 cf *dns.Cloudflare 31 31 posthog posthog.Client 32 - xrpc *xrpcclient.Client 33 32 idResolver *idresolver.Resolver 34 33 pages *pages.Pages 35 34 l *slog.Logger ··· 64 63 disallowed := make(map[string]bool) 65 64 66 65 if filepath == "" { 67 - logger.Debug("no disallowed nicknames file configured") 66 + logger.Warn("no disallowed nicknames file configured") 68 67 return disallowed 69 68 } 70 69 ··· 133 132 noticeId := "signup-msg" 134 133 135 134 if err := s.validateCaptcha(cfToken, r); err != nil { 136 - s.l.Warn("turnstile validation failed", "error", err) 135 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 136 s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 137 return 139 138 } ··· 218 217 return 219 218 } 220 219 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 220 if s.cf == nil { 229 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 230 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 231 223 return 232 224 } 233 225 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 - }) 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 241 228 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.") 229 + // Error already logged and notice already sent 244 230 return 245 231 } 232 + } 233 + } 246 234 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 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 + } 257 272 } 273 + }() 258 274 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)) 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 + } 262 282 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 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 270 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 271 326 } 272 327 273 328 type turnstileResponse struct {
+5 -5
appview/spindles/spindles.go
··· 189 189 return 190 190 } 191 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 193 var exCid *string 194 194 if ex != nil { 195 195 exCid = ex.Cid 196 196 } 197 197 198 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.SpindleNSID, 201 201 Repo: user.Did, 202 202 Rkey: instance, ··· 332 332 return 333 333 } 334 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 336 Collection: tangled.SpindleNSID, 337 337 Repo: user.Did, 338 338 Rkey: instance, ··· 542 542 return 543 543 } 544 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 546 Collection: tangled.SpindleMemberNSID, 547 547 Repo: user.Did, 548 548 Rkey: rkey, ··· 683 683 } 684 684 685 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 687 Collection: tangled.SpindleMemberNSID, 688 688 Repo: user.Did, 689 689 Rkey: members[0].Rkey,
+3 -2
appview/state/follow.go
··· 26 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 27 if err != nil { 28 28 log.Println("failed to follow, invalid did") 29 + return 29 30 } 30 31 31 32 if currentUser.Did == subjectIdent.DID.String() { ··· 43 44 case http.MethodPost: 44 45 createdAt := time.Now().Format(time.RFC3339) 45 46 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 48 Collection: tangled.GraphFollowNSID, 48 49 Repo: currentUser.Did, 49 50 Rkey: rkey, ··· 88 89 return 89 90 } 90 91 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 93 Collection: tangled.GraphFollowNSID, 93 94 Repo: currentUser.Did, 94 95 Rkey: follow.Rkey,
+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 25 ) 26 26 27 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 + logger := log.FromContext(ctx) 29 + logger = log.SubLogger(logger, "knotstream") 30 + 28 31 knots, err := db.GetRegistrations( 29 32 d, 30 33 db.FilterIsNot("registered", "null"), ··· 39 42 srcs[s] = struct{}{} 40 43 } 41 44 42 - logger := log.New("knotstream") 43 45 cache := cache.New(c.Redis.Addr) 44 46 cursorStore := cursor.NewRedisCursorStore(cache) 45 47 ··· 172 174 }) 173 175 } 174 176 175 - return db.InsertRepoLanguages(d, langs) 177 + tx, err := d.Begin() 178 + if err != nil { 179 + return err 180 + } 181 + defer tx.Rollback() 182 + 183 + // update appview's cache 184 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 185 + if err != nil { 186 + fmt.Printf("failed; %s\n", err) 187 + // non-fatal 188 + } 189 + 190 + return tx.Commit() 176 191 } 177 192 178 193 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+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 634 vanityStats = append(vanityStats, string(v.Kind)) 635 635 } 636 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 638 var cid *string 639 639 if ex != nil { 640 640 cid = ex.Cid 641 641 } 642 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 644 Collection: tangled.ActorProfileNSID, 645 645 Repo: user.Did, 646 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" ··· 47 47 case http.MethodPost: 48 48 createdAt := time.Now().Format(time.RFC3339) 49 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 52 Repo: currentUser.Did, 53 53 Rkey: rkey, ··· 70 70 return 71 71 } 72 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 76 76 } 77 77 78 78 log.Println("created atproto record: ", resp.Uri) ··· 80 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 81 ThreadAt: subjectUri, 82 82 Kind: reactionKind, 83 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 84 85 IsReacted: true, 85 86 }) 86 87 ··· 92 93 return 93 94 } 94 95 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 97 Collection: tangled.FeedReactionNSID, 97 98 Repo: currentUser.Did, 98 99 Rkey: reaction.Rkey, ··· 109 110 // this is not an issue, the firehose event might have already done this 110 111 } 111 112 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 113 114 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 115 116 return 116 117 } 117 118 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 120 ThreadAt: subjectUri, 120 121 Kind: reactionKind, 121 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 122 124 IsReacted: false, 123 125 }) 124 126
+69 -23
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 11 10 "tangled.org/core/appview/labels" 12 11 "tangled.org/core/appview/middleware" 13 12 "tangled.org/core/appview/notifications" 14 - oauthhandler "tangled.org/core/appview/oauth/handler" 15 13 "tangled.org/core/appview/pipelines" 16 14 "tangled.org/core/appview/pulls" 17 15 "tangled.org/core/appview/repo" ··· 34 32 s.pages, 35 33 ) 36 34 37 - router.Use(middleware.TryRefreshSession()) 38 35 router.Get("/favicon.svg", s.Favicon) 39 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 40 39 41 40 userRouter := s.UserRouter(&middleware) 42 41 standardRouter := s.StandardRouter(&middleware) ··· 122 121 // special-case handler for serving tangled.org/core 123 122 r.Get("/core", s.Core()) 124 123 124 + r.Get("/login", s.Login) 125 + r.Post("/login", s.Login) 126 + r.Post("/logout", s.Logout) 127 + 125 128 r.Route("/repo", func(r chi.Router) { 126 129 r.Route("/new", func(r chi.Router) { 127 130 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 130 133 }) 131 134 // r.Post("/import", s.ImportRepo) 132 135 }) 136 + 137 + r.Get("/goodfirstissues", s.GoodFirstIssues) 133 138 134 139 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 135 140 r.Post("/", s.Follow) ··· 161 166 r.Mount("/notifications", s.NotificationsRouter(mw)) 162 167 163 168 r.Mount("/signup", s.SignupRouter()) 164 - r.Mount("/", s.OAuthRouter()) 169 + r.Mount("/", s.oauth.Router()) 165 170 166 171 r.Get("/keys/{user}", s.Keys) 167 172 r.Get("/terms", s.TermsOfService) ··· 188 193 } 189 194 } 190 195 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 196 func (s *State) SettingsRouter() http.Handler { 198 197 settings := &settings.Settings{ 199 198 Db: s.db, ··· 206 205 } 207 206 208 207 func (s *State) SpindlesRouter() http.Handler { 209 - logger := log.New("spindles") 208 + logger := log.SubLogger(s.logger, "spindles") 210 209 211 210 spindles := &spindles.Spindles{ 212 211 Db: s.db, ··· 222 221 } 223 222 224 223 func (s *State) KnotsRouter() http.Handler { 225 - logger := log.New("knots") 224 + logger := log.SubLogger(s.logger, "knots") 226 225 227 226 knots := &knots.Knots{ 228 227 Db: s.db, ··· 239 238 } 240 239 241 240 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 242 - logger := log.New("strings") 241 + logger := log.SubLogger(s.logger, "strings") 243 242 244 243 strs := &avstrings.Strings{ 245 244 Db: s.db, ··· 254 253 } 255 254 256 255 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) 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 + ) 258 267 return issues.Router(mw) 259 268 } 260 269 261 270 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) 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 + ) 263 283 return pulls.Router(mw) 264 284 } 265 285 266 286 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) 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 + ) 269 300 return repo.Router(mw) 270 301 } 271 302 272 303 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) 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 + ) 274 315 return pipes.Router(mw) 275 316 } 276 317 277 318 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 278 - ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 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 + ) 279 327 return ls.Router(mw) 280 328 } 281 329 282 330 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 283 - notifs := notifications.New(s.db, s.oauth, s.pages) 331 + notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 284 332 return notifs.Router(mw) 285 333 } 286 334 287 335 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) 336 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 291 337 return sig.Router() 292 338 }
+3 -1
appview/state/spindlestream.go
··· 22 22 ) 23 23 24 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 + logger := log.FromContext(ctx) 26 + logger = log.SubLogger(logger, "spindlestream") 27 + 25 28 spindles, err := db.GetSpindles( 26 29 d, 27 30 db.FilterIsNot("verified", "null"), ··· 36 39 srcs[src] = struct{}{} 37 40 } 38 41 39 - logger := log.New("spindlestream") 40 42 cache := cache.New(c.Redis.Addr) 41 43 cursorStore := cursor.NewRedisCursorStore(cache) 42 44
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+81 -37
appview/state/state.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "strings" 12 11 "time" 13 12 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview" 22 - "tangled.org/core/appview/cache" 23 - "tangled.org/core/appview/cache/session" 24 15 "tangled.org/core/appview/config" 25 16 "tangled.org/core/appview/db" 26 17 "tangled.org/core/appview/models" ··· 35 26 "tangled.org/core/eventconsumer" 36 27 "tangled.org/core/idresolver" 37 28 "tangled.org/core/jetstream" 29 + "tangled.org/core/log" 38 30 tlog "tangled.org/core/log" 39 31 "tangled.org/core/rbac" 40 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 41 ) 42 42 43 43 type State struct { ··· 46 46 oauth *oauth.OAuth 47 47 enforcer *rbac.Enforcer 48 48 pages *pages.Pages 49 - sess *session.SessionStore 50 49 idResolver *idresolver.Resolver 51 50 posthog posthog.Client 52 51 jc *jetstream.JetstreamClient ··· 59 58 } 60 59 61 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 62 - d, err := db.Make(config.Core.DbPath) 61 + logger := tlog.FromContext(ctx) 62 + 63 + d, err := db.Make(ctx, config.Core.DbPath) 63 64 if err != nil { 64 65 return nil, fmt.Errorf("failed to create db: %w", err) 65 66 } ··· 71 72 72 73 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 73 74 if err != nil { 74 - log.Printf("failed to create redis resolver: %v", err) 75 + logger.Error("failed to create redis resolver", "err", err) 75 76 res = idresolver.DefaultResolver() 76 77 } 77 78 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 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 85 80 if err != nil { 86 81 return nil, fmt.Errorf("failed to create posthog client: %w", err) 87 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) 88 90 89 91 repoResolver := reporesolver.New(config, enforcer, res, d) 90 92 ··· 107 109 tangled.LabelOpNSID, 108 110 }, 109 111 nil, 110 - slog.Default(), 112 + tlog.SubLogger(logger, "jetstream"), 111 113 wrapper, 112 114 false, 113 115 ··· 128 130 Enforcer: enforcer, 129 131 IdResolver: res, 130 132 Config: config, 131 - Logger: tlog.New("ingester"), 133 + Logger: log.SubLogger(logger, "ingester"), 132 134 Validator: validator, 133 135 } 134 136 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 164 166 notifier, 165 167 oauth, 166 168 enforcer, 167 - pgs, 168 - sess, 169 + pages, 169 170 res, 170 171 posthog, 171 172 jc, ··· 173 174 repoResolver, 174 175 knotstream, 175 176 spindlestream, 176 - slog.Default(), 177 + logger, 177 178 validator, 178 179 } 179 180 ··· 198 199 s.pages.Favicon(w) 199 200 } 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 + 201 235 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 202 236 user := s.oauth.GetUser(r) 203 237 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 230 264 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 231 265 user := s.oauth.GetUser(r) 232 266 267 + // TODO: set this flag based on the UI 268 + filtered := false 269 + 233 270 var userDid string 234 271 if user != nil { 235 272 userDid = user.Did 236 273 } 237 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 274 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 238 275 if err != nil { 239 - log.Println(err) 276 + s.logger.Error("failed to make timeline", "err", err) 240 277 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 241 278 } 242 279 243 280 repos, err := db.GetTopStarredReposLastWeek(s.db) 244 281 if err != nil { 245 - log.Println(err) 282 + s.logger.Error("failed to get top starred repos", "err", err) 246 283 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 247 284 return 248 285 } 249 286 287 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 288 + if err != nil { 289 + // non-fatal 290 + } 291 + 250 292 s.pages.Timeline(w, pages.TimelineParams{ 251 293 LoggedInUser: user, 252 294 Timeline: timeline, 253 295 Repos: repos, 296 + GfiLabel: gfiLabel, 254 297 }) 255 298 } 256 299 ··· 262 305 263 306 l := s.logger.With("handler", "UpgradeBanner") 264 307 l = l.With("did", user.Did) 265 - l = l.With("handle", user.Handle) 266 308 267 309 regs, err := db.GetRegistrations( 268 310 s.db, ··· 293 335 } 294 336 295 337 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 296 - timeline, err := db.MakeTimeline(s.db, 5, "") 338 + // TODO: set this flag based on the UI 339 + filtered := false 340 + 341 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 297 342 if err != nil { 298 - log.Println(err) 343 + s.logger.Error("failed to make timeline", "err", err) 299 344 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 300 345 return 301 346 } 302 347 303 348 repos, err := db.GetTopStarredReposLastWeek(s.db) 304 349 if err != nil { 305 - log.Println(err) 350 + s.logger.Error("failed to get top starred repos", "err", err) 306 351 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 307 352 return 308 353 } ··· 402 447 403 448 user := s.oauth.GetUser(r) 404 449 l = l.With("did", user.Did) 405 - l = l.With("handle", user.Handle) 406 450 407 451 // form validation 408 452 domain := r.FormValue("domain") ··· 466 510 } 467 511 record := repo.AsRecord() 468 512 469 - xrpcClient, err := s.oauth.AuthorizedClient(r) 513 + atpClient, err := s.oauth.AuthorizedClient(r) 470 514 if err != nil { 471 515 l.Info("PDS write failed", "err", err) 472 516 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 473 517 return 474 518 } 475 519 476 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 520 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 477 521 Collection: tangled.RepoNSID, 478 522 Repo: user.Did, 479 523 Rkey: rkey, ··· 505 549 rollback := func() { 506 550 err1 := tx.Rollback() 507 551 err2 := s.enforcer.E.LoadPolicy() 508 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 552 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 509 553 510 554 // ignore txn complete errors, this is okay 511 555 if errors.Is(err1, sql.ErrTxDone) { ··· 578 622 aturi = "" 579 623 580 624 s.notifier.NewRepo(r.Context(), repo) 581 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 625 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 582 626 } 583 627 } 584 628 585 629 // this is used to rollback changes made to the PDS 586 630 // 587 631 // it is a no-op if the provided ATURI is empty 588 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 632 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 589 633 if aturi == "" { 590 634 return nil 591 635 } ··· 596 640 repo := parsed.Authority().String() 597 641 rkey := parsed.RecordKey().String() 598 642 599 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 643 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 600 644 Collection: collection, 601 645 Repo: repo, 602 646 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 22 "github.com/bluesky-social/indigo/api/atproto" 23 23 "github.com/bluesky-social/indigo/atproto/identity" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 29 ) 28 30 29 31 type Strings struct { ··· 254 256 } 255 257 256 258 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 260 if err != nil { 259 261 fail("Failed to updated existing record.", err) 260 262 return 261 263 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 263 265 Collection: tangled.StringNSID, 264 266 Repo: entry.Did.String(), 265 267 Rkey: entry.Rkey, ··· 284 286 s.Notifier.EditString(r.Context(), &entry) 285 287 286 288 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 288 290 } 289 291 290 292 } ··· 336 338 return 337 339 } 338 340 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 340 342 Collection: tangled.StringNSID, 341 343 Repo: user.Did, 342 344 Rkey: string.Rkey, ··· 360 362 s.Notifier.NewString(r.Context(), &string) 361 363 362 364 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 364 366 } 365 367 } 366 368 ··· 403 405 404 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 407 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 407 409 } 408 410 409 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+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 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+14 -9
cmd/appview/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 - "log/slog" 7 5 "net/http" 8 6 "os" 9 7 10 8 "tangled.org/core/appview/config" 11 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 12 11 ) 13 12 14 13 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 18 17 19 18 c, err := config.LoadConfig(ctx) 20 19 if err != nil { 21 - log.Println("failed to load config", "error", err) 20 + logger.Error("failed to load config", "error", err) 22 21 return 23 22 } 24 23 25 24 state, err := state.Make(ctx, c) 26 25 defer func() { 27 - log.Println(state.Close()) 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 28 29 }() 29 30 30 31 if err != nil { 31 - log.Fatal(err) 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 32 34 } 33 35 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 36 41 }
+62
cmd/cborgen/cborgen.go
··· 1 + package main 2 + 3 + import ( 4 + cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.org/core/api/tangled" 6 + ) 7 + 8 + func main() { 9 + 10 + genCfg := cbg.Gen{ 11 + MaxStringLength: 1_000_000, 12 + } 13 + 14 + if err := genCfg.WriteMapEncodersToFile( 15 + "api/tangled/cbor_gen.go", 16 + "tangled", 17 + tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 19 + tangled.FeedStar{}, 20 + tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 + tangled.GitRefUpdate_Meta{}, 26 + tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 + tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 33 + tangled.Pipeline{}, 34 + tangled.Pipeline_CloneOpts{}, 35 + tangled.Pipeline_ManualTriggerData{}, 36 + tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_PullRequestTriggerData{}, 38 + tangled.Pipeline_PushTriggerData{}, 39 + tangled.PipelineStatus{}, 40 + tangled.Pipeline_TriggerMetadata{}, 41 + tangled.Pipeline_TriggerRepo{}, 42 + tangled.Pipeline_Workflow{}, 43 + tangled.PublicKey{}, 44 + tangled.Repo{}, 45 + tangled.RepoArtifact{}, 46 + tangled.RepoCollaborator{}, 47 + tangled.RepoIssue{}, 48 + tangled.RepoIssueComment{}, 49 + tangled.RepoIssueState{}, 50 + tangled.RepoPull{}, 51 + tangled.RepoPullComment{}, 52 + tangled.RepoPull_Source{}, 53 + tangled.RepoPullStatus{}, 54 + tangled.RepoPull_Target{}, 55 + tangled.Spindle{}, 56 + tangled.SpindleMember{}, 57 + tangled.String{}, 58 + ); err != nil { 59 + panic(err) 60 + } 61 + 62 + }
-62
cmd/gen.go
··· 1 - package main 2 - 3 - import ( 4 - cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.org/core/api/tangled" 6 - ) 7 - 8 - func main() { 9 - 10 - genCfg := cbg.Gen{ 11 - MaxStringLength: 1_000_000, 12 - } 13 - 14 - if err := genCfg.WriteMapEncodersToFile( 15 - "api/tangled/cbor_gen.go", 16 - "tangled", 17 - tangled.ActorProfile{}, 18 - tangled.FeedReaction{}, 19 - tangled.FeedStar{}, 20 - tangled.GitRefUpdate{}, 21 - tangled.GitRefUpdate_CommitCountBreakdown{}, 22 - tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_IndividualLanguageSize{}, 24 - tangled.GitRefUpdate_LangBreakdown{}, 25 - tangled.GitRefUpdate_Meta{}, 26 - tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 - tangled.KnotMember{}, 29 - tangled.LabelDefinition{}, 30 - tangled.LabelDefinition_ValueType{}, 31 - tangled.LabelOp{}, 32 - tangled.LabelOp_Operand{}, 33 - tangled.Pipeline{}, 34 - tangled.Pipeline_CloneOpts{}, 35 - tangled.Pipeline_ManualTriggerData{}, 36 - tangled.Pipeline_Pair{}, 37 - tangled.Pipeline_PullRequestTriggerData{}, 38 - tangled.Pipeline_PushTriggerData{}, 39 - tangled.PipelineStatus{}, 40 - tangled.Pipeline_TriggerMetadata{}, 41 - tangled.Pipeline_TriggerRepo{}, 42 - tangled.Pipeline_Workflow{}, 43 - tangled.PublicKey{}, 44 - tangled.Repo{}, 45 - tangled.RepoArtifact{}, 46 - tangled.RepoCollaborator{}, 47 - tangled.RepoIssue{}, 48 - tangled.RepoIssueComment{}, 49 - tangled.RepoIssueState{}, 50 - tangled.RepoPull{}, 51 - tangled.RepoPullComment{}, 52 - tangled.RepoPull_Source{}, 53 - tangled.RepoPullStatus{}, 54 - tangled.RepoPull_Target{}, 55 - tangled.Spindle{}, 56 - tangled.SpindleMember{}, 57 - tangled.String{}, 58 - ); err != nil { 59 - panic(err) 60 - } 61 - 62 - }
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+6 -3
cmd/knot/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 8 "github.com/urfave/cli/v3" ··· 9 10 "tangled.org/core/hook" 10 11 "tangled.org/core/keyfetch" 11 12 "tangled.org/core/knotserver" 12 - "tangled.org/core/log" 13 + tlog "tangled.org/core/log" 13 14 ) 14 15 15 16 func main() { ··· 24 25 }, 25 26 } 26 27 28 + logger := tlog.New("knot") 29 + slog.SetDefault(logger) 30 + 27 31 ctx := context.Background() 28 - logger := log.New("knot") 29 - ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 32 + ctx = tlog.IntoContext(ctx, logger) 30 33 31 34 if err := cmd.Run(ctx, os.Args); err != nil { 32 35 logger.Error(err.Error())
-49
cmd/punchcardPopulate/main.go
··· 1 - package main 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "log" 7 - "math/rand" 8 - "time" 9 - 10 - _ "github.com/mattn/go-sqlite3" 11 - ) 12 - 13 - func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 - if err != nil { 16 - log.Fatal("Failed to open database:", err) 17 - } 18 - defer db.Close() 19 - 20 - const did = "did:plc:qfpnj4og54vl56wngdriaxug" 21 - 22 - now := time.Now() 23 - start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 24 - 25 - tx, err := db.Begin() 26 - if err != nil { 27 - log.Fatal(err) 28 - } 29 - stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)") 30 - if err != nil { 31 - log.Fatal(err) 32 - } 33 - defer stmt.Close() 34 - 35 - for day := start; !day.After(now); day = day.AddDate(0, 0, 1) { 36 - count := rand.Intn(16) // 0–5 37 - dateStr := day.Format("2006-01-02") 38 - _, err := stmt.Exec(did, dateStr, count) 39 - if err != nil { 40 - log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 - } 42 - } 43 - 44 - if err := tx.Commit(); err != nil { 45 - log.Fatal("Failed to commit:", err) 46 - } 47 - 48 - fmt.Println("Done populating punchcard.") 49 - }
+9 -4
cmd/spindle/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 - "tangled.org/core/log" 8 + tlog "tangled.org/core/log" 8 9 "tangled.org/core/spindle" 9 - _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() { 13 - ctx := log.NewContext(context.Background(), "spindle") 13 + logger := tlog.New("spindle") 14 + slog.SetDefault(logger) 15 + 16 + ctx := context.Background() 17 + ctx = tlog.IntoContext(ctx, logger) 18 + 14 19 err := spindle.Run(ctx) 15 20 if err != nil { 16 - log.FromContext(ctx).Error("error running spindle", "error", err) 21 + logger.Error("error running spindle", "error", err) 17 22 os.Exit(-1) 18 23 } 19 24 }
+2 -1
docs/knot-hosting.md
··· 39 39 ``` 40 40 41 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/knot` is a good choice: 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 43 44 44 ``` 45 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 46 47 ``` 47 48 48 49 This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 25 26 26 ```yaml 27 27 when:
+1 -1
flake.nix
··· 262 262 lexgen --build-file lexicon-build-config.json lexicons 263 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 265 + go run ./cmd/cborgen/ 266 266 lexgen --build-file lexicon-build-config.json lexicons 267 267 rm api/tangled/*.bak 268 268 '';
+23 -6
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 21 github.com/go-chi/chi/v5 v5.2.0 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 24 25 github.com/google/uuid v1.6.0 25 26 github.com/gorilla/feeds v1.2.0 26 27 github.com/gorilla/sessions v1.4.0 ··· 36 37 github.com/redis/go-redis/v9 v9.7.3 37 38 github.com/resend/resend-go/v2 v2.15.0 38 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 39 42 github.com/stretchr/testify v1.10.0 40 43 github.com/urfave/cli/v3 v3.3.3 41 44 github.com/whyrusleeping/cbor-gen v0.3.1 42 45 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 46 + github.com/yuin/goldmark v1.7.13 44 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 48 golang.org/x/crypto v0.40.0 49 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 46 51 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 52 + golang.org/x/sync v0.17.0 48 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 54 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 55 ) 52 56 53 57 require ( ··· 56 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 57 61 github.com/alecthomas/repr v0.4.0 // indirect 58 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 59 64 github.com/aymerick/douceur v0.2.0 // indirect 60 65 github.com/beorn7/perks v1.0.1 // indirect 61 66 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 62 67 github.com/casbin/govaluate v1.3.0 // indirect 63 68 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 64 69 github.com/cespare/xxhash/v2 v2.3.0 // indirect 70 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 71 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 72 + github.com/charmbracelet/log v0.4.2 // indirect 73 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 74 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 75 + github.com/charmbracelet/x/term v0.2.1 // indirect 65 76 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 66 77 github.com/containerd/errdefs v1.0.0 // indirect 67 78 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 80 91 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 81 92 github.com/go-git/go-billy/v5 v5.6.2 // indirect 82 93 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 94 + github.com/go-logfmt/logfmt v0.6.0 // indirect 83 95 github.com/go-logr/logr v1.4.3 // indirect 84 96 github.com/go-logr/stdr v1.2.2 // indirect 85 97 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 122 134 github.com/lestrrat-go/httprc v1.0.6 // indirect 123 135 github.com/lestrrat-go/iter v1.0.2 // indirect 124 136 github.com/lestrrat-go/option v1.0.1 // indirect 137 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 125 138 github.com/mattn/go-isatty v0.0.20 // indirect 139 + github.com/mattn/go-runewidth v0.0.16 // indirect 126 140 github.com/minio/sha256-simd v1.0.1 // indirect 127 141 github.com/mitchellh/mapstructure v1.5.0 // indirect 128 142 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 130 144 github.com/moby/term v0.5.2 // indirect 131 145 github.com/morikuni/aec v1.0.0 // indirect 132 146 github.com/mr-tron/base58 v1.2.0 // indirect 147 + github.com/muesli/termenv v0.16.0 // indirect 133 148 github.com/multiformats/go-base32 v0.1.0 // indirect 134 149 github.com/multiformats/go-base36 v0.2.0 // indirect 135 150 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 148 163 github.com/prometheus/client_model v0.6.2 // indirect 149 164 github.com/prometheus/common v0.64.0 // indirect 150 165 github.com/prometheus/procfs v0.16.1 // indirect 166 + github.com/rivo/uniseg v0.4.7 // indirect 151 167 github.com/ryanuber/go-glob v1.0.0 // indirect 152 168 github.com/segmentio/asm v1.2.0 // indirect 153 169 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 156 172 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 173 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 174 github.com/wyatt915/treeblood v0.1.15 // indirect 175 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 176 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 159 177 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 178 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 179 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 168 186 go.uber.org/atomic v1.11.0 // indirect 169 187 go.uber.org/multierr v1.11.0 // indirect 170 188 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 189 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 190 + golang.org/x/text v0.29.0 // indirect 174 191 golang.org/x/time v0.12.0 // indirect 175 192 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 193 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+45 -12
go.sum
··· 19 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 23 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 22 24 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 25 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 26 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 27 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 48 50 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 51 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 52 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 53 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 54 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 55 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 56 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 57 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 58 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 59 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 60 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 61 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 62 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 63 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 64 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 51 65 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 66 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 67 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 120 134 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 135 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 136 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 137 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 138 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 123 139 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 140 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 141 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 136 152 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 153 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 154 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 155 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 156 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 139 157 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 158 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 159 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 243 261 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 262 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 263 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 246 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 247 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 248 264 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 265 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 266 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 276 292 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 293 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 294 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 295 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 296 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 279 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 298 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 299 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 300 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 301 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 302 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 283 303 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 304 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 305 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 300 320 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 321 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 322 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 323 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 324 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 303 325 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 326 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 327 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 399 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 400 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 401 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 402 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 403 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 404 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 380 405 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 406 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 407 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 399 424 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 425 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 426 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 427 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 428 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 429 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 430 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 402 431 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 432 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 433 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 430 459 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 460 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 461 github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 462 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 433 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 465 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 466 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 467 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 468 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 469 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 470 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 471 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 472 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 473 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 474 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 475 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 443 476 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 477 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 478 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 489 522 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 523 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 524 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 525 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 526 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 492 527 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 528 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 529 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 563 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 564 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 565 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 531 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 566 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 567 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 533 568 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 569 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 570 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 618 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 619 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 620 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 586 - golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 - golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 621 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 622 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 588 623 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 624 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 625 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 652 687 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 688 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 689 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 690 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 691 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71 -12
input.css
··· 134 134 } 135 135 136 136 .prose hr { 137 - @apply my-2; 137 + @apply my-2; 138 138 } 139 139 140 140 .prose li:has(input) { 141 - @apply list-none; 141 + @apply list-none; 142 142 } 143 143 144 144 .prose ul:has(input) { 145 - @apply pl-2; 145 + @apply pl-2; 146 146 } 147 147 148 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 149 + @apply no-underline mx-2 opacity-0; 150 150 } 151 151 152 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 153 + @apply opacity-70; 154 154 } 155 155 156 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 157 + @apply opacity-70; 158 158 } 159 159 160 160 .prose a.footnote-backref { 161 - @apply no-underline; 161 + @apply no-underline; 162 162 } 163 163 164 164 .prose li { 165 - @apply my-0 py-0; 165 + @apply my-0 py-0; 166 166 } 167 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 170 171 } 171 172 172 173 .prose img { ··· 176 177 } 177 178 178 179 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 + @apply inline-block my-0 mb-1 mx-1; 180 181 } 181 182 182 183 .prose input[type="checkbox"] { 183 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 185 244 } 186 245 @layer utilities { 187 246 .error { ··· 228 287 } 229 288 /* LineHighlight */ 230 289 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 291 } 233 292 234 293 /* LineNumbersTable */
+1 -1
jetstream/jetstream.go
··· 114 114 115 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 116 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 118 if err != nil { 119 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 120 }
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
+2 -3
knotserver/events.go
··· 8 8 "time" 9 9 10 10 "github.com/gorilla/websocket" 11 + "tangled.org/core/log" 11 12 ) 12 13 13 14 var upgrader = websocket.Upgrader{ ··· 16 17 } 17 18 18 19 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 - l := h.l.With("handler", "OpLog") 20 + l := log.SubLogger(h.l, "eventstream") 20 21 l.Debug("received new connection") 21 22 22 23 conn, err := upgrader.Upgrade(w, r, nil) ··· 75 76 } 76 77 case <-time.After(30 * time.Second): 77 78 // send a keep-alive 78 - l.Debug("sent keepalive") 79 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 80 l.Error("failed to write control", "err", err) 81 81 } ··· 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 90 return err 91 91 } 92 - h.l.Debug("ops", "ops", events) 93 92 94 93 for _, event := range events { 95 94 // first extract the inner json into a map
+5
knotserver/git/branch.go
··· 110 110 slices.Reverse(branches) 111 111 return branches, nil 112 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+11
knotserver/git/git.go
··· 71 71 return &g, nil 72 72 } 73 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 + 74 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 75 86 commits := []*object.Commit{} 76 87
+21 -2
knotserver/git/last_commit.go
··· 30 30 commitCache = cache 31 31 } 32 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 33 + // processReader wraps a reader and ensures the associated process is cleaned up 34 + type processReader struct { 35 + io.Reader 36 + cmd *exec.Cmd 37 + stdout io.ReadCloser 38 + } 39 + 40 + func (pr *processReader) Close() error { 41 + if err := pr.stdout.Close(); err != nil { 42 + return err 43 + } 44 + return pr.cmd.Wait() 45 + } 46 + 47 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 34 48 args := []string{} 35 49 args = append(args, "log") 36 50 args = append(args, g.h.String()) ··· 48 62 return nil, err 49 63 } 50 64 51 - return stdout, nil 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 52 70 } 53 71 54 72 type commit struct { ··· 104 122 if err != nil { 105 123 return nil, err 106 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 107 126 108 127 reader := bufio.NewReader(output) 109 128 var current commit
+150 -37
knotserver/git/merge.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "fmt" 7 + "log" 7 8 "os" 8 9 "os/exec" 9 10 "regexp" ··· 12 13 "github.com/dgraph-io/ristretto" 13 14 "github.com/go-git/go-git/v5" 14 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 15 18 ) 16 19 17 20 type MergeCheckCache struct { ··· 32 35 mergeCheckCache = MergeCheckCache{cache} 33 36 } 34 37 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 36 39 sep := byte(':') 37 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 41 return fmt.Sprintf("%x", hash) ··· 49 52 } 50 53 } 51 54 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 53 56 key := m.cacheKey(g, patch, targetBranch) 54 57 val := m.cacheVal(mergeCheck) 55 58 m.cache.Set(key, val, 0) 56 59 } 57 60 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 59 62 key := m.cacheKey(g, patch, targetBranch) 60 63 if val, ok := m.cache.Get(key); ok { 61 64 if val == struct{}{} { ··· 104 107 return fmt.Sprintf("merge failed: %s", e.Message) 105 108 } 106 109 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 108 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 112 if err != nil { 110 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 114 } 112 115 113 - if _, err := tmpFile.Write(patchData); err != nil { 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 114 117 tmpFile.Close() 115 118 os.Remove(tmpFile.Name()) 116 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 165 return nil 163 166 } 164 167 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 166 169 var stderr bytes.Buffer 167 170 var cmd *exec.Cmd 168 171 169 172 // configure default git user before merge 170 - exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 - exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 173 176 174 177 // if patch is a format-patch, apply using 'git am' 175 178 if opts.FormatPatch { 176 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 - } else { 178 - // else, apply using 'git apply' and commit it manually 179 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 - applyCmd.Stderr = &stderr 181 - if err := applyCmd.Run(); err != nil { 182 - return fmt.Errorf("patch application failed: %s", stderr.String()) 183 - } 179 + return g.applyMailbox(patchData) 180 + } 184 181 185 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 - if err := stageCmd.Run(); err != nil { 187 - return fmt.Errorf("failed to stage changes: %w", err) 188 - } 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 189 188 190 - commitArgs := []string{"-C", tmpDir, "commit"} 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 191 193 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 194 + commitArgs := []string{"-C", g.path, "commit"} 195 195 196 - if authorName != "" && authorEmail != "" { 197 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 - } 199 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 200 199 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 202 204 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 206 207 - cmd = exec.Command("git", commitArgs...) 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 208 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 209 212 210 213 cmd.Stderr = &stderr 211 214 ··· 216 219 return nil 217 220 } 218 221 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 227 + 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 235 + } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 + } 239 + 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 + cmd.Stderr = &stderr 252 + 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 259 + if err := cmd.Run(); err != nil { 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 + } 321 + } 322 + 323 + // new hash of commit 324 + return newHash, nil 325 + } 326 + 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 220 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 329 return val 222 330 } ··· 244 352 return result 245 353 } 246 354 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 248 356 patchFile, err := g.createTempFileWithPatch(patchData) 249 357 if err != nil { 250 358 return &ErrMerge{ ··· 263 371 } 264 372 defer os.RemoveAll(tmpDir) 265 373 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 267 380 return err 268 381 } 269 382
+18 -18
knotserver/git.go
··· 13 13 "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) 20 20 if err != nil { 21 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 23 return 24 24 } 25 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 27 if err != nil { 28 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 30 return 31 31 } 32 32 ··· 46 46 47 47 if err := cmd.InfoRefs(); err != nil { 48 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 50 return 51 51 } 52 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 53 + h.RejectPush(w, r, name) 54 54 default: 55 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 56 } 57 57 } 58 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 63 if err != nil { 64 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 66 return 67 67 } 68 68 ··· 77 77 gzipReader, err := gzip.NewReader(r.Body) 78 78 if err != nil { 79 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 81 return 82 82 } 83 83 defer gzipReader.Close() ··· 88 88 w.Header().Set("Connection", "Keep-Alive") 89 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 90 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 91 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 92 93 93 cmd := service.ServiceCommand{ 94 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 100 w.WriteHeader(http.StatusOK) 101 101 102 102 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 104 return 105 105 } 106 106 } 107 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 112 if err != nil { 113 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 115 return 116 116 } 117 117 118 - d.RejectPush(w, r, name) 118 + h.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 134 + hostname := h.c.Server.Hostname 135 135 if strings.Contains(hostname, ":") { 136 136 hostname = strings.Split(hostname, ":")[0] 137 137 }
+50 -1
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 16 17 "tangled.org/core/api/tangled" 17 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 18 20 "tangled.org/core/knotserver/config" 19 21 "tangled.org/core/knotserver/db" 20 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 21 24 "tangled.org/core/notifier" 22 25 "tangled.org/core/rbac" 23 26 "tangled.org/core/workflow" ··· 118 121 // non-fatal 119 122 } 120 123 124 + if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 125 + msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 126 + if err != nil { 127 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 128 + // non-fatal 129 + } else { 130 + for msgLine := range msg { 131 + resp.Messages = append(resp.Messages, msg[msgLine]) 132 + } 133 + } 134 + } 135 + 121 136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 137 if err != nil { 123 138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 128 143 writeJSON(w, resp) 129 144 } 130 145 146 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 + l := h.l.With("handler", "replyCompare") 148 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 + user := repoOwner 150 + if err != nil { 151 + l.Error("Failed to fetch user identity", "err", err) 152 + // non-fatal 153 + } else { 154 + user = userIdent.Handle.String() 155 + } 156 + gr, err := git.PlainOpen(gitRelativeDir) 157 + if err != nil { 158 + l.Error("Failed to open git repository", "err", err) 159 + return []string{}, err 160 + } 161 + defaultBranch, err := gr.FindMainBranch() 162 + if err != nil { 163 + l.Error("Failed to fetch default branch", "err", err) 164 + return []string{}, err 165 + } 166 + if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 167 + return []string{}, nil 168 + } 169 + ZWS := "\u200B" 170 + var msg []string 171 + msg = append(msg, ZWS) 172 + msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 173 + msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 174 + msg = append(msg, ZWS) 175 + return msg, nil 176 + } 177 + 131 178 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 132 179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 133 180 if err != nil { ··· 268 315 return h.db.InsertEvent(event, h.n) 269 316 } 270 317 271 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 318 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 272 319 r := chi.NewRouter() 320 + l := log.FromContext(ctx) 321 + l = log.SubLogger(l, "internal") 273 322 274 323 h := InternalHandle{ 275 324 db,
+35
knotserver/middleware.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (h *Knot) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + h.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+16 -9
knotserver/router.go
··· 12 12 "tangled.org/core/knotserver/config" 13 13 "tangled.org/core/knotserver/db" 14 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 15 + "tangled.org/core/log" 16 16 "tangled.org/core/notifier" 17 17 "tangled.org/core/rbac" 18 18 "tangled.org/core/xrpc/serviceauth" ··· 28 28 resolver *idresolver.Resolver 29 29 } 30 30 31 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 - r := chi.NewRouter() 33 - 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 34 32 h := Knot{ 35 33 c: c, 36 34 db: db, 37 35 e: e, 38 - l: l, 36 + l: log.FromContext(ctx), 39 37 jc: jc, 40 38 n: n, 41 39 resolver: idresolver.DefaultResolver(), ··· 67 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 66 } 69 67 68 + return h.Router(), nil 69 + } 70 + 71 + func (h *Knot) Router() http.Handler { 72 + r := chi.NewRouter() 73 + 74 + r.Use(h.RequestLogger) 75 + 70 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 77 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 78 }) ··· 86 92 // Socket that streams git oplogs 87 93 r.Get("/events", h.Events) 88 94 89 - return r, nil 95 + return r 90 96 } 91 97 92 98 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 99 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 94 100 95 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 101 + l := log.SubLogger(h.l, "xrpc") 96 102 97 103 xrpc := &xrpc.Xrpc{ 98 104 Config: h.c, 99 105 Db: h.db, 100 106 Ingester: h.jc, 101 107 Enforcer: h.e, 102 - Logger: logger, 108 + Logger: l, 103 109 Notifier: h.n, 104 110 Resolver: h.resolver, 105 111 ServiceAuth: serviceAuth, 106 112 } 113 + 107 114 return xrpc.Router() 108 115 } 109 116
+5 -4
knotserver/server.go
··· 43 43 44 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 47 48 48 49 c, err := config.Load(ctx) 49 50 if err != nil { ··· 80 81 tangled.KnotMemberNSID, 81 82 tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil { 85 86 logger.Error("failed to setup jetstream", "error", err) 86 87 } 87 88 88 89 notifier := notifier.New() 89 90 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 91 92 if err != nil { 92 93 return fmt.Errorf("failed to setup server: %w", err) 93 94 } 94 95 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 + imux := Internal(ctx, c, db, e, &notifier) 96 97 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 99 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+87
knotserver/xrpc/delete_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDeleteBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(xrpcerr.GenericError(err)) 76 + return 77 + } 78 + 79 + err = gr.DeleteBranch(data.Branch) 80 + if err != nil { 81 + l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
+1 -1
knotserver/xrpc/merge.go
··· 85 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 87 88 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 89 if err != nil { 90 90 var mergeErr *git.ErrMerge 91 91 if errors.As(err, &mergeErr) {
+3 -1
knotserver/xrpc/merge_check.go
··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 55 56 56 response := tangled.RepoMergeCheck_Output{ 57 57 Is_conflicted: false, ··· 80 80 response.Error = &errMsg 81 81 } 82 82 } 83 + 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 83 85 84 86 w.Header().Set("Content-Type", "application/json") 85 87 w.WriteHeader(http.StatusOK)
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 8 "tangled.org/core/knotserver/git" 8 9 "tangled.org/core/types" 9 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 72 return 72 73 } 73 74 75 + var combinedPatch []*gitdiff.File 76 + var combinedPatchRaw string 77 + // we need the combined patch 78 + if len(formatPatch) >= 2 { 79 + diffTree, err := gr.DiffTree(commit1, commit2) 80 + if err != nil { 81 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 + } else { 83 + combinedPatch = diffTree.Diff 84 + combinedPatchRaw = diffTree.Patch 85 + } 86 + } 87 + 74 88 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 79 95 } 80 96 81 97 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 38 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 39 40 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 41 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+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 4 "context" 5 5 "log/slog" 6 6 "os" 7 + 8 + "github.com/charmbracelet/log" 7 9 ) 8 10 9 - // NewHandler sets up a new slog.Handler with the service name 10 - // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 - Level: slog.LevelDebug, 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 14 16 }) 15 - 16 - var attrs []slog.Attr 17 - attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)}) 18 - handler.WithAttrs(attrs) 19 - return handler 20 17 } 21 18 22 19 func New(name string) *slog.Logger { ··· 49 46 50 47 return slog.Default() 51 48 } 49 + 50 + // sublogger derives a new logger from an existing one by appending a suffix to its prefix. 51 + func SubLogger(base *slog.Logger, suffix string) *slog.Logger { 52 + // try to get the underlying charmbracelet logger 53 + if cl, ok := base.Handler().(*log.Logger); ok { 54 + prefix := cl.GetPrefix() 55 + if prefix != "" { 56 + prefix = prefix + "/" + suffix 57 + } else { 58 + prefix = suffix 59 + } 60 + return slog.New(NewHandler(prefix)) 61 + } 62 + 63 + // Fallback: no known handler type 64 + return slog.New(NewHandler(suffix)) 65 + }
+62 -11
nix/gomod2nix.toml
··· 29 29 [mod."github.com/avast/retry-go/v4"] 30 30 version = "v4.6.1" 31 31 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 + [mod."github.com/aymanbagabas/go-osc52/v2"] 33 + version = "v2.0.1" 34 + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 32 35 [mod."github.com/aymerick/douceur"] 33 36 version = "v0.2.0" 34 37 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" ··· 40 43 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 44 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 45 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 46 + version = "v0.0.0-20251003000214-3259b215110e" 47 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 48 [mod."github.com/bluesky-social/jetstream"] 46 49 version = "v0.0.0-20241210005130-ea96859b93d1" 47 50 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 63 66 [mod."github.com/cespare/xxhash/v2"] 64 67 version = "v2.3.0" 65 68 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 69 + [mod."github.com/charmbracelet/colorprofile"] 70 + version = "v0.2.3-0.20250311203215-f60798e515dc" 71 + hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" 72 + [mod."github.com/charmbracelet/lipgloss"] 73 + version = "v1.1.0" 74 + hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" 75 + [mod."github.com/charmbracelet/log"] 76 + version = "v0.4.2" 77 + hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=" 78 + [mod."github.com/charmbracelet/x/ansi"] 79 + version = "v0.8.0" 80 + hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas=" 81 + [mod."github.com/charmbracelet/x/cellbuf"] 82 + version = "v0.0.13-0.20250311204145-2c3ea96c31dd" 83 + hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" 84 + [mod."github.com/charmbracelet/x/term"] 85 + version = "v0.2.1" 86 + hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" 66 87 [mod."github.com/cloudflare/circl"] 67 88 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 89 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 145 166 [mod."github.com/go-jose/go-jose/v3"] 146 167 version = "v3.0.4" 147 168 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 169 + [mod."github.com/go-logfmt/logfmt"] 170 + version = "v0.6.0" 171 + hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 148 172 [mod."github.com/go-logr/logr"] 149 173 version = "v1.4.3" 150 174 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 163 187 [mod."github.com/gogo/protobuf"] 164 188 version = "v1.3.2" 165 189 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 190 + [mod."github.com/goki/freetype"] 191 + version = "v1.0.5" 192 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 166 193 [mod."github.com/golang-jwt/jwt/v5"] 167 194 version = "v5.2.3" 168 195 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 295 322 [mod."github.com/lestrrat-go/option"] 296 323 version = "v1.0.1" 297 324 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 325 + [mod."github.com/lucasb-eyer/go-colorful"] 326 + version = "v1.2.0" 327 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 298 328 [mod."github.com/mattn/go-isatty"] 299 329 version = "v0.0.20" 300 330 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 331 + [mod."github.com/mattn/go-runewidth"] 332 + version = "v0.0.16" 333 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 301 334 [mod."github.com/mattn/go-sqlite3"] 302 335 version = "v1.14.24" 303 336 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 325 358 [mod."github.com/mr-tron/base58"] 326 359 version = "v1.2.0" 327 360 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 361 + [mod."github.com/muesli/termenv"] 362 + version = "v0.16.0" 363 + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 328 364 [mod."github.com/multiformats/go-base32"] 329 365 version = "v0.1.0" 330 366 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 391 427 [mod."github.com/resend/resend-go/v2"] 392 428 version = "v2.15.0" 393 429 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 430 + [mod."github.com/rivo/uniseg"] 431 + version = "v0.4.7" 432 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 394 433 [mod."github.com/ryanuber/go-glob"] 395 434 version = "v1.0.0" 396 435 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 407 446 [mod."github.com/spaolacci/murmur3"] 408 447 version = "v1.1.0" 409 448 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 449 + [mod."github.com/srwiley/oksvg"] 450 + version = "v0.0.0-20221011165216-be6e8873101c" 451 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 452 + [mod."github.com/srwiley/rasterx"] 453 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 454 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 410 455 [mod."github.com/stretchr/testify"] 411 456 version = "v1.10.0" 412 457 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 431 476 [mod."github.com/wyatt915/treeblood"] 432 477 version = "v0.1.15" 433 478 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 479 + [mod."github.com/xo/terminfo"] 480 + version = "v0.0.0-20220910002029-abceb7e1c41e" 481 + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 434 482 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 483 + version = "v1.7.13" 484 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 437 485 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 486 version = "v2.0.0-20230729083705-37449abec8cc" 439 487 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 488 + [mod."gitlab.com/staticnoise/goldmark-callout"] 489 + version = "v0.0.0-20240609120641-6366b799e4ab" 490 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 440 491 [mod."gitlab.com/yawning/secp256k1-voi"] 441 492 version = "v0.0.0-20230925100816-f2616030848b" 442 493 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 479 530 [mod."golang.org/x/exp"] 480 531 version = "v0.0.0-20250620022241-b7579e27df2b" 481 532 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 533 + [mod."golang.org/x/image"] 534 + version = "v0.31.0" 535 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 482 536 [mod."golang.org/x/net"] 483 537 version = "v0.42.0" 484 538 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 539 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 540 + version = "v0.17.0" 541 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 488 542 [mod."golang.org/x/sys"] 489 543 version = "v0.34.0" 490 544 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 545 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 546 + version = "v0.29.0" 547 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 494 548 [mod."golang.org/x/time"] 495 549 version = "v0.12.0" 496 550 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 581 [mod."lukechampine.com/blake3"] 528 582 version = "v1.4.1" 529 583 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 - version = "v0.0.0-20250724194903-28e660378cb1" 532 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+2 -2
nix/modules/knot.nix
··· 22 22 23 23 appviewEndpoint = mkOption { 24 24 type = types.str; 25 - default = "https://tangled.sh"; 25 + default = "https://tangled.org"; 26 26 description = "Appview endpoint"; 27 27 }; 28 28 ··· 107 107 108 108 hostname = mkOption { 109 109 type = types.str; 110 - example = "knot.tangled.sh"; 110 + example = "my.knot.com"; 111 111 description = "Hostname for the server (required)"; 112 112 }; 113 113
+2 -2
nix/modules/spindle.nix
··· 33 33 34 34 hostname = mkOption { 35 35 type = types.str; 36 - example = "spindle.tangled.sh"; 36 + example = "my.spindle.com"; 37 37 description = "Hostname for the server (required)"; 38 38 }; 39 39 ··· 92 92 pipelines = { 93 93 nixery = mkOption { 94 94 type = types.str; 95 - default = "nixery.tangled.sh"; 95 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 96 description = "Nixery instance to use"; 97 97 }; 98 98
+1
nix/pkgs/appview-static-files.nix
··· 22 22 cp -rf ${lucide-src}/*.svg icons/ 23 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 25 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 28 # for whatever reason (produces broken css), so we are doing this instead
+18 -7
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "os" ··· 42 43 // IsPatchValid checks if the given patch string is valid. 43 44 // It performs very basic sniffing for either git-diff or git-format-patch 44 45 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 46 53 if len(patch) == 0 { 47 - return false 54 + return EmptyPatchError 48 55 } 49 56 50 57 lines := strings.Split(patch, "\n") 51 58 if len(lines) < 2 { 52 - return false 59 + return EmptyPatchError 53 60 } 54 61 55 62 firstLine := strings.TrimSpace(lines[0]) ··· 60 67 strings.HasPrefix(firstLine, "Index: ") || 61 68 strings.HasPrefix(firstLine, "+++ ") || 62 69 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 70 + return nil 64 71 } 65 72 66 73 // check if it's format-patch ··· 70 77 // it's safe to say it's broken. 71 78 patches, err := ExtractPatches(patch) 72 79 if err != nil { 73 - return false 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 74 81 } 75 - return len(patches) > 0 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 76 87 } 77 88 78 - return false 89 + return GenericPatchError 79 90 } 80 91 81 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "reflect" 5 6 "testing" 6 7 ) ··· 9 10 tests := []struct { 10 11 name string 11 12 patch string 12 - expected bool 13 + expected error 13 14 }{ 14 15 { 15 16 name: `empty patch`, 16 17 patch: ``, 17 - expected: false, 18 + expected: EmptyPatchError, 18 19 }, 19 20 { 20 21 name: `single line patch`, 21 22 patch: `single line`, 22 - expected: false, 23 + expected: EmptyPatchError, 23 24 }, 24 25 { 25 26 name: `valid diff patch`, ··· 31 32 -old line 32 33 +new line 33 34 context`, 34 - expected: true, 35 + expected: nil, 35 36 }, 36 37 { 37 38 name: `valid patch starting with ---`, ··· 41 42 -old line 42 43 +new line 43 44 context`, 44 - expected: true, 45 + expected: nil, 45 46 }, 46 47 { 47 48 name: `valid patch starting with Index`, ··· 53 54 -old line 54 55 +new line 55 56 context`, 56 - expected: true, 57 + expected: nil, 57 58 }, 58 59 { 59 60 name: `valid patch starting with +++`, ··· 63 64 -old line 64 65 +new line 65 66 context`, 66 - expected: true, 67 + expected: nil, 67 68 }, 68 69 { 69 70 name: `valid patch starting with @@`, ··· 72 73 +new line 73 74 context 74 75 `, 75 - expected: true, 76 + expected: nil, 76 77 }, 77 78 { 78 79 name: `valid format patch`, ··· 90 91 +new content 91 92 -- 92 93 2.48.1`, 93 - expected: true, 94 + expected: nil, 94 95 }, 95 96 { 96 97 name: `invalid format patch`, 97 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 99 From: Author <author@example.com> 99 100 This is not a valid patch format`, 100 - expected: false, 101 + expected: FormatPatchError, 101 102 }, 102 103 { 103 104 name: `not a patch at all`, ··· 105 106 just some 106 107 random text 107 108 that isn't a patch`, 108 - expected: false, 109 + expected: GenericPatchError, 109 110 }, 110 111 } 111 112 112 113 for _, tt := range tests { 113 114 t.Run(tt.name, func(t *testing.T) { 114 115 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 + if !errors.Is(result, tt.expected) { 116 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 118 } 118 119 })
+1 -1
spindle/engines/nixery/engine.go
··· 222 222 }, 223 223 ReadonlyRootfs: false, 224 224 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 226 226 SecurityOpt: []string{"no-new-privileges"}, 227 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 228 }, nil, nil, "")
+35
spindle/middleware.go
··· 1 + package spindle 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (s *Spindle) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + s.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+6 -6
spindle/server.go
··· 108 108 tangled.RepoNSID, 109 109 tangled.RepoCollaboratorNSID, 110 110 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 112 if err != nil { 113 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 114 } ··· 171 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 172 // job in the above registered queue. 173 173 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 174 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 175 ccfg.Dev = cfg.Server.Dev 176 176 ccfg.ProcessFunc = spindle.processPipeline 177 177 ccfg.CursorStore = cursorStore ··· 210 210 } 211 211 212 212 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 213 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 214 215 + l := log.SubLogger(s.l, "xrpc") 216 + 217 217 x := xrpc.Xrpc{ 218 - Logger: logger, 218 + Logger: l, 219 219 Db: s.db, 220 220 Enforcer: s.e, 221 221 Engines: s.engs, ··· 305 305 306 306 ok := s.jq.Enqueue(queue.Job{ 307 307 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 308 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 311 Workflows: workflows,
+3 -3
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 + "tangled.org/core/log" 13 14 "tangled.org/core/spindle/models" 14 15 15 16 "github.com/go-chi/chi/v5" ··· 23 24 } 24 25 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 + l := log.SubLogger(s.l, "eventstream") 28 + 27 29 l.Debug("received new connection") 28 30 29 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 84 } 83 85 case <-time.After(30 * time.Second): 84 86 // send a keep-alive 85 - l.Debug("sent keepalive") 86 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 88 l.Error("failed to write control", "err", err) 88 89 } ··· 222 223 s.l.Debug("err", "err", err) 223 224 return err 224 225 } 225 - s.l.Debug("ops", "ops", events) 226 226 227 227 for _, event := range events { 228 228 // first extract the inner json into a map
+7 -5
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 4 5 "github.com/go-git/go-git/v5/plumbing/object" 5 6 ) 6 7 ··· 33 34 } 34 35 35 36 type RepoFormatPatchResponse struct { 36 - Rev1 string `json:"rev1,omitempty"` 37 - Rev2 string `json:"rev2,omitempty"` 38 - FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 - MergeBase string `json:"merge_base,omitempty"` // deprecated 40 - Patch string `json:"patch,omitempty"` 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 40 + FormatPatchRaw string `json:"patch,omitempty"` 41 + CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 42 + CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 41 43 } 42 44 43 45 type RepoTreeResponse struct {
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 22 23 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 25 return &ServiceAuth{ 25 - logger: logger, 26 + logger: log.SubLogger(logger, "serviceauth"), 26 27 resolver: resolver, 27 28 audienceDid: audienceDid, 28 29 } ··· 30 31 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 34 token := r.Header.Get("Authorization") 36 35 token = strings.TrimPrefix(token, "Bearer ") 37 36 ··· 42 41 43 42 did, err := s.Validate(r.Context(), token, nil) 44 43 if err != nil { 45 - l.Error("signature verification failed", "err", err) 44 + sa.logger.Error("signature verification failed", "err", err) 46 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 46 return 48 47 } 48 + 49 + sa.logger.Debug("valid signature", ActorDid, did) 49 50 50 51 r = r.WithContext( 51 52 context.WithValue(r.Context(), ActorDid, did),