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

Compare changes

Choose any two refs to compare.

+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
-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
-20
appview/db/issues.go
··· 247 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 248 } 249 249 250 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 - query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 - row := e.QueryRow(query, repoAt, issueId) 253 - 254 - var issue models.Issue 255 - var createdAt string 256 - err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 - if err != nil { 258 - return nil, err 259 - } 260 - 261 - createdTime, err := time.Parse(time.RFC3339, createdAt) 262 - if err != nil { 263 - return nil, err 264 - } 265 - issue.Created = createdTime 266 - 267 - return &issue, nil 268 - } 269 - 270 250 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 251 result, err := e.Exec( 272 252 `insert into issue_comments (
+81 -45
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), ··· 274 275 return count, nil 275 276 } 276 277 277 - func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 278 279 idFilter := FilterEq("id", notificationID) 279 280 recipientFilter := FilterEq("recipient_did", userDID) 280 281 ··· 286 287 287 288 args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 289 289 - result, err := d.DB.ExecContext(ctx, query, args...) 290 + result, err := e.Exec(query, args...) 290 291 if err != nil { 291 292 return fmt.Errorf("failed to mark notification as read: %w", err) 292 293 } ··· 303 304 return nil 304 305 } 305 306 306 - func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 + func MarkAllNotificationsRead(e Execer, userDID string) error { 307 308 recipientFilter := FilterEq("recipient_did", userDID) 308 309 readFilter := FilterEq("read", 0) 309 310 ··· 315 316 316 317 args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 318 318 - _, err := d.DB.ExecContext(ctx, query, args...) 319 + _, err := e.Exec(query, args...) 319 320 if err != nil { 320 321 return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 322 } ··· 323 324 return nil 324 325 } 325 326 326 - func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 327 328 idFilter := FilterEq("id", notificationID) 328 329 recipientFilter := FilterEq("recipient_did", userDID) 329 330 ··· 334 335 335 336 args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 337 337 - result, err := d.DB.ExecContext(ctx, query, args...) 338 + result, err := e.Exec(query, args...) 338 339 if err != nil { 339 340 return fmt.Errorf("failed to delete notification: %w", err) 340 341 } ··· 351 352 return nil 352 353 } 353 354 354 - func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 - userFilter := FilterEq("user_did", userDID) 355 + func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 356 + prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 357 + if err != nil { 358 + return nil, err 359 + } 360 + 361 + p, ok := prefs[syntax.DID(userDid)] 362 + if !ok { 363 + return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 364 + } 365 + 366 + return p, nil 367 + } 368 + 369 + func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 370 + prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 371 + 372 + var conditions []string 373 + var args []any 374 + for _, filter := range filters { 375 + conditions = append(conditions, filter.Condition()) 376 + args = append(args, filter.Arg()...) 377 + } 378 + 379 + whereClause := "" 380 + if conditions != nil { 381 + whereClause = " where " + strings.Join(conditions, " and ") 382 + } 356 383 357 384 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()) 385 + select 386 + id, 387 + user_did, 388 + repo_starred, 389 + issue_created, 390 + issue_commented, 391 + pull_created, 392 + pull_commented, 393 + followed, 394 + pull_merged, 395 + issue_closed, 396 + email_notifications 397 + from 398 + notification_preferences 399 + %s 400 + `, whereClause) 363 401 364 - var prefs models.NotificationPreferences 365 - err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 - &prefs.ID, 367 - &prefs.UserDid, 368 - &prefs.RepoStarred, 369 - &prefs.IssueCreated, 370 - &prefs.IssueCommented, 371 - &prefs.PullCreated, 372 - &prefs.PullCommented, 373 - &prefs.Followed, 374 - &prefs.PullMerged, 375 - &prefs.IssueClosed, 376 - &prefs.EmailNotifications, 377 - ) 378 - 402 + rows, err := e.Query(query, args...) 379 403 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 404 + return nil, err 405 + } 406 + defer rows.Close() 407 + 408 + for rows.Next() { 409 + var prefs models.NotificationPreferences 410 + if err := rows.Scan( 411 + &prefs.ID, 412 + &prefs.UserDid, 413 + &prefs.RepoStarred, 414 + &prefs.IssueCreated, 415 + &prefs.IssueCommented, 416 + &prefs.PullCreated, 417 + &prefs.PullCommented, 418 + &prefs.Followed, 419 + &prefs.PullMerged, 420 + &prefs.IssueClosed, 421 + &prefs.EmailNotifications, 422 + ); err != nil { 423 + return nil, err 393 424 } 394 - return nil, fmt.Errorf("failed to get notification preferences: %w", err) 425 + 426 + prefsMap[prefs.UserDid] = &prefs 427 + } 428 + 429 + if err := rows.Err(); err != nil { 430 + return nil, err 395 431 } 396 432 397 - return &prefs, nil 433 + return prefsMap, nil 398 434 } 399 435 400 436 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
+21 -18
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 ··· 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 353 + } 354 + 355 + if submissionSourceRev.Valid { 356 + submission.SourceRev = submissionSourceRev.String 352 357 } 353 - submission.Created = createdTime 354 358 355 - if sourceRev.Valid { 356 - submission.SourceRev = sourceRev.String 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 }
+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
+28 -26
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" ··· 28 27 "tangled.org/core/appview/reporesolver" 29 28 "tangled.org/core/appview/validator" 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 ··· 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 } ··· 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 ··· 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 } ··· 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 } ··· 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 } ··· 561 560 // update the record on pds 562 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 } ··· 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 } ··· 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 ··· 771 772 772 773 page, ok := r.Context().Value("page").(pagination.Page) 773 774 if !ok { 774 - log.Println("failed to get page") 775 + l.Error("failed to get page") 775 776 page = pagination.FirstPage() 776 777 } 777 778 778 779 user := rp.oauth.GetUser(r) 779 780 f, err := rp.repoResolver.Resolve(r) 780 781 if err != nil { 781 - log.Println("failed to get repo and knot", err) 782 + l.Error("failed to get repo and knot", "err", err) 782 783 return 783 784 } 784 785 ··· 793 794 db.FilterEq("open", openVal), 794 795 ) 795 796 if err != nil { 796 - log.Println("failed to get issues", err) 797 + l.Error("failed to get issues", "err", err) 797 798 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 798 799 return 799 800 } ··· 804 805 db.FilterContains("scope", tangled.RepoIssueNSID), 805 806 ) 806 807 if err != nil { 807 - log.Println("failed to fetch labels", err) 808 + l.Error("failed to fetch labels", "err", err) 808 809 rp.pages.Error503(w) 809 810 return 810 811 } ··· 848 849 Body: r.FormValue("body"), 849 850 Did: user.Did, 850 851 Created: time.Now(), 852 + Repo: &f.Repo, 851 853 } 852 854 853 855 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 901 903 902 904 err = db.PutIssue(tx, issue) 903 905 if err != nil { 904 - log.Println("failed to create issue", err) 906 + l.Error("failed to create issue", "err", err) 905 907 rp.pages.Notice(w, "issues", "Failed to create issue.") 906 908 return 907 909 } 908 910 909 911 if err = tx.Commit(); err != nil { 910 - log.Println("failed to create issue", err) 912 + l.Error("failed to create issue", "err", err) 911 913 rp.pages.Notice(w, "issues", "Failed to create issue.") 912 914 return 913 915 }
+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) {
+1 -3
appview/labels/labels.go
··· 16 16 "tangled.org/core/appview/oauth" 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/validator" 19 - "tangled.org/core/log" 20 19 "tangled.org/core/rbac" 21 20 "tangled.org/core/tid" 22 21 ··· 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,
+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) {
+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 + }
+25 -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 { ··· 261 255 } 262 256 263 257 return participants 258 + } 259 + 260 + func (s PullSubmission) CombinedPatch() string { 261 + if s.Combined == "" { 262 + return s.Patch 263 + } 264 + 265 + return s.Combined 264 266 } 265 267 266 268 type Stack []*Pull
+19 -16
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "log" 4 + "log/slog" 5 5 "net/http" 6 6 "strconv" 7 7 ··· 14 14 ) 15 15 16 16 type Notifications struct { 17 - db *db.DB 18 - oauth *oauth.OAuth 19 - pages *pages.Pages 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + logger *slog.Logger 20 21 } 21 22 22 - 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 { 23 24 return &Notifications{ 24 - db: database, 25 - oauth: oauthHandler, 26 - pages: pagesHandler, 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + logger: logger, 27 29 } 28 30 } 29 31 ··· 44 46 } 45 47 46 48 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 49 + l := n.logger.With("handler", "notificationsPage") 47 50 user := n.oauth.GetUser(r) 48 51 49 52 page, ok := r.Context().Value("page").(pagination.Page) 50 53 if !ok { 51 - log.Println("failed to get page") 54 + l.Error("failed to get page") 52 55 page = pagination.FirstPage() 53 56 } 54 57 ··· 57 60 db.FilterEq("recipient_did", user.Did), 58 61 ) 59 62 if err != nil { 60 - log.Println("failed to get total notifications:", err) 63 + l.Error("failed to get total notifications", "err", err) 61 64 n.pages.Error500(w) 62 65 return 63 66 } ··· 68 71 db.FilterEq("recipient_did", user.Did), 69 72 ) 70 73 if err != nil { 71 - log.Println("failed to get notifications:", err) 74 + l.Error("failed to get notifications", "err", err) 72 75 n.pages.Error500(w) 73 76 return 74 77 } 75 78 76 - err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 79 + err = db.MarkAllNotificationsRead(n.db, user.Did) 77 80 if err != nil { 78 - log.Println("failed to mark notifications as read:", err) 81 + l.Error("failed to mark notifications as read", "err", err) 79 82 } 80 83 81 84 unreadCount := 0 ··· 125 128 return 126 129 } 127 130 128 - err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 131 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 129 132 if err != nil { 130 133 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 131 134 return ··· 137 140 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 138 141 userDid := n.oauth.GetDid(r) 139 142 140 - err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 143 + err := db.MarkAllNotificationsRead(n.db, userDid) 141 144 if err != nil { 142 145 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 143 146 return ··· 156 159 return 157 160 } 158 161 159 - err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 162 + err = db.DeleteNotification(n.db, notificationID, userDid) 160 163 if err != nil { 161 164 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 162 165 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 }
+212 -2
appview/oauth/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 - "log" 7 + "fmt" 6 8 "net/http" 9 + "slices" 10 + "time" 7 11 8 12 "github.com/go-chi/chi/v5" 9 13 "github.com/lestrrat-go/jwx/v2/jwk" 14 + "github.com/posthog/posthog-go" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/consts" 18 + "tangled.org/core/tid" 10 19 ) 11 20 12 21 func (o *OAuth) Router() http.Handler { ··· 33 42 jwks := o.Config.OAuth.Jwks 34 43 pubKey, err := pubKeyFromJwk(jwks) 35 44 if err != nil { 36 - log.Printf("error parsing public key: %v", err) 45 + o.Logger.Error("error parsing public key", "err", err) 37 46 http.Error(w, err.Error(), http.StatusInternalServerError) 38 47 return 39 48 } ··· 59 68 if err := o.SaveSession(w, r, sessData); err != nil { 60 69 http.Error(w, err.Error(), http.StatusInternalServerError) 61 70 return 71 + } 72 + 73 + o.Logger.Debug("session saved successfully") 74 + go o.addToDefaultKnot(sessData.AccountDID.String()) 75 + go o.addToDefaultSpindle(sessData.AccountDID.String()) 76 + 77 + if !o.Config.Core.Dev { 78 + err = o.Posthog.Enqueue(posthog.Capture{ 79 + DistinctId: sessData.AccountDID.String(), 80 + Event: "signin", 81 + }) 82 + if err != nil { 83 + o.Logger.Error("failed to enqueue posthog event", "err", err) 84 + } 62 85 } 63 86 64 87 http.Redirect(w, r, "/", http.StatusFound) 65 88 } 89 + 90 + func (o *OAuth) addToDefaultSpindle(did string) { 91 + l := o.Logger.With("subject", did) 92 + 93 + // use the tangled.sh app password to get an accessJwt 94 + // and create an sh.tangled.spindle.member record with that 95 + spindleMembers, err := db.GetSpindleMembers( 96 + o.Db, 97 + db.FilterEq("instance", "spindle.tangled.sh"), 98 + db.FilterEq("subject", did), 99 + ) 100 + if err != nil { 101 + l.Error("failed to get spindle members", "err", err) 102 + return 103 + } 104 + 105 + if len(spindleMembers) != 0 { 106 + l.Warn("already a member of the default spindle") 107 + return 108 + } 109 + 110 + l.Debug("adding to default spindle") 111 + session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 112 + if err != nil { 113 + l.Error("failed to create session", "err", err) 114 + return 115 + } 116 + 117 + record := tangled.SpindleMember{ 118 + LexiconTypeID: "sh.tangled.spindle.member", 119 + Subject: did, 120 + Instance: consts.DefaultSpindle, 121 + CreatedAt: time.Now().Format(time.RFC3339), 122 + } 123 + 124 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 125 + l.Error("failed to add to default spindle", "err", err) 126 + return 127 + } 128 + 129 + l.Debug("successfully added to default spindle", "did", did) 130 + } 131 + 132 + func (o *OAuth) addToDefaultKnot(did string) { 133 + l := o.Logger.With("subject", did) 134 + 135 + // use the tangled.sh app password to get an accessJwt 136 + // and create an sh.tangled.spindle.member record with that 137 + 138 + allKnots, err := o.Enforcer.GetKnotsForUser(did) 139 + if err != nil { 140 + l.Error("failed to get knot members for did", "err", err) 141 + return 142 + } 143 + 144 + if slices.Contains(allKnots, consts.DefaultKnot) { 145 + l.Warn("already a member of the default knot") 146 + return 147 + } 148 + 149 + l.Debug("addings to default knot") 150 + session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 151 + if err != nil { 152 + l.Error("failed to create session", "err", err) 153 + return 154 + } 155 + 156 + record := tangled.KnotMember{ 157 + LexiconTypeID: "sh.tangled.knot.member", 158 + Subject: did, 159 + Domain: consts.DefaultKnot, 160 + CreatedAt: time.Now().Format(time.RFC3339), 161 + } 162 + 163 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 164 + l.Error("failed to add to default knot", "err", err) 165 + return 166 + } 167 + 168 + if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 169 + l.Error("failed to set up enforcer rules", "err", err) 170 + return 171 + } 172 + 173 + l.Debug("successfully addeds to default Knot") 174 + } 175 + 176 + // create a session using apppasswords 177 + type session struct { 178 + AccessJwt string `json:"accessJwt"` 179 + PdsEndpoint string 180 + Did string 181 + } 182 + 183 + func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 184 + if appPassword == "" { 185 + return nil, fmt.Errorf("no app password configured, skipping member addition") 186 + } 187 + 188 + resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 189 + if err != nil { 190 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 191 + } 192 + 193 + pdsEndpoint := resolved.PDSEndpoint() 194 + if pdsEndpoint == "" { 195 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 196 + } 197 + 198 + sessionPayload := map[string]string{ 199 + "identifier": did, 200 + "password": appPassword, 201 + } 202 + sessionBytes, err := json.Marshal(sessionPayload) 203 + if err != nil { 204 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 205 + } 206 + 207 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 208 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 209 + if err != nil { 210 + return nil, fmt.Errorf("failed to create session request: %v", err) 211 + } 212 + sessionReq.Header.Set("Content-Type", "application/json") 213 + 214 + client := &http.Client{Timeout: 30 * time.Second} 215 + sessionResp, err := client.Do(sessionReq) 216 + if err != nil { 217 + return nil, fmt.Errorf("failed to create session: %v", err) 218 + } 219 + defer sessionResp.Body.Close() 220 + 221 + if sessionResp.StatusCode != http.StatusOK { 222 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 223 + } 224 + 225 + var session session 226 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 227 + return nil, fmt.Errorf("failed to decode session response: %v", err) 228 + } 229 + 230 + session.PdsEndpoint = pdsEndpoint 231 + session.Did = did 232 + 233 + return &session, nil 234 + } 235 + 236 + func (s *session) putRecord(record any, collection string) error { 237 + recordBytes, err := json.Marshal(record) 238 + if err != nil { 239 + return fmt.Errorf("failed to marshal knot member record: %w", err) 240 + } 241 + 242 + payload := map[string]any{ 243 + "repo": s.Did, 244 + "collection": collection, 245 + "rkey": tid.TID(), 246 + "record": json.RawMessage(recordBytes), 247 + } 248 + 249 + payloadBytes, err := json.Marshal(payload) 250 + if err != nil { 251 + return fmt.Errorf("failed to marshal request payload: %w", err) 252 + } 253 + 254 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 255 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 256 + if err != nil { 257 + return fmt.Errorf("failed to create HTTP request: %w", err) 258 + } 259 + 260 + req.Header.Set("Content-Type", "application/json") 261 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 262 + 263 + client := &http.Client{Timeout: 30 * time.Second} 264 + resp, err := client.Do(req) 265 + if err != nil { 266 + return fmt.Errorf("failed to add user to default service: %w", err) 267 + } 268 + defer resp.Body.Close() 269 + 270 + if resp.StatusCode != http.StatusOK { 271 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 272 + } 273 + 274 + return nil 275 + }
+27 -12
appview/oauth/oauth.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "log/slog" 6 7 "net/http" 7 8 "time" 8 9 ··· 13 14 xrpc "github.com/bluesky-social/indigo/xrpc" 14 15 "github.com/gorilla/sessions" 15 16 "github.com/lestrrat-go/jwx/v2/jwk" 17 + "github.com/posthog/posthog-go" 16 18 "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 17 22 ) 18 23 19 - func New(config *config.Config) (*OAuth, error) { 24 + type OAuth struct { 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + Posthog posthog.Client 30 + Db *db.DB 31 + Enforcer *rbac.Enforcer 32 + IdResolver *idresolver.Resolver 33 + Logger *slog.Logger 34 + } 35 + 36 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 20 37 21 38 var oauthConfig oauth.ClientConfig 22 39 var clientUri string ··· 42 59 sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 60 44 61 return &OAuth{ 45 - ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 - Config: config, 47 - SessStore: sessStore, 48 - JwksUri: jwksUri, 62 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 63 + Config: config, 64 + SessStore: sessStore, 65 + JwksUri: jwksUri, 66 + Posthog: ph, 67 + Db: db, 68 + Enforcer: enforcer, 69 + IdResolver: res, 70 + Logger: logger, 49 71 }, nil 50 - } 51 - 52 - type OAuth struct { 53 - ClientApp *oauth.ClientApp 54 - SessStore *sessions.CookieStore 55 - Config *config.Config 56 - JwksUri string 57 72 } 58 73 59 74 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
+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 + }
+3 -2
appview/pages/funcmap.go
··· 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)
+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 {
+2 -2
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, ··· 72 72 rctx: rctx, 73 73 resolver: res, 74 74 templateDir: "appview/pages", 75 - logger: slog.Default().With("component", "pages"), 75 + logger: logger, 76 76 } 77 77 78 78 if p.dev {
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 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">
+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>
+1 -1
appview/pages/templates/repo/fragments/og.html
··· 11 11 <meta property="og:image" content="{{ $imageUrl }}" /> 12 12 <meta property="og:image:width" content="1200" /> 13 13 <meta property="og:image:height" content="600" /> 14 - 14 + 15 15 <meta name="twitter:card" content="summary_large_image" /> 16 16 <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 17 <meta name="twitter:description" content="{{ $description }}" />
+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"
+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 }}
+3 -6
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">
+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 -9
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>
+1 -4
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" }}
+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/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">
+3
appview/pages/templates/user/login.html
··· 29 29 <div class="flex flex-col"> 30 30 <label for="handle">handle</label> 31 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 32 35 type="text" 33 36 id="handle" 34 37 name="handle"
+1 -3
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 49 return &Pipelines{ 52 50 oauth: oauth, 53 51 repoResolver: repoResolver,
+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 + }
+89 -160
appview/pulls/pulls.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "log" 9 + "log/slog" 9 10 "net/http" 10 11 "slices" 11 12 "sort" ··· 22 23 "tangled.org/core/appview/pages" 23 24 "tangled.org/core/appview/pages/markup" 24 25 "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/validator" 25 27 "tangled.org/core/appview/xrpcclient" 26 28 "tangled.org/core/idresolver" 27 29 "tangled.org/core/patchutil" ··· 29 31 "tangled.org/core/tid" 30 32 "tangled.org/core/types" 31 33 32 - "github.com/bluekeyes/go-gitdiff/gitdiff" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 34 35 lexutil "github.com/bluesky-social/indigo/lex/util" 35 36 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 46 47 config *config.Config 47 48 notifier notify.Notifier 48 49 enforcer *rbac.Enforcer 50 + logger *slog.Logger 51 + validator *validator.Validator 49 52 } 50 53 51 54 func New( ··· 57 60 config *config.Config, 58 61 notifier notify.Notifier, 59 62 enforcer *rbac.Enforcer, 63 + validator *validator.Validator, 64 + logger *slog.Logger, 60 65 ) *Pulls { 61 66 return &Pulls{ 62 67 oauth: oauth, ··· 67 72 config: config, 68 73 notifier: notifier, 69 74 enforcer: enforcer, 75 + logger: logger, 76 + validator: validator, 70 77 } 71 78 } 72 79 ··· 141 148 // can be nil if this pull is not stacked 142 149 stack, _ := r.Context().Value("stack").(models.Stack) 143 150 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 144 - 145 - totalIdents := 1 146 - for _, submission := range pull.Submissions { 147 - totalIdents += len(submission.Comments) 148 - } 149 - 150 - identsToResolve := make([]string, totalIdents) 151 - 152 - // populate idents 153 - identsToResolve[0] = pull.OwnerDid 154 - idx := 1 155 - for _, submission := range pull.Submissions { 156 - for _, comment := range submission.Comments { 157 - identsToResolve[idx] = comment.OwnerDid 158 - idx += 1 159 - } 160 - } 161 151 162 152 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 163 153 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) ··· 412 402 413 403 targetBranch := branchResp 414 404 415 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 405 + latestSourceRev := pull.LatestSha() 416 406 417 407 if pull.IsStacked() && stack != nil { 418 408 top := stack[0] 419 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 409 + latestSourceRev = top.LatestSha() 420 410 } 421 411 422 412 if latestSourceRev != targetBranch.Hash { ··· 456 446 return 457 447 } 458 448 459 - patch := pull.Submissions[roundIdInt].Patch 449 + patch := pull.Submissions[roundIdInt].CombinedPatch() 460 450 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 461 451 462 452 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 507 497 return 508 498 } 509 499 510 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 500 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 511 501 if err != nil { 512 502 log.Println("failed to interdiff; current patch malformed") 513 503 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 514 504 return 515 505 } 516 506 517 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 507 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 518 508 if err != nil { 519 509 log.Println("failed to interdiff; previous patch malformed") 520 510 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 716 706 717 707 createdAt := time.Now().Format(time.RFC3339) 718 708 719 - pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 720 - if err != nil { 721 - log.Println("failed to get pull at", err) 722 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 723 - return 724 - } 725 - 726 709 client, err := s.oauth.AuthorizedClient(r) 727 710 if err != nil { 728 711 log.Println("failed to get authorized client", err) ··· 735 718 Rkey: tid.TID(), 736 719 Record: &lexutil.LexiconTypeDecoder{ 737 720 Val: &tangled.RepoPullComment{ 738 - Pull: string(pullAt), 721 + Pull: pull.PullAt().String(), 739 722 Body: body, 740 723 CreatedAt: createdAt, 741 724 }, ··· 983 966 } 984 967 985 968 sourceRev := comparison.Rev2 986 - patch := comparison.Patch 969 + patch := comparison.FormatPatchRaw 970 + combined := comparison.CombinedPatchRaw 987 971 988 - if !patchutil.IsPatchValid(patch) { 972 + if err := s.validator.ValidatePatch(&patch); err != nil { 973 + s.logger.Error("failed to validate patch", "err", err) 989 974 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 990 975 return 991 976 } ··· 998 983 Sha: comparison.Rev2, 999 984 } 1000 985 1001 - 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) 1002 987 } 1003 988 1004 989 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1005 - if !patchutil.IsPatchValid(patch) { 990 + if err := s.validator.ValidatePatch(&patch); err != nil { 991 + s.logger.Error("patch validation failed", "err", err) 1006 992 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1007 993 return 1008 994 } 1009 995 1010 - 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) 1011 997 } 1012 998 1013 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) { ··· 1090 1076 } 1091 1077 1092 1078 sourceRev := comparison.Rev2 1093 - patch := comparison.Patch 1079 + patch := comparison.FormatPatchRaw 1080 + combined := comparison.CombinedPatchRaw 1094 1081 1095 - if !patchutil.IsPatchValid(patch) { 1082 + if err := s.validator.ValidatePatch(&patch); err != nil { 1083 + s.logger.Error("failed to validate patch", "err", err) 1096 1084 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1097 1085 return 1098 1086 } ··· 1110 1098 Sha: sourceRev, 1111 1099 } 1112 1100 1113 - 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) 1114 1102 } 1115 1103 1116 1104 func (s *Pulls) createPullRequest( ··· 1120 1108 user *oauth.User, 1121 1109 title, body, targetBranch string, 1122 1110 patch string, 1111 + combined string, 1123 1112 sourceRev string, 1124 1113 pullSource *models.PullSource, 1125 1114 recordPullSource *tangled.RepoPull_Source, ··· 1179 1168 rkey := tid.TID() 1180 1169 initialSubmission := models.PullSubmission{ 1181 1170 Patch: patch, 1171 + Combined: combined, 1182 1172 SourceRev: sourceRev, 1183 1173 } 1184 1174 pull := &models.Pull{ ··· 1354 1344 return 1355 1345 } 1356 1346 1357 - if patch == "" || !patchutil.IsPatchValid(patch) { 1347 + if err := s.validator.ValidatePatch(&patch); err != nil { 1348 + s.logger.Error("faield to validate patch", "err", err) 1358 1349 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1359 1350 return 1360 1351 } ··· 1608 1599 1609 1600 patch := r.FormValue("patch") 1610 1601 1611 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1602 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1612 1603 } 1613 1604 1614 1605 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1669 1660 } 1670 1661 1671 1662 sourceRev := comparison.Rev2 1672 - patch := comparison.Patch 1663 + patch := comparison.FormatPatchRaw 1664 + combined := comparison.CombinedPatchRaw 1673 1665 1674 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1666 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1675 1667 } 1676 1668 1677 1669 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1703 1695 return 1704 1696 } 1705 1697 1706 - // extract patch by performing compare 1707 - forkScheme := "http" 1708 - if !s.config.Core.Dev { 1709 - forkScheme = "https" 1710 - } 1711 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1712 - forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1713 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1714 - if err != nil { 1715 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1716 - log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1717 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1718 - return 1719 - } 1720 - log.Printf("failed to compare branches: %s", err) 1721 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1722 - return 1723 - } 1724 - 1725 - var forkComparison types.RepoFormatPatchResponse 1726 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1727 - log.Println("failed to decode XRPC compare response for fork", err) 1728 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1729 - return 1730 - } 1731 - 1732 1698 // update the hidden tracking branch to latest 1733 1699 client, err := s.oauth.ServiceClient( 1734 1700 r, ··· 1760 1726 return 1761 1727 } 1762 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 + 1763 1756 // Use the fork comparison we already made 1764 1757 comparison := forkComparison 1765 1758 1766 1759 sourceRev := comparison.Rev2 1767 - patch := comparison.Patch 1760 + patch := comparison.FormatPatchRaw 1761 + combined := comparison.CombinedPatchRaw 1768 1762 1769 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1770 - } 1771 - 1772 - // validate a resubmission against a pull request 1773 - func validateResubmittedPatch(pull *models.Pull, patch string) error { 1774 - if patch == "" { 1775 - return fmt.Errorf("Patch is empty.") 1776 - } 1777 - 1778 - if patch == pull.LatestPatch() { 1779 - return fmt.Errorf("Patch is identical to previous submission.") 1780 - } 1781 - 1782 - if !patchutil.IsPatchValid(patch) { 1783 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1784 - } 1785 - 1786 - return nil 1763 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1787 1764 } 1788 1765 1789 1766 func (s *Pulls) resubmitPullHelper( ··· 1793 1770 user *oauth.User, 1794 1771 pull *models.Pull, 1795 1772 patch string, 1773 + combined string, 1796 1774 sourceRev string, 1797 1775 ) { 1798 1776 if pull.IsStacked() { ··· 1801 1779 return 1802 1780 } 1803 1781 1804 - if err := validateResubmittedPatch(pull, patch); err != nil { 1782 + if err := s.validator.ValidatePatch(&patch); err != nil { 1805 1783 s.pages.Notice(w, "resubmit-error", err.Error()) 1806 1784 return 1807 1785 } 1808 1786 1787 + if patch == pull.LatestPatch() { 1788 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1789 + return 1790 + } 1791 + 1809 1792 // validate sourceRev if branch/fork based 1810 1793 if pull.IsBranchBased() || pull.IsForkBased() { 1811 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1794 + if sourceRev == pull.LatestSha() { 1812 1795 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1813 1796 return 1814 1797 } ··· 1822 1805 } 1823 1806 defer tx.Rollback() 1824 1807 1825 - 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) 1826 1814 if err != nil { 1827 1815 log.Println("failed to create pull request", err) 1828 1816 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1923 1911 // commits that got deleted: corresponding pull is closed 1924 1912 // commits that got added: new pull is created 1925 1913 // commits that got updated: corresponding pull is resubmitted & new round begins 1926 - // 1927 - // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1928 1914 additions := make(map[string]*models.Pull) 1929 1915 deletions := make(map[string]*models.Pull) 1930 - unchanged := make(map[string]struct{}) 1931 1916 updated := make(map[string]struct{}) 1932 1917 1933 1918 // pulls in orignal stack but not in new one ··· 1949 1934 for _, np := range newStack { 1950 1935 if op, ok := origById[np.ChangeId]; ok { 1951 1936 // pull exists in both stacks 1952 - // TODO: can we avoid reparse? 1953 - origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1954 - newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1955 - 1956 - origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1957 - newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1958 - 1959 - patchutil.SortPatch(newFiles) 1960 - patchutil.SortPatch(origFiles) 1961 - 1962 - // text content of patch may be identical, but a jj rebase might have forwarded it 1963 - // 1964 - // we still need to update the hash in submission.Patch and submission.SourceRev 1965 - if patchutil.Equal(newFiles, origFiles) && 1966 - origHeader.Title == newHeader.Title && 1967 - origHeader.Body == newHeader.Body { 1968 - unchanged[op.ChangeId] = struct{}{} 1969 - } else { 1970 - updated[op.ChangeId] = struct{}{} 1971 - } 1937 + updated[op.ChangeId] = struct{}{} 1972 1938 } 1973 1939 } 1974 1940 ··· 2035 2001 continue 2036 2002 } 2037 2003 2038 - submission := np.Submissions[np.LastRoundNumber()] 2039 - 2040 - // resubmit the old pull 2041 - err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 2042 - 2043 - if err != nil { 2044 - log.Println("failed to update pull", err, op.PullId) 2045 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2046 - return 2047 - } 2048 - 2049 - record := op.AsRecord() 2050 - record.Patch = submission.Patch 2051 - 2052 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2053 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2054 - Collection: tangled.RepoPullNSID, 2055 - Rkey: op.Rkey, 2056 - Value: &lexutil.LexiconTypeDecoder{ 2057 - Val: &record, 2058 - }, 2059 - }, 2060 - }) 2061 - } 2062 - 2063 - // unchanged pulls are edited without starting a new round 2064 - // 2065 - // update source-revs & patches without advancing rounds 2066 - for changeId := range unchanged { 2067 - op, _ := origById[changeId] 2068 - np, _ := newById[changeId] 2069 - 2070 - origSubmission := op.Submissions[op.LastRoundNumber()] 2071 - newSubmission := np.Submissions[np.LastRoundNumber()] 2072 - 2073 - log.Println("moving unchanged change id : ", changeId) 2074 - 2075 - err := db.UpdatePull( 2076 - tx, 2077 - newSubmission.Patch, 2078 - newSubmission.SourceRev, 2079 - db.FilterEq("id", origSubmission.ID), 2080 - ) 2081 - 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) 2082 2011 if err != nil { 2083 2012 log.Println("failed to update pull", err, op.PullId) 2084 2013 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2085 2014 return 2086 2015 } 2087 2016 2088 - record := op.AsRecord() 2089 - record.Patch = newSubmission.Patch 2017 + record := np.AsRecord() 2090 2018 2091 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2092 2020 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2427 2355 initialSubmission := models.PullSubmission{ 2428 2356 Patch: fp.Raw, 2429 2357 SourceRev: fp.SHA, 2358 + Combined: fp.Raw, 2430 2359 } 2431 2360 pull := models.Pull{ 2432 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)
+14 -11
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 ··· 210 213 err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 211 214 if err != nil { 212 215 // non-fatal 213 - log.Println("failed to cache lang results", err) 216 + l.Error("failed to cache lang results", "err", err) 214 217 } 215 218 216 219 err = tx.Commit()
-500
appview/repo/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 - // Support content types are in-sync with the allowed custom avatar file types 398 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 - return nil, false 401 - } 402 - 403 - body := resp.Body 404 - bodyBytes, err := io.ReadAll(body) 405 - if err != nil { 406 - log.Printf("error when fetching external image from %s: %v", url, err) 407 - return nil, false 408 - } 409 - 410 - bodyBuffer := bytes.NewReader(bodyBytes) 411 - _, imgType, err := image.DecodeConfig(bodyBuffer) 412 - if err != nil { 413 - log.Printf("error when decoding external image from %s: %v", url, err) 414 - return nil, false 415 - } 416 - 417 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 - if (contentType == "image/png" && imgType != "png") || 419 - (contentType == "image/jpeg" && imgType != "jpeg") || 420 - (contentType == "image/gif" && imgType != "gif") || 421 - (contentType == "image/webp" && imgType != "webp") { 422 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 - return nil, false 424 - } 425 - 426 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 - if err != nil { 428 - log.Printf("error w/ bodyBuffer.Seek") 429 - return nil, false 430 - } 431 - img, _, err := image.Decode(bodyBuffer) 432 - if err != nil { 433 - log.Printf("error when decoding external image from %s: %v", url, err) 434 - return nil, false 435 - } 436 - 437 - return img, true 438 - } 439 - 440 - func (c *Card) DrawExternalImage(url string) { 441 - image, ok := c.fetchExternalImage(url) 442 - if !ok { 443 - image = fallbackImage() 444 - } 445 - c.DrawImage(image) 446 - } 447 - 448 - // DrawCircularExternalImage draws an external image as a circle at the specified position 449 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 - img, ok := c.fetchExternalImage(url) 451 - if !ok { 452 - img = fallbackImage() 453 - } 454 - 455 - // Create a circular mask 456 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 - center := size / 2 458 - radius := float64(size / 2) 459 - 460 - // Scale the source image to fit the circle 461 - srcBounds := img.Bounds() 462 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 - 465 - // Draw the image with circular clipping 466 - for cy := 0; cy < size; cy++ { 467 - for cx := 0; cx < size; cx++ { 468 - // Calculate distance from center 469 - dx := float64(cx - center) 470 - dy := float64(cy - center) 471 - distance := math.Sqrt(dx*dx + dy*dy) 472 - 473 - // Only draw pixels within the circle 474 - if distance <= radius { 475 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 - } 477 - } 478 - } 479 - 480 - // Draw the circle onto the card 481 - bounds := c.Img.Bounds() 482 - destRect := image.Rect(x, y, x+size, y+size) 483 - 484 - // Make sure we don't draw outside the card bounds 485 - if destRect.Max.X > bounds.Max.X { 486 - destRect.Max.X = bounds.Max.X 487 - } 488 - if destRect.Max.Y > bounds.Max.Y { 489 - destRect.Max.Y = bounds.Max.Y 490 - } 491 - 492 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 - 494 - return nil 495 - } 496 - 497 - // DrawRect draws a rect with the given color 498 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 - }
+4 -4
appview/repo/opengraph.go
··· 15 15 "github.com/go-enry/go-enry/v2" 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/repo/ogcard" 18 + "tangled.org/core/appview/ogcard" 19 19 "tangled.org/core/types" 20 20 ) 21 21 ··· 158 158 // Draw star icon, count, and label 159 159 // Align icon baseline with text baseline 160 160 iconBaselineOffset := int(textSize) / 2 161 - err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 161 + err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 162 if err != nil { 163 163 log.Printf("failed to draw star icon: %v", err) 164 164 } ··· 185 185 186 186 // Draw issues icon, count, and label 187 187 issueStartX := currentX 188 - err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 188 + err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 189 if err != nil { 190 190 log.Printf("failed to draw circle-dot icon: %v", err) 191 191 } ··· 210 210 211 211 // Draw pull request icon, count, and label 212 212 prStartX := currentX 213 - err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 213 + err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 214 214 if err != nil { 215 215 log.Printf("failed to draw git-pull-request icon: %v", err) 216 216 }
+141 -94
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" ··· 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 } ··· 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 } ··· 629 648 } 630 649 631 650 func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 651 + l := rp.logger.With("handler", "DeleteBranch") 652 + 632 653 f, err := rp.repoResolver.Resolve(r) 633 654 if err != nil { 634 - log.Println("failed to get repo and knot", err) 655 + l.Error("failed to get repo and knot", "err", err) 635 656 return 636 657 } 637 658 638 659 noticeId := "delete-branch-error" 639 660 fail := func(msg string, err error) { 640 - log.Println(msg, "err", err) 661 + l.Error(msg, "err", err) 641 662 rp.pages.Notice(w, noticeId, msg) 642 663 } 643 664 ··· 670 691 fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 671 692 return 672 693 } 673 - log.Println("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 694 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 674 695 675 696 rp.pages.HxRefresh(w) 676 697 } 677 698 678 699 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 + l := rp.logger.With("handler", "RepoBlob") 701 + 679 702 f, err := rp.repoResolver.Resolve(r) 680 703 if err != nil { 681 - log.Println("failed to get repo and knot", err) 704 + l.Error("failed to get repo and knot", "err", err) 682 705 return 683 706 } 684 707 ··· 700 723 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 701 724 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 702 725 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 703 - log.Println("failed to call XRPC repo.blob", xrpcerr) 726 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 704 727 rp.pages.Error503(w) 705 728 return 706 729 } ··· 800 823 } 801 824 802 825 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 + l := rp.logger.With("handler", "RepoBlobRaw") 827 + 803 828 f, err := rp.repoResolver.Resolve(r) 804 829 if err != nil { 805 - log.Println("failed to get repo and knot", err) 830 + l.Error("failed to get repo and knot", "err", err) 806 831 w.WriteHeader(http.StatusBadRequest) 807 832 return 808 833 } ··· 834 859 835 860 req, err := http.NewRequest("GET", blobURL, nil) 836 861 if err != nil { 837 - log.Println("failed to create request", err) 862 + l.Error("failed to create request", "err", err) 838 863 return 839 864 } 840 865 ··· 846 871 client := &http.Client{} 847 872 resp, err := client.Do(req) 848 873 if err != nil { 849 - log.Println("failed to reach knotserver", err) 874 + l.Error("failed to reach knotserver", "err", err) 850 875 rp.pages.Error503(w) 851 876 return 852 877 } ··· 859 884 } 860 885 861 886 if resp.StatusCode != http.StatusOK { 862 - 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) 863 888 w.WriteHeader(resp.StatusCode) 864 889 _, _ = io.Copy(w, resp.Body) 865 890 return ··· 868 893 contentType := resp.Header.Get("Content-Type") 869 894 body, err := io.ReadAll(resp.Body) 870 895 if err != nil { 871 - log.Printf("error reading response body from knotserver: %v", err) 896 + l.Error("error reading response body from knotserver", "err", err) 872 897 w.WriteHeader(http.StatusInternalServerError) 873 898 return 874 899 } ··· 1443 1468 db.FilterContains("scope", subject.Collection().String()), 1444 1469 ) 1445 1470 if err != nil { 1446 - log.Println("failed to fetch label defs", err) 1471 + l.Error("failed to fetch label defs", "err", err) 1447 1472 return 1448 1473 } 1449 1474 ··· 1454 1479 1455 1480 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1456 1481 if err != nil { 1457 - log.Println("failed to build label state", err) 1482 + l.Error("failed to build label state", "err", err) 1458 1483 return 1459 1484 } 1460 1485 state := states[subject] ··· 1491 1516 db.FilterContains("scope", subject.Collection().String()), 1492 1517 ) 1493 1518 if err != nil { 1494 - log.Println("failed to fetch labels", err) 1519 + l.Error("failed to fetch labels", "err", err) 1495 1520 return 1496 1521 } 1497 1522 ··· 1502 1527 1503 1528 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1504 1529 if err != nil { 1505 - log.Println("failed to build label state", err) 1530 + l.Error("failed to build label state", "err", err) 1506 1531 return 1507 1532 } 1508 1533 state := states[subject] ··· 1649 1674 1650 1675 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1651 1676 user := rp.oauth.GetUser(r) 1677 + l := rp.logger.With("handler", "DeleteRepo") 1652 1678 1653 1679 noticeId := "operation-error" 1654 1680 f, err := rp.repoResolver.Resolve(r) 1655 1681 if err != nil { 1656 - log.Println("failed to get repo and knot", err) 1682 + l.Error("failed to get repo and knot", "err", err) 1657 1683 return 1658 1684 } 1659 1685 1660 1686 // remove record from pds 1661 1687 atpClient, err := rp.oauth.AuthorizedClient(r) 1662 1688 if err != nil { 1663 - log.Println("failed to get authorized client", err) 1689 + l.Error("failed to get authorized client", "err", err) 1664 1690 return 1665 1691 } 1666 1692 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ ··· 1669 1695 Rkey: f.Rkey, 1670 1696 }) 1671 1697 if err != nil { 1672 - log.Printf("failed to delete record: %s", err) 1698 + l.Error("failed to delete record", "err", err) 1673 1699 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1674 1700 return 1675 1701 } 1676 - log.Println("removed repo record ", f.RepoAt().String()) 1702 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1677 1703 1678 1704 client, err := rp.oauth.ServiceClient( 1679 1705 r, ··· 1682 1708 oauth.WithDev(rp.config.Core.Dev), 1683 1709 ) 1684 1710 if err != nil { 1685 - log.Println("failed to connect to knot server:", err) 1711 + l.Error("failed to connect to knot server", "err", err) 1686 1712 return 1687 1713 } 1688 1714 ··· 1699 1725 rp.pages.Notice(w, noticeId, err.Error()) 1700 1726 return 1701 1727 } 1702 - log.Println("deleted repo from knot") 1728 + l.Info("deleted repo from knot") 1703 1729 1704 1730 tx, err := rp.db.BeginTx(r.Context(), nil) 1705 1731 if err != nil { 1706 - log.Println("failed to start tx") 1732 + l.Error("failed to start tx") 1707 1733 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1708 1734 return 1709 1735 } ··· 1711 1737 tx.Rollback() 1712 1738 err = rp.enforcer.E.LoadPolicy() 1713 1739 if err != nil { 1714 - log.Println("failed to rollback policies") 1740 + l.Error("failed to rollback policies") 1715 1741 } 1716 1742 }() 1717 1743 ··· 1725 1751 did := c[0] 1726 1752 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1727 1753 } 1728 - log.Println("removed collaborators") 1754 + l.Info("removed collaborators") 1729 1755 1730 1756 // remove repo RBAC 1731 1757 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) ··· 1740 1766 rp.pages.Notice(w, noticeId, "Failed to update appview") 1741 1767 return 1742 1768 } 1743 - log.Println("removed repo from db") 1769 + l.Info("removed repo from db") 1744 1770 1745 1771 err = tx.Commit() 1746 1772 if err != nil { 1747 - log.Println("failed to commit changes", err) 1773 + l.Error("failed to commit changes", "err", err) 1748 1774 http.Error(w, err.Error(), http.StatusInternalServerError) 1749 1775 return 1750 1776 } 1751 1777 1752 1778 err = rp.enforcer.E.SavePolicy() 1753 1779 if err != nil { 1754 - log.Println("failed to update ACLs", err) 1780 + l.Error("failed to update ACLs", "err", err) 1755 1781 http.Error(w, err.Error(), http.StatusInternalServerError) 1756 1782 return 1757 1783 } ··· 1760 1786 } 1761 1787 1762 1788 func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 + l := rp.logger.With("handler", "SetDefaultBranch") 1790 + 1763 1791 f, err := rp.repoResolver.Resolve(r) 1764 1792 if err != nil { 1765 - log.Println("failed to get repo and knot", err) 1793 + l.Error("failed to get repo and knot", "err", err) 1766 1794 return 1767 1795 } 1768 1796 ··· 1780 1808 oauth.WithDev(rp.config.Core.Dev), 1781 1809 ) 1782 1810 if err != nil { 1783 - log.Println("failed to connect to knot server:", err) 1811 + l.Error("failed to connect to knot server", "err", err) 1784 1812 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1785 1813 return 1786 1814 } ··· 1794 1822 }, 1795 1823 ) 1796 1824 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1797 - log.Println("xrpc failed", "err", xe) 1825 + l.Error("xrpc failed", "err", xe) 1798 1826 rp.pages.Notice(w, noticeId, err.Error()) 1799 1827 return 1800 1828 } ··· 1809 1837 1810 1838 f, err := rp.repoResolver.Resolve(r) 1811 1839 if err != nil { 1812 - log.Println("failed to get repo and knot", err) 1840 + l.Error("failed to get repo and knot", "err", err) 1813 1841 return 1814 1842 } 1815 1843 1816 1844 if f.Spindle == "" { 1817 - log.Println("empty spindle cannot add/rm secret", err) 1845 + l.Error("empty spindle cannot add/rm secret", "err", err) 1818 1846 return 1819 1847 } 1820 1848 ··· 1831 1859 oauth.WithDev(rp.config.Core.Dev), 1832 1860 ) 1833 1861 if err != nil { 1834 - log.Println("failed to create spindle client", err) 1862 + l.Error("failed to create spindle client", "err", err) 1835 1863 return 1836 1864 } 1837 1865 ··· 1917 1945 } 1918 1946 1919 1947 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 + l := rp.logger.With("handler", "generalSettings") 1949 + 1920 1950 f, err := rp.repoResolver.Resolve(r) 1921 1951 user := rp.oauth.GetUser(r) 1922 1952 ··· 1932 1962 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1933 1963 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1934 1964 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1935 - log.Println("failed to call XRPC repo.branches", xrpcerr) 1965 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1936 1966 rp.pages.Error503(w) 1937 1967 return 1938 1968 } 1939 1969 1940 1970 var result types.RepoBranchesResponse 1941 1971 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1942 - log.Println("failed to decode XRPC response", err) 1972 + l.Error("failed to decode XRPC response", "err", err) 1943 1973 rp.pages.Error503(w) 1944 1974 return 1945 1975 } 1946 1976 1947 1977 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1948 1978 if err != nil { 1949 - log.Println("failed to fetch labels", err) 1979 + l.Error("failed to fetch labels", "err", err) 1950 1980 rp.pages.Error503(w) 1951 1981 return 1952 1982 } 1953 1983 1954 1984 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1955 1985 if err != nil { 1956 - log.Println("failed to fetch labels", err) 1986 + l.Error("failed to fetch labels", "err", err) 1957 1987 rp.pages.Error503(w) 1958 1988 return 1959 1989 } ··· 2001 2031 } 2002 2032 2003 2033 func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 + l := rp.logger.With("handler", "accessSettings") 2035 + 2004 2036 f, err := rp.repoResolver.Resolve(r) 2005 2037 user := rp.oauth.GetUser(r) 2006 2038 2007 2039 repoCollaborators, err := f.Collaborators(r.Context()) 2008 2040 if err != nil { 2009 - log.Println("failed to get collaborators", err) 2041 + l.Error("failed to get collaborators", "err", err) 2010 2042 } 2011 2043 2012 2044 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ ··· 2019 2051 } 2020 2052 2021 2053 func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 + l := rp.logger.With("handler", "pipelineSettings") 2055 + 2022 2056 f, err := rp.repoResolver.Resolve(r) 2023 2057 user := rp.oauth.GetUser(r) 2024 2058 2025 2059 // all spindles that the repo owner is a member of 2026 2060 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2027 2061 if err != nil { 2028 - log.Println("failed to fetch spindles", err) 2062 + l.Error("failed to fetch spindles", "err", err) 2029 2063 return 2030 2064 } 2031 2065 ··· 2038 2072 oauth.WithExp(60), 2039 2073 oauth.WithDev(rp.config.Core.Dev), 2040 2074 ); err != nil { 2041 - log.Println("failed to create spindle client", err) 2075 + l.Error("failed to create spindle client", "err", err) 2042 2076 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2043 - log.Println("failed to fetch secrets", err) 2077 + l.Error("failed to fetch secrets", "err", err) 2044 2078 } else { 2045 2079 secrets = resp.Secrets 2046 2080 } ··· 2080 2114 } 2081 2115 2082 2116 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 + l := rp.logger.With("handler", "SyncRepoFork") 2118 + 2083 2119 ref := chi.URLParam(r, "ref") 2084 2120 ref, _ = url.PathUnescape(ref) 2085 2121 2086 2122 user := rp.oauth.GetUser(r) 2087 2123 f, err := rp.repoResolver.Resolve(r) 2088 2124 if err != nil { 2089 - log.Printf("failed to resolve source repo: %v", err) 2125 + l.Error("failed to resolve source repo", "err", err) 2090 2126 return 2091 2127 } 2092 2128 ··· 2130 2166 } 2131 2167 2132 2168 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 2169 + l := rp.logger.With("handler", "ForkRepo") 2170 + 2133 2171 user := rp.oauth.GetUser(r) 2134 2172 f, err := rp.repoResolver.Resolve(r) 2135 2173 if err != nil { 2136 - log.Printf("failed to resolve source repo: %v", err) 2174 + l.Error("failed to resolve source repo", "err", err) 2137 2175 return 2138 2176 } 2139 2177 ··· 2184 2222 ) 2185 2223 if err != nil { 2186 2224 if !errors.Is(err, sql.ErrNoRows) { 2187 - log.Println("error fetching existing repo from db", "err", err) 2225 + l.Error("error fetching existing repo from db", "err", err) 2188 2226 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2189 2227 return 2190 2228 } ··· 2299 2337 2300 2338 err = db.AddRepo(tx, repo) 2301 2339 if err != nil { 2302 - log.Println(err) 2340 + l.Error("failed to AddRepo", "err", err) 2303 2341 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2304 2342 return 2305 2343 } ··· 2308 2346 p, _ := securejoin.SecureJoin(user.Did, forkName) 2309 2347 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2310 2348 if err != nil { 2311 - log.Println(err) 2349 + l.Error("failed to add ACLs", "err", err) 2312 2350 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2313 2351 return 2314 2352 } 2315 2353 2316 2354 err = tx.Commit() 2317 2355 if err != nil { 2318 - log.Println("failed to commit changes", err) 2356 + l.Error("failed to commit changes", "err", err) 2319 2357 http.Error(w, err.Error(), http.StatusInternalServerError) 2320 2358 return 2321 2359 } 2322 2360 2323 2361 err = rp.enforcer.E.SavePolicy() 2324 2362 if err != nil { 2325 - log.Println("failed to update ACLs", err) 2363 + l.Error("failed to update ACLs", "err", err) 2326 2364 http.Error(w, err.Error(), http.StatusInternalServerError) 2327 2365 return 2328 2366 } ··· 2358 2396 } 2359 2397 2360 2398 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 + l := rp.logger.With("handler", "RepoCompareNew") 2400 + 2361 2401 user := rp.oauth.GetUser(r) 2362 2402 f, err := rp.repoResolver.Resolve(r) 2363 2403 if err != nil { 2364 - log.Println("failed to get repo and knot", err) 2404 + l.Error("failed to get repo and knot", "err", err) 2365 2405 return 2366 2406 } 2367 2407 ··· 2377 2417 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2378 2418 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2379 2419 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2380 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2420 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2381 2421 rp.pages.Error503(w) 2382 2422 return 2383 2423 } 2384 2424 2385 2425 var branchResult types.RepoBranchesResponse 2386 2426 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2387 - log.Println("failed to decode XRPC branches response", err) 2427 + l.Error("failed to decode XRPC branches response", "err", err) 2388 2428 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2389 2429 return 2390 2430 } ··· 2414 2454 2415 2455 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2416 2456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2417 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2457 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2418 2458 rp.pages.Error503(w) 2419 2459 return 2420 2460 } 2421 2461 2422 2462 var tags types.RepoTagsResponse 2423 2463 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2424 - log.Println("failed to decode XRPC tags response", err) 2464 + l.Error("failed to decode XRPC tags response", "err", err) 2425 2465 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2426 2466 return 2427 2467 } ··· 2439 2479 } 2440 2480 2441 2481 func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 + l := rp.logger.With("handler", "RepoCompare") 2483 + 2442 2484 user := rp.oauth.GetUser(r) 2443 2485 f, err := rp.repoResolver.Resolve(r) 2444 2486 if err != nil { 2445 - log.Println("failed to get repo and knot", err) 2487 + l.Error("failed to get repo and knot", "err", err) 2446 2488 return 2447 2489 } 2448 2490 ··· 2469 2511 head, _ = url.PathUnescape(head) 2470 2512 2471 2513 if base == "" || head == "" { 2472 - log.Printf("invalid comparison") 2514 + l.Error("invalid comparison") 2473 2515 rp.pages.Error404(w) 2474 2516 return 2475 2517 } ··· 2487 2529 2488 2530 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2489 2531 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2490 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2532 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2491 2533 rp.pages.Error503(w) 2492 2534 return 2493 2535 } 2494 2536 2495 2537 var branches types.RepoBranchesResponse 2496 2538 if err := json.Unmarshal(branchBytes, &branches); err != nil { 2497 - log.Println("failed to decode XRPC branches response", err) 2539 + l.Error("failed to decode XRPC branches response", "err", err) 2498 2540 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2499 2541 return 2500 2542 } 2501 2543 2502 2544 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2503 2545 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2504 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2546 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2505 2547 rp.pages.Error503(w) 2506 2548 return 2507 2549 } 2508 2550 2509 2551 var tags types.RepoTagsResponse 2510 2552 if err := json.Unmarshal(tagBytes, &tags); err != nil { 2511 - log.Println("failed to decode XRPC tags response", err) 2553 + l.Error("failed to decode XRPC tags response", "err", err) 2512 2554 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2513 2555 return 2514 2556 } 2515 2557 2516 2558 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2517 2559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2518 - log.Println("failed to call XRPC repo.compare", xrpcerr) 2560 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2519 2561 rp.pages.Error503(w) 2520 2562 return 2521 2563 } 2522 2564 2523 2565 var formatPatch types.RepoFormatPatchResponse 2524 2566 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2525 - log.Println("failed to decode XRPC compare response", err) 2567 + l.Error("failed to decode XRPC compare response", "err", err) 2526 2568 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2527 2569 return 2528 2570 } 2529 2571 2530 - 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 + } 2531 2578 2532 2579 repoinfo := f.RepoInfo(user) 2533 2580
+3 -2
appview/settings/settings.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 25 26 lexutil "github.com/bluesky-social/indigo/lex/util" 26 27 "github.com/gliderlabs/ssh" 27 28 "github.com/google/uuid" ··· 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",
+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 + }
+94 -37
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 5 6 "encoding/json" 6 7 "errors" 7 8 "fmt" ··· 62 63 disallowed := make(map[string]bool) 63 64 64 65 if filepath == "" { 65 - logger.Debug("no disallowed nicknames file configured") 66 + logger.Warn("no disallowed nicknames file configured") 66 67 return disallowed 67 68 } 68 69 ··· 216 217 return 217 218 } 218 219 219 - did, err := s.createAccountRequest(username, password, email, code) 220 - if err != nil { 221 - s.l.Error("failed to create account", "error", err) 222 - s.pages.Notice(w, "signup-error", err.Error()) 223 - return 224 - } 225 - 226 220 if s.cf == nil { 227 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 228 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 229 223 return 230 224 } 231 225 232 - err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 233 - Type: "TXT", 234 - Name: "_atproto." + username, 235 - Content: fmt.Sprintf(`"did=%s"`, did), 236 - TTL: 6400, 237 - Proxied: false, 238 - }) 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 239 228 if err != nil { 240 - s.l.Error("failed to create DNS record", "error", err) 241 - 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 242 230 return 243 231 } 232 + } 233 + } 234 + 235 + // executeSignupTransaction performs the signup process transactionally with rollback 236 + func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 237 + var recordID string 238 + var did string 239 + var emailAdded bool 240 + 241 + success := false 242 + defer func() { 243 + if !success { 244 + s.l.Info("rolling back signup transaction", "username", username, "did", did) 244 245 245 - err = db.AddEmail(s.db, models.Email{ 246 - Did: did, 247 - Address: email, 248 - Verified: true, 249 - Primary: true, 250 - }) 251 - if err != nil { 252 - s.l.Error("failed to add email", "error", err) 253 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 254 - return 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 + } 255 272 } 273 + }() 256 274 257 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 258 - <a class="underline text-black dark:text-white" href="/login">login</a> 259 - 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 + } 282 + 283 + // step 2: create DNS record with actual DID 284 + recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 + Type: "TXT", 286 + Name: "_atproto." + username, 287 + Content: fmt.Sprintf(`"did=%s"`, did), 288 + TTL: 6400, 289 + Proxied: false, 290 + }) 291 + if err != nil { 292 + s.l.Error("failed to create DNS record", "error", err) 293 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 + return err 295 + } 260 296 261 - go func() { 262 - err := db.DeleteInflightSignup(s.db, email) 263 - if err != nil { 264 - s.l.Error("failed to delete inflight signup", "error", err) 265 - } 266 - }() 267 - return 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 268 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 269 326 } 270 327 271 328 type turnstileResponse struct {
+1
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() {
+3 -1
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
+7 -4
appview/state/login.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "log" 6 5 "net/http" 7 6 "strings" 8 7 ··· 10 9 ) 11 10 12 11 func (s *State) Login(w http.ResponseWriter, r *http.Request) { 12 + l := s.logger.With("handler", "Login") 13 + 13 14 switch r.Method { 14 15 case http.MethodGet: 15 16 returnURL := r.URL.Query().Get("return_url") ··· 32 33 33 34 // basic handle validation 34 35 if !strings.Contains(handle, ".") { 35 - log.Println("invalid handle format", "raw", handle) 36 + l.Error("invalid handle format", "raw", handle) 36 37 s.pages.Notice( 37 38 w, 38 39 "login-msg", ··· 52 53 } 53 54 54 55 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 56 + l := s.logger.With("handler", "Logout") 57 + 55 58 err := s.oauth.DeleteSession(w, r) 56 59 if err != nil { 57 - log.Println("failed to logout", "err", err) 60 + l.Error("failed to logout", "err", err) 58 61 } else { 59 - log.Println("logged out successfully") 62 + l.Info("logged out successfully") 60 63 } 61 64 62 65 s.pages.HxRedirect(w, "/login")
+60 -13
appview/state/router.go
··· 205 205 } 206 206 207 207 func (s *State) SpindlesRouter() http.Handler { 208 - logger := log.New("spindles") 208 + logger := log.SubLogger(s.logger, "spindles") 209 209 210 210 spindles := &spindles.Spindles{ 211 211 Db: s.db, ··· 221 221 } 222 222 223 223 func (s *State) KnotsRouter() http.Handler { 224 - logger := log.New("knots") 224 + logger := log.SubLogger(s.logger, "knots") 225 225 226 226 knots := &knots.Knots{ 227 227 Db: s.db, ··· 238 238 } 239 239 240 240 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 241 - logger := log.New("strings") 241 + logger := log.SubLogger(s.logger, "strings") 242 242 243 243 strs := &avstrings.Strings{ 244 244 Db: s.db, ··· 253 253 } 254 254 255 255 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 256 - 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 + ) 257 267 return issues.Router(mw) 258 268 } 259 269 260 270 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 261 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.enforcer) 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 + ) 262 283 return pulls.Router(mw) 263 284 } 264 285 265 286 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 266 - logger := log.New("repo") 267 - 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 + ) 268 300 return repo.Router(mw) 269 301 } 270 302 271 303 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 272 - 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 + ) 273 315 return pipes.Router(mw) 274 316 } 275 317 276 318 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 277 - 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 + ) 278 327 return ls.Router(mw) 279 328 } 280 329 281 330 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 282 - notifs := notifications.New(s.db, s.oauth, s.pages) 331 + notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 283 332 return notifs.Router(mw) 284 333 } 285 334 286 335 func (s *State) SignupRouter() http.Handler { 287 - logger := log.New("signup") 288 - 289 - 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")) 290 337 return sig.Router() 291 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
+19 -23
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" ··· 13 12 14 13 "tangled.org/core/api/tangled" 15 14 "tangled.org/core/appview" 16 - "tangled.org/core/appview/cache" 17 - "tangled.org/core/appview/cache/session" 18 15 "tangled.org/core/appview/config" 19 16 "tangled.org/core/appview/db" 20 17 "tangled.org/core/appview/models" ··· 29 26 "tangled.org/core/eventconsumer" 30 27 "tangled.org/core/idresolver" 31 28 "tangled.org/core/jetstream" 29 + "tangled.org/core/log" 32 30 tlog "tangled.org/core/log" 33 31 "tangled.org/core/rbac" 34 32 "tangled.org/core/tid" ··· 48 46 oauth *oauth.OAuth 49 47 enforcer *rbac.Enforcer 50 48 pages *pages.Pages 51 - sess *session.SessionStore 52 49 idResolver *idresolver.Resolver 53 50 posthog posthog.Client 54 51 jc *jetstream.JetstreamClient ··· 61 58 } 62 59 63 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 64 - d, err := db.Make(config.Core.DbPath) 61 + logger := tlog.FromContext(ctx) 62 + 63 + d, err := db.Make(ctx, config.Core.DbPath) 65 64 if err != nil { 66 65 return nil, fmt.Errorf("failed to create db: %w", err) 67 66 } ··· 73 72 74 73 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 75 74 if err != nil { 76 - log.Printf("failed to create redis resolver: %v", err) 75 + logger.Error("failed to create redis resolver", "err", err) 77 76 res = idresolver.DefaultResolver() 78 77 } 79 78 80 - pages := pages.NewPages(config, res) 81 - cache := cache.New(config.Redis.Addr) 82 - sess := session.New(cache) 83 - oauth2, err := oauth.New(config) 79 + posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 80 if err != nil { 85 - return nil, fmt.Errorf("failed to start oauth handler: %w", err) 81 + return nil, fmt.Errorf("failed to create posthog client: %w", err) 86 82 } 87 - validator := validator.New(d, res, enforcer) 88 83 89 - posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 + pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 85 + oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 90 86 if err != nil { 91 - return nil, fmt.Errorf("failed to create posthog client: %w", err) 87 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 92 88 } 89 + validator := validator.New(d, res, enforcer) 93 90 94 91 repoResolver := reporesolver.New(config, enforcer, res, d) 95 92 ··· 112 109 tangled.LabelOpNSID, 113 110 }, 114 111 nil, 115 - slog.Default(), 112 + tlog.SubLogger(logger, "jetstream"), 116 113 wrapper, 117 114 false, 118 115 ··· 133 130 Enforcer: enforcer, 134 131 IdResolver: res, 135 132 Config: config, 136 - Logger: tlog.New("ingester"), 133 + Logger: log.SubLogger(logger, "ingester"), 137 134 Validator: validator, 138 135 } 139 136 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 167 164 state := &State{ 168 165 d, 169 166 notifier, 170 - oauth2, 167 + oauth, 171 168 enforcer, 172 169 pages, 173 - sess, 174 170 res, 175 171 posthog, 176 172 jc, ··· 178 174 repoResolver, 179 175 knotstream, 180 176 spindlestream, 181 - slog.Default(), 177 + logger, 182 178 validator, 183 179 } 184 180 ··· 277 273 } 278 274 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 279 275 if err != nil { 280 - log.Println(err) 276 + s.logger.Error("failed to make timeline", "err", err) 281 277 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 282 278 } 283 279 284 280 repos, err := db.GetTopStarredReposLastWeek(s.db) 285 281 if err != nil { 286 - log.Println(err) 282 + s.logger.Error("failed to get top starred repos", "err", err) 287 283 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 288 284 return 289 285 } ··· 344 340 345 341 timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 346 342 if err != nil { 347 - log.Println(err) 343 + s.logger.Error("failed to make timeline", "err", err) 348 344 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 349 345 return 350 346 } 351 347 352 348 repos, err := db.GetTopStarredReposLastWeek(s.db) 353 349 if err != nil { 354 - log.Println(err) 350 + s.logger.Error("failed to get top starred repos", "err", err) 355 351 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 356 352 return 357 353 }
+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 + }
+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 - }
+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
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 '';
+13
go.mod
··· 60 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 61 61 github.com/alecthomas/repr v0.4.0 // indirect 62 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 63 64 github.com/aymerick/douceur v0.2.0 // indirect 64 65 github.com/beorn7/perks v1.0.1 // indirect 65 66 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 66 67 github.com/casbin/govaluate v1.3.0 // indirect 67 68 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 68 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 69 76 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 70 77 github.com/containerd/errdefs v1.0.0 // indirect 71 78 github.com/containerd/errdefs/pkg v0.3.0 // indirect ··· 84 91 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 85 92 github.com/go-git/go-billy/v5 v5.6.2 // indirect 86 93 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 94 + github.com/go-logfmt/logfmt v0.6.0 // indirect 87 95 github.com/go-logr/logr v1.4.3 // indirect 88 96 github.com/go-logr/stdr v1.2.2 // indirect 89 97 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 126 134 github.com/lestrrat-go/httprc v1.0.6 // indirect 127 135 github.com/lestrrat-go/iter v1.0.2 // indirect 128 136 github.com/lestrrat-go/option v1.0.1 // indirect 137 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 129 138 github.com/mattn/go-isatty v0.0.20 // indirect 139 + github.com/mattn/go-runewidth v0.0.16 // indirect 130 140 github.com/minio/sha256-simd v1.0.1 // indirect 131 141 github.com/mitchellh/mapstructure v1.5.0 // indirect 132 142 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 134 144 github.com/moby/term v0.5.2 // indirect 135 145 github.com/morikuni/aec v1.0.0 // indirect 136 146 github.com/mr-tron/base58 v1.2.0 // indirect 147 + github.com/muesli/termenv v0.16.0 // indirect 137 148 github.com/multiformats/go-base32 v0.1.0 // indirect 138 149 github.com/multiformats/go-base36 v0.2.0 // indirect 139 150 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 152 163 github.com/prometheus/client_model v0.6.2 // indirect 153 164 github.com/prometheus/common v0.64.0 // indirect 154 165 github.com/prometheus/procfs v0.16.1 // indirect 166 + github.com/rivo/uniseg v0.4.7 // indirect 155 167 github.com/ryanuber/go-glob v1.0.0 // indirect 156 168 github.com/segmentio/asm v1.2.0 // indirect 157 169 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect ··· 160 172 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 161 173 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 162 174 github.com/wyatt915/treeblood v0.1.15 // indirect 175 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 163 176 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 164 177 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 165 178 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
+27
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= ··· 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= ··· 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= ··· 434 459 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 435 460 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 436 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= 437 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 438 465 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 439 466 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+1 -1
jetstream/jetstream.go
··· 114 114 115 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 116 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 118 if err != nil { 119 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 120 }
+2 -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
+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
+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 }
+8 -5
knotserver/internal.go
··· 20 20 "tangled.org/core/knotserver/config" 21 21 "tangled.org/core/knotserver/db" 22 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 23 24 "tangled.org/core/notifier" 24 25 "tangled.org/core/rbac" 25 26 "tangled.org/core/workflow" ··· 121 122 } 122 123 123 124 if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 124 - msg, err := h.replyCompare(line, gitUserDid, gitRelativeDir, repoName, r.Context()) 125 + msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 125 126 if err != nil { 126 127 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 127 128 // non-fatal ··· 142 143 writeJSON(w, resp) 143 144 } 144 145 145 - func (h *InternalHandle) replyCompare(line git.PostReceiveLine, gitUserDid string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 146 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 146 147 l := h.l.With("handler", "replyCompare") 147 - userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, gitUserDid) 148 - user := gitUserDid 148 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 + user := repoOwner 149 150 if err != nil { 150 151 l.Error("Failed to fetch user identity", "err", err) 151 152 // non-fatal ··· 314 315 return h.db.InsertEvent(event, h.n) 315 316 } 316 317 317 - 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 { 318 319 r := chi.NewRouter() 320 + l := log.FromContext(ctx) 321 + l = log.SubLogger(l, "internal") 319 322 320 323 h := InternalHandle{ 321 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)
+2
knotserver/xrpc/merge_check.go
··· 81 81 } 82 82 } 83 83 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 85 + 84 86 w.Header().Set("Content-Type", "application/json") 85 87 w.WriteHeader(http.StatusOK) 86 88 json.NewEncoder(w).Encode(response)
+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)
+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 + }
+39
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=" ··· 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=" ··· 298 322 [mod."github.com/lestrrat-go/option"] 299 323 version = "v1.0.1" 300 324 hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 325 + [mod."github.com/lucasb-eyer/go-colorful"] 326 + version = "v1.2.0" 327 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 301 328 [mod."github.com/mattn/go-isatty"] 302 329 version = "v0.0.20" 303 330 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 331 + [mod."github.com/mattn/go-runewidth"] 332 + version = "v0.0.16" 333 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 304 334 [mod."github.com/mattn/go-sqlite3"] 305 335 version = "v1.14.24" 306 336 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 328 358 [mod."github.com/mr-tron/base58"] 329 359 version = "v1.2.0" 330 360 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 361 + [mod."github.com/muesli/termenv"] 362 + version = "v0.16.0" 363 + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 331 364 [mod."github.com/multiformats/go-base32"] 332 365 version = "v0.1.0" 333 366 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 394 427 [mod."github.com/resend/resend-go/v2"] 395 428 version = "v2.15.0" 396 429 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 430 + [mod."github.com/rivo/uniseg"] 431 + version = "v0.4.7" 432 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 397 433 [mod."github.com/ryanuber/go-glob"] 398 434 version = "v1.0.0" 399 435 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" ··· 440 476 [mod."github.com/wyatt915/treeblood"] 441 477 version = "v0.1.15" 442 478 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 479 + [mod."github.com/xo/terminfo"] 480 + version = "v0.0.0-20220910002029-abceb7e1c41e" 481 + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 443 482 [mod."github.com/yuin/goldmark"] 444 483 version = "v1.7.13" 445 484 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
+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
+18 -7
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "os" ··· 42 43 // IsPatchValid checks if the given patch string is valid. 43 44 // It performs very basic sniffing for either git-diff or git-format-patch 44 45 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 46 53 if len(patch) == 0 { 47 - return false 54 + return EmptyPatchError 48 55 } 49 56 50 57 lines := strings.Split(patch, "\n") 51 58 if len(lines) < 2 { 52 - return false 59 + return EmptyPatchError 53 60 } 54 61 55 62 firstLine := strings.TrimSpace(lines[0]) ··· 60 67 strings.HasPrefix(firstLine, "Index: ") || 61 68 strings.HasPrefix(firstLine, "+++ ") || 62 69 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 70 + return nil 64 71 } 65 72 66 73 // check if it's format-patch ··· 70 77 // it's safe to say it's broken. 71 78 patches, err := ExtractPatches(patch) 72 79 if err != nil { 73 - return false 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 74 81 } 75 - return len(patches) > 0 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 76 87 } 77 88 78 - return false 89 + return GenericPatchError 79 90 } 80 91 81 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "reflect" 5 6 "testing" 6 7 ) ··· 9 10 tests := []struct { 10 11 name string 11 12 patch string 12 - expected bool 13 + expected error 13 14 }{ 14 15 { 15 16 name: `empty patch`, 16 17 patch: ``, 17 - expected: false, 18 + expected: EmptyPatchError, 18 19 }, 19 20 { 20 21 name: `single line patch`, 21 22 patch: `single line`, 22 - expected: false, 23 + expected: EmptyPatchError, 23 24 }, 24 25 { 25 26 name: `valid diff patch`, ··· 31 32 -old line 32 33 +new line 33 34 context`, 34 - expected: true, 35 + expected: nil, 35 36 }, 36 37 { 37 38 name: `valid patch starting with ---`, ··· 41 42 -old line 42 43 +new line 43 44 context`, 44 - expected: true, 45 + expected: nil, 45 46 }, 46 47 { 47 48 name: `valid patch starting with Index`, ··· 53 54 -old line 54 55 +new line 55 56 context`, 56 - expected: true, 57 + expected: nil, 57 58 }, 58 59 { 59 60 name: `valid patch starting with +++`, ··· 63 64 -old line 64 65 +new line 65 66 context`, 66 - expected: true, 67 + expected: nil, 67 68 }, 68 69 { 69 70 name: `valid patch starting with @@`, ··· 72 73 +new line 73 74 context 74 75 `, 75 - expected: true, 76 + expected: nil, 76 77 }, 77 78 { 78 79 name: `valid format patch`, ··· 90 91 +new content 91 92 -- 92 93 2.48.1`, 93 - expected: true, 94 + expected: nil, 94 95 }, 95 96 { 96 97 name: `invalid format patch`, 97 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 99 From: Author <author@example.com> 99 100 This is not a valid patch format`, 100 - expected: false, 101 + expected: FormatPatchError, 101 102 }, 102 103 { 103 104 name: `not a patch at all`, ··· 105 106 just some 106 107 random text 107 108 that isn't a patch`, 108 - expected: false, 109 + expected: GenericPatchError, 109 110 }, 110 111 } 111 112 112 113 for _, tt := range tests { 113 114 t.Run(tt.name, func(t *testing.T) { 114 115 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 + if !errors.Is(result, tt.expected) { 116 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 118 } 118 119 })
+35
spindle/middleware.go
··· 1 + package spindle 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (s *Spindle) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + s.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+6 -6
spindle/server.go
··· 108 108 tangled.RepoNSID, 109 109 tangled.RepoCollaboratorNSID, 110 110 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 112 if err != nil { 113 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 114 } ··· 171 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 172 // job in the above registered queue. 173 173 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 174 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 175 ccfg.Dev = cfg.Server.Dev 176 176 ccfg.ProcessFunc = spindle.processPipeline 177 177 ccfg.CursorStore = cursorStore ··· 210 210 } 211 211 212 212 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 213 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 214 215 + l := log.SubLogger(s.l, "xrpc") 216 + 217 217 x := xrpc.Xrpc{ 218 - Logger: logger, 218 + Logger: l, 219 219 Db: s.db, 220 220 Enforcer: s.e, 221 221 Engines: s.engs, ··· 305 305 306 306 ok := s.jq.Enqueue(queue.Job{ 307 307 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 308 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 311 Workflows: workflows,
+3 -3
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 + "tangled.org/core/log" 13 14 "tangled.org/core/spindle/models" 14 15 15 16 "github.com/go-chi/chi/v5" ··· 23 24 } 24 25 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 + l := log.SubLogger(s.l, "eventstream") 28 + 27 29 l.Debug("received new connection") 28 30 29 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 84 } 83 85 case <-time.After(30 * time.Second): 84 86 // send a keep-alive 85 - l.Debug("sent keepalive") 86 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 88 l.Error("failed to write control", "err", err) 88 89 } ··· 222 223 s.l.Debug("err", "err", err) 223 224 return err 224 225 } 225 - s.l.Debug("ops", "ops", events) 226 226 227 227 for _, event := range events { 228 228 // first extract the inner json into a map
+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),