+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/test.yml
+1
-1
.tangled/workflows/test.yml
+30
api/tangled/repodeleteBranch.go
+30
api/tangled/repodeleteBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.deleteBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch"
15
+
)
16
+
17
+
// RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call.
18
+
type RepoDeleteBranch_Input struct {
19
+
Branch string `json:"branch" cborgen:"branch"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch".
24
+
func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
-1
appview/db/artifact.go
-1
appview/db/artifact.go
+53
appview/db/collaborators.go
+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
+45
-28
appview/db/db.go
···
4
4
"context"
5
5
"database/sql"
6
6
"fmt"
7
-
"log"
7
+
"log/slog"
8
8
"reflect"
9
9
"strings"
10
10
11
11
_ "github.com/mattn/go-sqlite3"
12
+
"tangled.org/core/log"
12
13
)
13
14
14
15
type DB struct {
15
16
*sql.DB
17
+
logger *slog.Logger
16
18
}
17
19
18
20
type Execer interface {
···
26
28
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
27
29
}
28
30
29
-
func Make(dbPath string) (*DB, error) {
31
+
func Make(ctx context.Context, dbPath string) (*DB, error) {
30
32
// https://github.com/mattn/go-sqlite3#connection-string
31
33
opts := []string{
32
34
"_foreign_keys=1",
···
35
37
"_auto_vacuum=incremental",
36
38
}
37
39
40
+
logger := log.FromContext(ctx)
41
+
logger = log.SubLogger(logger, "db")
42
+
38
43
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
39
44
if err != nil {
40
45
return nil, err
41
46
}
42
-
43
-
ctx := context.Background()
44
47
45
48
conn, err := db.Conn(ctx)
46
49
if err != nil {
···
574
577
}
575
578
576
579
// run migrations
577
-
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
580
+
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
578
581
tx.Exec(`
579
582
alter table repos add column description text check (length(description) <= 200);
580
583
`)
581
584
return nil
582
585
})
583
586
584
-
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
587
+
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
585
588
// add unconstrained column
586
589
_, err := tx.Exec(`
587
590
alter table public_keys
···
604
607
return nil
605
608
})
606
609
607
-
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
610
+
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
608
611
_, err := tx.Exec(`
609
612
alter table comments drop column comment_at;
610
613
alter table comments add column rkey text;
···
612
615
return err
613
616
})
614
617
615
-
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
618
+
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
616
619
_, err := tx.Exec(`
617
620
alter table comments add column deleted text; -- timestamp
618
621
alter table comments add column edited text; -- timestamp
···
620
623
return err
621
624
})
622
625
623
-
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
626
+
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
624
627
_, err := tx.Exec(`
625
628
alter table pulls add column source_branch text;
626
629
alter table pulls add column source_repo_at text;
···
629
632
return err
630
633
})
631
634
632
-
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
635
+
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
633
636
_, err := tx.Exec(`
634
637
alter table repos add column source text;
635
638
`)
···
641
644
//
642
645
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
643
646
conn.ExecContext(ctx, "pragma foreign_keys = off;")
644
-
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
647
+
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
645
648
_, err := tx.Exec(`
646
649
create table pulls_new (
647
650
-- identifiers
···
698
701
})
699
702
conn.ExecContext(ctx, "pragma foreign_keys = on;")
700
703
701
-
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
704
+
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
702
705
tx.Exec(`
703
706
alter table repos add column spindle text;
704
707
`)
···
708
711
// drop all knot secrets, add unique constraint to knots
709
712
//
710
713
// knots will henceforth use service auth for signed requests
711
-
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
714
+
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
712
715
_, err := tx.Exec(`
713
716
create table registrations_new (
714
717
id integer primary key autoincrement,
···
731
734
})
732
735
733
736
// recreate and add rkey + created columns with default constraint
734
-
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
737
+
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
735
738
// create new table
736
739
// - repo_at instead of repo integer
737
740
// - rkey field
···
785
788
return err
786
789
})
787
790
788
-
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
791
+
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
789
792
_, err := tx.Exec(`
790
793
alter table issues add column rkey text not null default '';
791
794
···
797
800
})
798
801
799
802
// repurpose the read-only column to "needs-upgrade"
800
-
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
803
+
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
801
804
_, err := tx.Exec(`
802
805
alter table registrations rename column read_only to needs_upgrade;
803
806
`)
···
805
808
})
806
809
807
810
// require all knots to upgrade after the release of total xrpc
808
-
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
811
+
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
809
812
_, err := tx.Exec(`
810
813
update registrations set needs_upgrade = 1;
811
814
`)
···
813
816
})
814
817
815
818
// require all knots to upgrade after the release of total xrpc
816
-
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
819
+
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
817
820
_, err := tx.Exec(`
818
821
alter table spindles add column needs_upgrade integer not null default 0;
819
822
`)
···
831
834
//
832
835
// disable foreign-keys for the next migration
833
836
conn.ExecContext(ctx, "pragma foreign_keys = off;")
834
-
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
837
+
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
835
838
_, err := tx.Exec(`
836
839
create table if not exists issues_new (
837
840
-- identifiers
···
901
904
// - new columns
902
905
// * column "reply_to" which can be any other comment
903
906
// * column "at-uri" which is a generated column
904
-
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
907
+
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
905
908
_, err := tx.Exec(`
906
909
create table if not exists issue_comments (
907
910
-- identifiers
···
961
964
//
962
965
// disable foreign-keys for the next migration
963
966
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
-
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
967
+
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
968
_, err := tx.Exec(`
966
969
create table if not exists pulls_new (
967
970
-- identifiers
···
1042
1045
//
1043
1046
// disable foreign-keys for the next migration
1044
1047
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
-
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1048
+
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
1049
_, err := tx.Exec(`
1047
1050
create table if not exists pull_submissions_new (
1048
1051
-- identifiers
···
1094
1097
})
1095
1098
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
1099
1097
-
return &DB{db}, nil
1100
+
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
+
// (but not on the pds record), because calculating the combined patch requires a git index
1102
+
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
+
_, err := tx.Exec(`
1104
+
alter table pull_submissions add column combined text;
1105
+
`)
1106
+
return err
1107
+
})
1108
+
1109
+
return &DB{
1110
+
db,
1111
+
logger,
1112
+
}, nil
1098
1113
}
1099
1114
1100
1115
type migrationFn = func(*sql.Tx) error
1101
1116
1102
-
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
1117
+
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1118
+
logger = logger.With("migration", name)
1119
+
1103
1120
tx, err := c.BeginTx(context.Background(), nil)
1104
1121
if err != nil {
1105
1122
return err
···
1116
1133
// run migration
1117
1134
err = migrationFn(tx)
1118
1135
if err != nil {
1119
-
log.Printf("Failed to run migration %s: %v", name, err)
1136
+
logger.Error("failed to run migration", "err", err)
1120
1137
return err
1121
1138
}
1122
1139
1123
1140
// mark migration as complete
1124
1141
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1125
1142
if err != nil {
1126
-
log.Printf("Failed to mark migration %s as complete: %v", name, err)
1143
+
logger.Error("failed to mark migration as complete", "err", err)
1127
1144
return err
1128
1145
}
1129
1146
···
1132
1149
return err
1133
1150
}
1134
1151
1135
-
log.Printf("migration %s applied successfully", name)
1152
+
logger.Info("migration applied successfully")
1136
1153
} else {
1137
-
log.Printf("skipped migration %s, already applied", name)
1154
+
logger.Warn("skipped migration, already applied")
1138
1155
}
1139
1156
1140
1157
return nil
+8
-25
appview/db/issues.go
+8
-25
appview/db/issues.go
···
101
101
pLower := FilterGte("row_num", page.Offset+1)
102
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
103
104
-
args = append(args, pLower.Arg()...)
105
-
args = append(args, pUpper.Arg()...)
106
-
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
104
+
pageClause := ""
105
+
if page.Limit > 0 {
106
+
args = append(args, pLower.Arg()...)
107
+
args = append(args, pUpper.Arg()...)
108
+
pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition()
109
+
}
107
110
108
111
query := fmt.Sprintf(
109
112
`
···
128
131
%s
129
132
`,
130
133
whereClause,
131
-
pagination,
134
+
pageClause,
132
135
)
133
136
134
137
rows, err := e.Query(query, args...)
···
244
247
}
245
248
246
249
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
-
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
248
-
}
249
-
250
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
-
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
-
row := e.QueryRow(query, repoAt, issueId)
253
-
254
-
var issue models.Issue
255
-
var createdAt string
256
-
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
257
-
if err != nil {
258
-
return nil, err
259
-
}
260
-
261
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
262
-
if err != nil {
263
-
return nil, err
264
-
}
265
-
issue.Created = createdTime
266
-
267
-
return &issue, nil
250
+
return GetIssuesPaginated(e, pagination.Page{}, filters...)
268
251
}
269
252
270
253
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+34
appview/db/language.go
+34
appview/db/language.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
4
5
"fmt"
5
6
"strings"
6
7
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
9
"tangled.org/core/appview/models"
8
10
)
9
11
···
82
84
83
85
return nil
84
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+88
-49
appview/db/notifications.go
+88
-49
appview/db/notifications.go
···
8
8
"strings"
9
9
"time"
10
10
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
12
"tangled.org/core/appview/models"
12
13
"tangled.org/core/appview/pagination"
13
14
)
14
15
15
-
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
func CreateNotification(e Execer, notification *models.Notification) error {
16
17
query := `
17
18
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
18
19
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19
20
`
20
21
21
-
result, err := d.DB.ExecContext(ctx, query,
22
+
result, err := e.Exec(query,
22
23
notification.RecipientDid,
23
24
notification.ActorDid,
24
25
string(notification.Type),
···
59
60
whereClause += " AND " + condition
60
61
}
61
62
}
63
+
pageClause := ""
64
+
if page.Limit > 0 {
65
+
pageClause = " limit ? offset ? "
66
+
args = append(args, page.Limit, page.Offset)
67
+
}
62
68
63
69
query := fmt.Sprintf(`
64
70
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
65
71
from notifications
66
72
%s
67
73
order by created desc
68
-
limit ? offset ?
69
-
`, whereClause)
70
-
71
-
args = append(args, page.Limit, page.Offset)
74
+
%s
75
+
`, whereClause, pageClause)
72
76
73
77
rows, err := e.QueryContext(context.Background(), query, args...)
74
78
if err != nil {
···
274
278
return count, nil
275
279
}
276
280
277
-
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
281
+
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
278
282
idFilter := FilterEq("id", notificationID)
279
283
recipientFilter := FilterEq("recipient_did", userDID)
280
284
···
286
290
287
291
args := append(idFilter.Arg(), recipientFilter.Arg()...)
288
292
289
-
result, err := d.DB.ExecContext(ctx, query, args...)
293
+
result, err := e.Exec(query, args...)
290
294
if err != nil {
291
295
return fmt.Errorf("failed to mark notification as read: %w", err)
292
296
}
···
303
307
return nil
304
308
}
305
309
306
-
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
310
+
func MarkAllNotificationsRead(e Execer, userDID string) error {
307
311
recipientFilter := FilterEq("recipient_did", userDID)
308
312
readFilter := FilterEq("read", 0)
309
313
···
315
319
316
320
args := append(recipientFilter.Arg(), readFilter.Arg()...)
317
321
318
-
_, err := d.DB.ExecContext(ctx, query, args...)
322
+
_, err := e.Exec(query, args...)
319
323
if err != nil {
320
324
return fmt.Errorf("failed to mark all notifications as read: %w", err)
321
325
}
···
323
327
return nil
324
328
}
325
329
326
-
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
330
+
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
327
331
idFilter := FilterEq("id", notificationID)
328
332
recipientFilter := FilterEq("recipient_did", userDID)
329
333
···
334
338
335
339
args := append(idFilter.Arg(), recipientFilter.Arg()...)
336
340
337
-
result, err := d.DB.ExecContext(ctx, query, args...)
341
+
result, err := e.Exec(query, args...)
338
342
if err != nil {
339
343
return fmt.Errorf("failed to delete notification: %w", err)
340
344
}
···
351
355
return nil
352
356
}
353
357
354
-
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
-
userFilter := FilterEq("user_did", userDID)
358
+
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
359
+
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
360
+
if err != nil {
361
+
return nil, err
362
+
}
363
+
364
+
p, ok := prefs[syntax.DID(userDid)]
365
+
if !ok {
366
+
return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil
367
+
}
368
+
369
+
return p, nil
370
+
}
371
+
372
+
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
373
+
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
374
+
375
+
var conditions []string
376
+
var args []any
377
+
for _, filter := range filters {
378
+
conditions = append(conditions, filter.Condition())
379
+
args = append(args, filter.Arg()...)
380
+
}
381
+
382
+
whereClause := ""
383
+
if conditions != nil {
384
+
whereClause = " where " + strings.Join(conditions, " and ")
385
+
}
356
386
357
387
query := fmt.Sprintf(`
358
-
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
-
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
-
FROM notification_preferences
361
-
WHERE %s
362
-
`, userFilter.Condition())
363
-
364
-
var prefs models.NotificationPreferences
365
-
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
-
&prefs.ID,
367
-
&prefs.UserDid,
368
-
&prefs.RepoStarred,
369
-
&prefs.IssueCreated,
370
-
&prefs.IssueCommented,
371
-
&prefs.PullCreated,
372
-
&prefs.PullCommented,
373
-
&prefs.Followed,
374
-
&prefs.PullMerged,
375
-
&prefs.IssueClosed,
376
-
&prefs.EmailNotifications,
377
-
)
388
+
select
389
+
id,
390
+
user_did,
391
+
repo_starred,
392
+
issue_created,
393
+
issue_commented,
394
+
pull_created,
395
+
pull_commented,
396
+
followed,
397
+
pull_merged,
398
+
issue_closed,
399
+
email_notifications
400
+
from
401
+
notification_preferences
402
+
%s
403
+
`, whereClause)
378
404
405
+
rows, err := e.Query(query, args...)
379
406
if err != nil {
380
-
if err == sql.ErrNoRows {
381
-
return &models.NotificationPreferences{
382
-
UserDid: userDID,
383
-
RepoStarred: true,
384
-
IssueCreated: true,
385
-
IssueCommented: true,
386
-
PullCreated: true,
387
-
PullCommented: true,
388
-
Followed: true,
389
-
PullMerged: true,
390
-
IssueClosed: true,
391
-
EmailNotifications: false,
392
-
}, nil
407
+
return nil, err
408
+
}
409
+
defer rows.Close()
410
+
411
+
for rows.Next() {
412
+
var prefs models.NotificationPreferences
413
+
if err := rows.Scan(
414
+
&prefs.ID,
415
+
&prefs.UserDid,
416
+
&prefs.RepoStarred,
417
+
&prefs.IssueCreated,
418
+
&prefs.IssueCommented,
419
+
&prefs.PullCreated,
420
+
&prefs.PullCommented,
421
+
&prefs.Followed,
422
+
&prefs.PullMerged,
423
+
&prefs.IssueClosed,
424
+
&prefs.EmailNotifications,
425
+
); err != nil {
426
+
return nil, err
393
427
}
394
-
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
428
+
429
+
prefsMap[prefs.UserDid] = &prefs
395
430
}
396
431
397
-
return &prefs, nil
432
+
if err := rows.Err(); err != nil {
433
+
return nil, err
434
+
}
435
+
436
+
return prefsMap, nil
398
437
}
399
438
400
439
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
+23
-20
appview/db/pulls.go
+23
-20
appview/db/pulls.go
···
90
90
pull.ID = int(id)
91
91
92
92
_, err = tx.Exec(`
93
-
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
-
values (?, ?, ?, ?)
95
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
93
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
+
values (?, ?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
96
return err
97
97
}
98
98
···
246
246
// collect pull source for all pulls that need it
247
247
var sourceAts []syntax.ATURI
248
248
for _, p := range pulls {
249
-
if p.PullSource.RepoAt != nil {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
250
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
251
}
252
252
}
···
259
259
sourceRepoMap[r.RepoAt()] = &r
260
260
}
261
261
for _, p := range pulls {
262
-
if p.PullSource.RepoAt != nil {
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
263
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
264
p.PullSource.Repo = sourceRepo
265
265
}
···
313
313
pull_at,
314
314
round_number,
315
315
patch,
316
+
combined,
316
317
created,
317
318
source_rev
318
319
from
···
332
333
333
334
for rows.Next() {
334
335
var submission models.PullSubmission
335
-
var createdAt string
336
-
var sourceRev sql.NullString
336
+
var submissionCreatedStr string
337
+
var submissionSourceRev, submissionCombined sql.NullString
337
338
err := rows.Scan(
338
339
&submission.ID,
339
340
&submission.PullAt,
340
341
&submission.RoundNumber,
341
342
&submission.Patch,
342
-
&createdAt,
343
-
&sourceRev,
343
+
&submissionCombined,
344
+
&submissionCreatedStr,
345
+
&submissionSourceRev,
344
346
)
345
347
if err != nil {
346
348
return nil, err
347
349
}
348
350
349
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
-
if err != nil {
351
-
return nil, err
351
+
if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil {
352
+
submission.Created = t
352
353
}
353
-
submission.Created = createdTime
354
354
355
-
if sourceRev.Valid {
356
-
submission.SourceRev = sourceRev.String
355
+
if submissionSourceRev.Valid {
356
+
submission.SourceRev = submissionSourceRev.String
357
+
}
358
+
359
+
if submissionCombined.Valid {
360
+
submission.Combined = submissionCombined.String
357
361
}
358
362
359
363
submissionMap[submission.ID] = &submission
···
590
594
return err
591
595
}
592
596
593
-
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
594
-
newRoundNumber := len(pull.Submissions)
597
+
func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error {
595
598
_, err := e.Exec(`
596
-
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
-
values (?, ?, ?, ?)
598
-
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
599
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
600
+
values (?, ?, ?, ?, ?)
601
+
`, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
599
602
600
603
return err
601
604
}
+34
-7
appview/db/reaction.go
+34
-7
appview/db/reaction.go
···
62
62
return count, nil
63
63
}
64
64
65
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
-
countMap := map[models.ReactionKind]int{}
65
+
func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) {
66
+
query := `
67
+
select kind, reacted_by_did,
68
+
row_number() over (partition by kind order by created asc) as rn,
69
+
count(*) over (partition by kind) as total
70
+
from reactions
71
+
where thread_at = ?
72
+
order by kind, created asc`
73
+
74
+
rows, err := e.Query(query, threadAt)
75
+
if err != nil {
76
+
return nil, err
77
+
}
78
+
defer rows.Close()
79
+
80
+
reactionMap := map[models.ReactionKind]models.ReactionDisplayData{}
67
81
for _, kind := range models.OrderedReactionKinds {
68
-
count, err := GetReactionCount(e, threadAt, kind)
69
-
if err != nil {
70
-
return map[models.ReactionKind]int{}, nil
82
+
reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}}
83
+
}
84
+
85
+
for rows.Next() {
86
+
var kind models.ReactionKind
87
+
var did string
88
+
var rn, total int
89
+
if err := rows.Scan(&kind, &did, &rn, &total); err != nil {
90
+
return nil, err
71
91
}
72
-
countMap[kind] = count
92
+
93
+
data := reactionMap[kind]
94
+
data.Count = total
95
+
if userLimit > 0 && rn <= userLimit {
96
+
data.Users = append(data.Users, did)
97
+
}
98
+
reactionMap[kind] = data
73
99
}
74
-
return countMap, nil
100
+
101
+
return reactionMap, rows.Err()
75
102
}
76
103
77
104
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+38
-10
appview/db/timeline.go
+38
-10
appview/db/timeline.go
···
9
9
10
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
13
13
var events []models.TimelineEvent
14
14
15
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
15
+
var userIsFollowing []string
16
+
if limitToUsersIsFollowing {
17
+
following, err := GetFollowing(e, loggedInUserDid)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
userIsFollowing = make([]string, 0, len(following))
23
+
for _, follow := range following {
24
+
userIsFollowing = append(userIsFollowing, follow.SubjectDid)
25
+
}
26
+
}
27
+
28
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing)
16
29
if err != nil {
17
30
return nil, err
18
31
}
19
32
20
-
stars, err := getTimelineStars(e, limit, loggedInUserDid)
33
+
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
21
34
if err != nil {
22
35
return nil, err
23
36
}
24
37
25
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
38
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
26
39
if err != nil {
27
40
return nil, err
28
41
}
···
70
83
return isStarred, starCount
71
84
}
72
85
73
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
-
repos, err := GetRepos(e, limit)
86
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
+
filters := make([]filter, 0)
88
+
if userIsFollowing != nil {
89
+
filters = append(filters, FilterIn("did", userIsFollowing))
90
+
}
91
+
92
+
repos, err := GetRepos(e, limit, filters...)
75
93
if err != nil {
76
94
return nil, err
77
95
}
···
125
143
return events, nil
126
144
}
127
145
128
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
-
stars, err := GetStars(e, limit)
146
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
+
filters := make([]filter, 0)
148
+
if userIsFollowing != nil {
149
+
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
+
}
151
+
152
+
stars, err := GetStars(e, limit, filters...)
130
153
if err != nil {
131
154
return nil, err
132
155
}
···
166
189
return events, nil
167
190
}
168
191
169
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
-
follows, err := GetFollows(e, limit)
192
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
193
+
filters := make([]filter, 0)
194
+
if userIsFollowing != nil {
195
+
filters = append(filters, FilterIn("user_did", userIsFollowing))
196
+
}
197
+
198
+
follows, err := GetFollows(e, limit, filters...)
171
199
if err != nil {
172
200
return nil, err
173
201
}
+4
-4
appview/dns/cloudflare.go
+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
+1
-1
appview/ingester.go
+41
-43
appview/issues/issues.go
+41
-43
appview/issues/issues.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"slices"
12
11
"time"
13
12
14
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
15
15
"github.com/bluesky-social/indigo/atproto/syntax"
16
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
17
"github.com/go-chi/chi/v5"
···
26
26
"tangled.org/core/appview/pagination"
27
27
"tangled.org/core/appview/reporesolver"
28
28
"tangled.org/core/appview/validator"
29
-
"tangled.org/core/appview/xrpcclient"
30
29
"tangled.org/core/idresolver"
31
-
tlog "tangled.org/core/log"
32
30
"tangled.org/core/tid"
33
31
)
34
32
···
53
51
config *config.Config,
54
52
notifier notify.Notifier,
55
53
validator *validator.Validator,
54
+
logger *slog.Logger,
56
55
) *Issues {
57
56
return &Issues{
58
57
oauth: oauth,
···
62
61
db: db,
63
62
config: config,
64
63
notifier: notifier,
65
-
logger: tlog.New("issues"),
64
+
logger: logger,
66
65
validator: validator,
67
66
}
68
67
}
···
72
71
user := rp.oauth.GetUser(r)
73
72
f, err := rp.repoResolver.Resolve(r)
74
73
if err != nil {
75
-
log.Println("failed to get repo and knot", err)
74
+
l.Error("failed to get repo and knot", "err", err)
76
75
return
77
76
}
78
77
···
83
82
return
84
83
}
85
84
86
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
85
+
reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
87
86
if err != nil {
88
87
l.Error("failed to get issue reactions", "err", err)
89
88
}
···
99
98
db.FilterContains("scope", tangled.RepoIssueNSID),
100
99
)
101
100
if err != nil {
102
-
log.Println("failed to fetch labels", err)
101
+
l.Error("failed to fetch labels", "err", err)
103
102
rp.pages.Error503(w)
104
103
return
105
104
}
···
115
114
Issue: issue,
116
115
CommentList: issue.CommentList(),
117
116
OrderedReactionKinds: models.OrderedReactionKinds,
118
-
Reactions: reactionCountMap,
117
+
Reactions: reactionMap,
119
118
UserReacted: userReactions,
120
119
LabelDefs: defs,
121
120
})
···
126
125
user := rp.oauth.GetUser(r)
127
126
f, err := rp.repoResolver.Resolve(r)
128
127
if err != nil {
129
-
log.Println("failed to get repo and knot", err)
128
+
l.Error("failed to get repo and knot", "err", err)
130
129
return
131
130
}
132
131
···
166
165
return
167
166
}
168
167
169
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
168
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
170
169
if err != nil {
171
170
l.Error("failed to get record", "err", err)
172
171
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
173
172
return
174
173
}
175
174
176
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
175
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
177
176
Collection: tangled.RepoIssueNSID,
178
177
Repo: user.Did,
179
178
Rkey: newIssue.Rkey,
···
199
198
200
199
err = db.PutIssue(tx, newIssue)
201
200
if err != nil {
202
-
log.Println("failed to edit issue", err)
201
+
l.Error("failed to edit issue", "err", err)
203
202
rp.pages.Notice(w, "issues", "Failed to edit issue.")
204
203
return
205
204
}
···
237
236
// delete from PDS
238
237
client, err := rp.oauth.AuthorizedClient(r)
239
238
if err != nil {
240
-
log.Println("failed to get authorized client", err)
239
+
l.Error("failed to get authorized client", "err", err)
241
240
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
242
241
return
243
242
}
244
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
243
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
245
244
Collection: tangled.RepoIssueNSID,
246
245
Repo: issue.Did,
247
246
Rkey: issue.Rkey,
···
282
281
283
282
collaborators, err := f.Collaborators(r.Context())
284
283
if err != nil {
285
-
log.Println("failed to fetch repo collaborators: %w", err)
284
+
l.Error("failed to fetch repo collaborators", "err", err)
286
285
}
287
286
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
288
287
return user.Did == collab.Did
···
296
295
db.FilterEq("id", issue.Id),
297
296
)
298
297
if err != nil {
299
-
log.Println("failed to close issue", err)
298
+
l.Error("failed to close issue", "err", err)
300
299
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
301
300
return
302
301
}
···
307
306
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
308
307
return
309
308
} else {
310
-
log.Println("user is not permitted to close issue")
309
+
l.Error("user is not permitted to close issue")
311
310
http.Error(w, "for biden", http.StatusUnauthorized)
312
311
return
313
312
}
···
318
317
user := rp.oauth.GetUser(r)
319
318
f, err := rp.repoResolver.Resolve(r)
320
319
if err != nil {
321
-
log.Println("failed to get repo and knot", err)
320
+
l.Error("failed to get repo and knot", "err", err)
322
321
return
323
322
}
324
323
···
331
330
332
331
collaborators, err := f.Collaborators(r.Context())
333
332
if err != nil {
334
-
log.Println("failed to fetch repo collaborators: %w", err)
333
+
l.Error("failed to fetch repo collaborators", "err", err)
335
334
}
336
335
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
337
336
return user.Did == collab.Did
···
344
343
db.FilterEq("id", issue.Id),
345
344
)
346
345
if err != nil {
347
-
log.Println("failed to reopen issue", err)
346
+
l.Error("failed to reopen issue", "err", err)
348
347
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
349
348
return
350
349
}
351
350
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
352
351
return
353
352
} else {
354
-
log.Println("user is not the owner of the repo")
353
+
l.Error("user is not the owner of the repo")
355
354
http.Error(w, "forbidden", http.StatusUnauthorized)
356
355
return
357
356
}
···
408
407
}
409
408
410
409
// create a record first
411
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
410
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
412
411
Collection: tangled.RepoIssueCommentNSID,
413
412
Repo: comment.Did,
414
413
Rkey: comment.Rkey,
···
538
537
newBody := r.FormValue("body")
539
538
client, err := rp.oauth.AuthorizedClient(r)
540
539
if err != nil {
541
-
log.Println("failed to get authorized client", err)
540
+
l.Error("failed to get authorized client", "err", err)
542
541
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
543
542
return
544
543
}
···
551
550
552
551
_, err = db.AddIssueComment(rp.db, newComment)
553
552
if err != nil {
554
-
log.Println("failed to perferom update-description query", err)
553
+
l.Error("failed to perferom update-description query", "err", err)
555
554
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
556
555
return
557
556
}
···
559
558
// rkey is optional, it was introduced later
560
559
if newComment.Rkey != "" {
561
560
// update the record on pds
562
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
561
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
563
562
if err != nil {
564
-
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
563
+
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
565
564
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
566
565
return
567
566
}
568
567
569
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
568
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
570
569
Collection: tangled.RepoIssueCommentNSID,
571
570
Repo: user.Did,
572
571
Rkey: newComment.Rkey,
···
729
728
if comment.Rkey != "" {
730
729
client, err := rp.oauth.AuthorizedClient(r)
731
730
if err != nil {
732
-
log.Println("failed to get authorized client", err)
731
+
l.Error("failed to get authorized client", "err", err)
733
732
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
734
733
return
735
734
}
736
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
735
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
737
736
Collection: tangled.RepoIssueCommentNSID,
738
737
Repo: user.Did,
739
738
Rkey: comment.Rkey,
740
739
})
741
740
if err != nil {
742
-
log.Println(err)
741
+
l.Error("failed to delete from PDS", "err", err)
743
742
}
744
743
}
745
744
···
757
756
}
758
757
759
758
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
759
+
l := rp.logger.With("handler", "RepoIssues")
760
+
760
761
params := r.URL.Query()
761
762
state := params.Get("state")
762
763
isOpen := true
···
769
770
isOpen = true
770
771
}
771
772
772
-
page, ok := r.Context().Value("page").(pagination.Page)
773
-
if !ok {
774
-
log.Println("failed to get page")
775
-
page = pagination.FirstPage()
776
-
}
773
+
page := pagination.FromContext(r.Context())
777
774
778
775
user := rp.oauth.GetUser(r)
779
776
f, err := rp.repoResolver.Resolve(r)
780
777
if err != nil {
781
-
log.Println("failed to get repo and knot", err)
778
+
l.Error("failed to get repo and knot", "err", err)
782
779
return
783
780
}
784
781
···
793
790
db.FilterEq("open", openVal),
794
791
)
795
792
if err != nil {
796
-
log.Println("failed to get issues", err)
793
+
l.Error("failed to get issues", "err", err)
797
794
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
798
795
return
799
796
}
···
804
801
db.FilterContains("scope", tangled.RepoIssueNSID),
805
802
)
806
803
if err != nil {
807
-
log.Println("failed to fetch labels", err)
804
+
l.Error("failed to fetch labels", "err", err)
808
805
rp.pages.Error503(w)
809
806
return
810
807
}
···
848
845
Body: r.FormValue("body"),
849
846
Did: user.Did,
850
847
Created: time.Now(),
848
+
Repo: &f.Repo,
851
849
}
852
850
853
851
if err := rp.validator.ValidateIssue(issue); err != nil {
···
865
863
rp.pages.Notice(w, "issues", "Failed to create issue.")
866
864
return
867
865
}
868
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
866
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
869
867
Collection: tangled.RepoIssueNSID,
870
868
Repo: user.Did,
871
869
Rkey: issue.Rkey,
···
901
899
902
900
err = db.PutIssue(tx, issue)
903
901
if err != nil {
904
-
log.Println("failed to create issue", err)
902
+
l.Error("failed to create issue", "err", err)
905
903
rp.pages.Notice(w, "issues", "Failed to create issue.")
906
904
return
907
905
}
908
906
909
907
if err = tx.Commit(); err != nil {
910
-
log.Println("failed to create issue", err)
908
+
l.Error("failed to create issue", "err", err)
911
909
rp.pages.Notice(w, "issues", "Failed to create issue.")
912
910
return
913
911
}
···
923
921
// this is used to rollback changes made to the PDS
924
922
//
925
923
// it is a no-op if the provided ATURI is empty
926
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
924
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
927
925
if aturi == "" {
928
926
return nil
929
927
}
···
934
932
repo := parsed.Authority().String()
935
933
rkey := parsed.RecordKey().String()
936
934
937
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
935
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
938
936
Collection: collection,
939
937
Repo: repo,
940
938
Rkey: rkey,
+267
appview/issues/opengraph.go
+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
+1
appview/issues/router.go
+6
-6
appview/knots/knots.go
+6
-6
appview/knots/knots.go
···
185
185
return
186
186
}
187
187
188
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
188
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
189
189
var exCid *string
190
190
if ex != nil {
191
191
exCid = ex.Cid
192
192
}
193
193
194
194
// re-announce by registering under same rkey
195
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
195
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
196
196
Collection: tangled.KnotNSID,
197
197
Repo: user.Did,
198
198
Rkey: domain,
···
323
323
return
324
324
}
325
325
326
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
326
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
327
327
Collection: tangled.KnotNSID,
328
328
Repo: user.Did,
329
329
Rkey: domain,
···
431
431
return
432
432
}
433
433
434
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
434
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
435
435
var exCid *string
436
436
if ex != nil {
437
437
exCid = ex.Cid
438
438
}
439
439
440
440
// ignore the error here
441
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
441
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
442
442
Collection: tangled.KnotNSID,
443
443
Repo: user.Did,
444
444
Rkey: domain,
···
555
555
556
556
rkey := tid.TID()
557
557
558
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
558
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
559
559
Collection: tangled.KnotMemberNSID,
560
560
Repo: user.Did,
561
561
Rkey: rkey,
+10
-12
appview/labels/labels.go
+10
-12
appview/labels/labels.go
···
9
9
"net/http"
10
10
"time"
11
11
12
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
"github.com/go-chi/chi/v5"
16
-
17
12
"tangled.org/core/api/tangled"
18
13
"tangled.org/core/appview/db"
19
14
"tangled.org/core/appview/middleware"
···
21
16
"tangled.org/core/appview/oauth"
22
17
"tangled.org/core/appview/pages"
23
18
"tangled.org/core/appview/validator"
24
-
"tangled.org/core/appview/xrpcclient"
25
-
"tangled.org/core/log"
26
19
"tangled.org/core/rbac"
27
20
"tangled.org/core/tid"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
24
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
+
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
"github.com/go-chi/chi/v5"
28
27
)
29
28
30
29
type Labels struct {
···
42
41
db *db.DB,
43
42
validator *validator.Validator,
44
43
enforcer *rbac.Enforcer,
44
+
logger *slog.Logger,
45
45
) *Labels {
46
-
logger := log.New("labels")
47
-
48
46
return &Labels{
49
47
oauth: oauth,
50
48
pages: pages,
···
196
194
return
197
195
}
198
196
199
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
197
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
198
Collection: tangled.LabelOpNSID,
201
199
Repo: did,
202
200
Rkey: rkey,
···
252
250
// this is used to rollback changes made to the PDS
253
251
//
254
252
// it is a no-op if the provided ATURI is empty
255
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
253
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
256
254
if aturi == "" {
257
255
return nil
258
256
}
···
263
261
repo := parsed.Authority().String()
264
262
rkey := parsed.RecordKey().String()
265
263
266
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
264
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
267
265
Collection: collection,
268
266
Repo: repo,
269
267
Rkey: rkey,
+6
-15
appview/middleware/middleware.go
+6
-15
appview/middleware/middleware.go
···
43
43
44
44
type middlewareFunc func(http.Handler) http.Handler
45
45
46
-
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
-
return func(next http.Handler) http.Handler {
48
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
-
_, _, _ = mw.oauth.GetSession(r)
50
-
next.ServeHTTP(w, r)
51
-
})
52
-
}
53
-
}
54
-
55
-
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
46
+
func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
56
47
return func(next http.Handler) http.Handler {
57
48
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
49
returnURL := "/"
···
72
63
}
73
64
}
74
65
75
-
_, auth, err := a.GetSession(r)
66
+
sess, err := o.ResumeSession(r)
76
67
if err != nil {
77
-
log.Println("not logged in, redirecting", "err", err)
68
+
log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String())
78
69
redirectFunc(w, r)
79
70
return
80
71
}
81
72
82
-
if !auth {
83
-
log.Printf("not logged in, redirecting")
73
+
if sess == nil {
74
+
log.Printf("session is nil, redirecting...")
84
75
redirectFunc(w, r)
85
76
return
86
77
}
···
114
105
}
115
106
}
116
107
117
-
ctx := context.WithValue(r.Context(), "page", page)
108
+
ctx := pagination.IntoContext(r.Context(), page)
118
109
next.ServeHTTP(w, r.WithContext(ctx))
119
110
})
120
111
}
+24
appview/models/issue.go
+24
appview/models/issue.go
···
54
54
Replies []*IssueComment
55
55
}
56
56
57
+
func (it *CommentListItem) Participants() []syntax.DID {
58
+
participantSet := make(map[syntax.DID]struct{})
59
+
participants := []syntax.DID{}
60
+
61
+
addParticipant := func(did syntax.DID) {
62
+
if _, exists := participantSet[did]; !exists {
63
+
participantSet[did] = struct{}{}
64
+
participants = append(participants, did)
65
+
}
66
+
}
67
+
68
+
addParticipant(syntax.DID(it.Self.Did))
69
+
70
+
for _, c := range it.Replies {
71
+
addParticipant(syntax.DID(c.Did))
72
+
}
73
+
74
+
return participants
75
+
}
76
+
57
77
func (i *Issue) CommentList() []CommentListItem {
58
78
// Create a map to quickly find comments by their aturi
59
79
toplevel := make(map[string]*CommentListItem)
···
167
187
168
188
func (i *IssueComment) IsTopLevel() bool {
169
189
return i.ReplyTo == nil
190
+
}
191
+
192
+
func (i *IssueComment) IsReply() bool {
193
+
return i.ReplyTo != nil
170
194
}
171
195
172
196
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+14
-13
appview/models/label.go
+14
-13
appview/models/label.go
···
461
461
return result
462
462
}
463
463
464
+
var (
465
+
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
+
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
+
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
+
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
+
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
+
)
471
+
464
472
func DefaultLabelDefs() []string {
465
-
rkeys := []string{
466
-
"wontfix",
467
-
"duplicate",
468
-
"assignee",
469
-
"good-first-issue",
470
-
"documentation",
473
+
return []string{
474
+
LabelWontfix,
475
+
LabelDuplicate,
476
+
LabelAssignee,
477
+
LabelGoodFirstIssue,
478
+
LabelDocumentation,
471
479
}
472
-
473
-
defs := make([]string, len(rkeys))
474
-
for i, r := range rkeys {
475
-
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
476
-
}
477
-
478
-
return defs
479
480
}
480
481
481
482
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+43
-1
appview/models/notifications.go
+43
-1
appview/models/notifications.go
···
2
2
3
3
import (
4
4
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
5
7
)
6
8
7
9
type NotificationType string
···
69
71
70
72
type NotificationPreferences struct {
71
73
ID int64
72
-
UserDid string
74
+
UserDid syntax.DID
73
75
RepoStarred bool
74
76
IssueCreated bool
75
77
IssueCommented bool
···
80
82
IssueClosed bool
81
83
EmailNotifications bool
82
84
}
85
+
86
+
func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool {
87
+
switch t {
88
+
case NotificationTypeRepoStarred:
89
+
return prefs.RepoStarred
90
+
case NotificationTypeIssueCreated:
91
+
return prefs.IssueCreated
92
+
case NotificationTypeIssueCommented:
93
+
return prefs.IssueCommented
94
+
case NotificationTypeIssueClosed:
95
+
return prefs.IssueClosed
96
+
case NotificationTypePullCreated:
97
+
return prefs.PullCreated
98
+
case NotificationTypePullCommented:
99
+
return prefs.PullCommented
100
+
case NotificationTypePullMerged:
101
+
return prefs.PullMerged
102
+
case NotificationTypePullClosed:
103
+
return prefs.PullMerged // same pref for now
104
+
case NotificationTypeFollowed:
105
+
return prefs.Followed
106
+
default:
107
+
return false
108
+
}
109
+
}
110
+
111
+
func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences {
112
+
return &NotificationPreferences{
113
+
UserDid: user,
114
+
RepoStarred: true,
115
+
IssueCreated: true,
116
+
IssueCommented: true,
117
+
PullCreated: true,
118
+
PullCommented: true,
119
+
Followed: true,
120
+
PullMerged: true,
121
+
IssueClosed: true,
122
+
EmailNotifications: false,
123
+
}
124
+
}
+30
-23
appview/models/pull.go
+30
-23
appview/models/pull.go
···
84
84
func (p Pull) AsRecord() tangled.RepoPull {
85
85
var source *tangled.RepoPull_Source
86
86
if p.PullSource != nil {
87
-
s := p.PullSource.AsRecord()
88
-
source = &s
87
+
source = &tangled.RepoPull_Source{}
88
+
source.Branch = p.PullSource.Branch
89
89
source.Sha = p.LatestSha()
90
+
if p.PullSource.RepoAt != nil {
91
+
s := p.PullSource.RepoAt.String()
92
+
source.Repo = &s
93
+
}
90
94
}
91
95
92
96
record := tangled.RepoPull{
···
111
115
Repo *Repo
112
116
}
113
117
114
-
func (p PullSource) AsRecord() tangled.RepoPull_Source {
115
-
var repoAt *string
116
-
if p.RepoAt != nil {
117
-
s := p.RepoAt.String()
118
-
repoAt = &s
119
-
}
120
-
record := tangled.RepoPull_Source{
121
-
Branch: p.Branch,
122
-
Repo: repoAt,
123
-
}
124
-
return record
125
-
}
126
-
127
118
type PullSubmission struct {
128
119
// ids
129
120
ID int
···
134
125
// content
135
126
RoundNumber int
136
127
Patch string
128
+
Combined string
137
129
Comments []PullComment
138
130
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
139
131
···
159
151
Created time.Time
160
152
}
161
153
154
+
func (p *Pull) LastRoundNumber() int {
155
+
return len(p.Submissions) - 1
156
+
}
157
+
158
+
func (p *Pull) LatestSubmission() *PullSubmission {
159
+
return p.Submissions[p.LastRoundNumber()]
160
+
}
161
+
162
162
func (p *Pull) LatestPatch() string {
163
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
-
return latestSubmission.Patch
163
+
return p.LatestSubmission().Patch
165
164
}
166
165
167
166
func (p *Pull) LatestSha() string {
168
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
169
-
return latestSubmission.SourceRev
167
+
return p.LatestSubmission().SourceRev
170
168
}
171
169
172
170
func (p *Pull) PullAt() syntax.ATURI {
173
171
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
174
-
}
175
-
176
-
func (p *Pull) LastRoundNumber() int {
177
-
return len(p.Submissions) - 1
178
172
}
179
173
180
174
func (p *Pull) IsPatchBased() bool {
···
263
257
return participants
264
258
}
265
259
260
+
func (s PullSubmission) CombinedPatch() string {
261
+
if s.Combined == "" {
262
+
return s.Patch
263
+
}
264
+
265
+
return s.Combined
266
+
}
267
+
266
268
type Stack []*Pull
267
269
268
270
// position of this pull in the stack
···
350
352
351
353
return mergeable
352
354
}
355
+
356
+
type BranchDeleteStatus struct {
357
+
Repo *Repo
358
+
Branch string
359
+
}
+5
appview/models/reaction.go
+5
appview/models/reaction.go
+5
appview/models/repo.go
+5
appview/models/repo.go
+36
-39
appview/notifications/notifications.go
+36
-39
appview/notifications/notifications.go
···
1
1
package notifications
2
2
3
3
import (
4
-
"fmt"
5
-
"log"
4
+
"log/slog"
6
5
"net/http"
7
6
"strconv"
8
7
···
15
14
)
16
15
17
16
type Notifications struct {
18
-
db *db.DB
19
-
oauth *oauth.OAuth
20
-
pages *pages.Pages
17
+
db *db.DB
18
+
oauth *oauth.OAuth
19
+
pages *pages.Pages
20
+
logger *slog.Logger
21
21
}
22
22
23
-
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications {
24
24
return &Notifications{
25
-
db: database,
26
-
oauth: oauthHandler,
27
-
pages: pagesHandler,
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
logger: logger,
28
29
}
29
30
}
30
31
31
32
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
32
33
r := chi.NewRouter()
33
34
34
-
r.Use(middleware.AuthMiddleware(n.oauth))
35
+
r.Get("/count", n.getUnreadCount)
35
36
36
-
r.With(middleware.Paginate).Get("/", n.notificationsPage)
37
-
38
-
r.Get("/count", n.getUnreadCount)
39
-
r.Post("/{id}/read", n.markRead)
40
-
r.Post("/read-all", n.markAllRead)
41
-
r.Delete("/{id}", n.deleteNotification)
37
+
r.Group(func(r chi.Router) {
38
+
r.Use(middleware.AuthMiddleware(n.oauth))
39
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
40
+
r.Post("/{id}/read", n.markRead)
41
+
r.Post("/read-all", n.markAllRead)
42
+
r.Delete("/{id}", n.deleteNotification)
43
+
})
42
44
43
45
return r
44
46
}
45
47
46
48
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
47
-
userDid := n.oauth.GetDid(r)
49
+
l := n.logger.With("handler", "notificationsPage")
50
+
user := n.oauth.GetUser(r)
48
51
49
-
page, ok := r.Context().Value("page").(pagination.Page)
50
-
if !ok {
51
-
log.Println("failed to get page")
52
-
page = pagination.FirstPage()
53
-
}
52
+
page := pagination.FromContext(r.Context())
54
53
55
54
total, err := db.CountNotifications(
56
55
n.db,
57
-
db.FilterEq("recipient_did", userDid),
56
+
db.FilterEq("recipient_did", user.Did),
58
57
)
59
58
if err != nil {
60
-
log.Println("failed to get total notifications:", err)
59
+
l.Error("failed to get total notifications", "err", err)
61
60
n.pages.Error500(w)
62
61
return
63
62
}
···
65
64
notifications, err := db.GetNotificationsWithEntities(
66
65
n.db,
67
66
page,
68
-
db.FilterEq("recipient_did", userDid),
67
+
db.FilterEq("recipient_did", user.Did),
69
68
)
70
69
if err != nil {
71
-
log.Println("failed to get notifications:", err)
70
+
l.Error("failed to get notifications", "err", err)
72
71
n.pages.Error500(w)
73
72
return
74
73
}
75
74
76
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
75
+
err = db.MarkAllNotificationsRead(n.db, user.Did)
77
76
if err != nil {
78
-
log.Println("failed to mark notifications as read:", err)
77
+
l.Error("failed to mark notifications as read", "err", err)
79
78
}
80
79
81
80
unreadCount := 0
82
81
83
-
user := n.oauth.GetUser(r)
84
-
if user == nil {
85
-
http.Error(w, "Failed to get user", http.StatusInternalServerError)
86
-
return
87
-
}
88
-
89
-
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
82
+
n.pages.Notifications(w, pages.NotificationsParams{
90
83
LoggedInUser: user,
91
84
Notifications: notifications,
92
85
UnreadCount: unreadCount,
93
86
Page: page,
94
87
Total: total,
95
-
}))
88
+
})
96
89
}
97
90
98
91
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
99
92
user := n.oauth.GetUser(r)
93
+
if user == nil {
94
+
return
95
+
}
96
+
100
97
count, err := db.CountNotifications(
101
98
n.db,
102
99
db.FilterEq("recipient_did", user.Did),
···
127
124
return
128
125
}
129
126
130
-
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
127
+
err = db.MarkNotificationRead(n.db, notificationID, userDid)
131
128
if err != nil {
132
129
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
133
130
return
···
139
136
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
140
137
userDid := n.oauth.GetDid(r)
141
138
142
-
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
139
+
err := db.MarkAllNotificationsRead(n.db, userDid)
143
140
if err != nil {
144
141
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
145
142
return
···
158
155
return
159
156
}
160
157
161
-
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
158
+
err = db.DeleteNotification(n.db, notificationID, userDid)
162
159
if err != nil {
163
160
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
164
161
return
+303
-251
appview/notify/db/db.go
+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
+42
-50
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
+
"reflect"
6
+
"sync"
5
7
6
8
"tangled.org/core/appview/models"
7
9
)
···
16
18
17
19
var _ Notifier = &mergedNotifier{}
18
20
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
21
+
// fanout calls the same method on all notifiers concurrently
22
+
func (m *mergedNotifier) fanout(method string, args ...any) {
23
+
var wg sync.WaitGroup
24
+
for _, n := range m.notifiers {
25
+
wg.Add(1)
26
+
go func(notifier Notifier) {
27
+
defer wg.Done()
28
+
v := reflect.ValueOf(notifier).MethodByName(method)
29
+
in := make([]reflect.Value, len(args))
30
+
for i, arg := range args {
31
+
in[i] = reflect.ValueOf(arg)
32
+
}
33
+
v.Call(in)
34
+
}(n)
22
35
}
36
+
wg.Wait()
37
+
}
38
+
39
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
40
+
m.fanout("NewRepo", ctx, repo)
23
41
}
24
42
25
43
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
44
+
m.fanout("NewStar", ctx, star)
29
45
}
46
+
30
47
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
48
+
m.fanout("DeleteStar", ctx, star)
34
49
}
35
50
36
51
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
52
+
m.fanout("NewIssue", ctx, issue)
40
53
}
54
+
41
55
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
-
for _, notifier := range m.notifiers {
43
-
notifier.NewIssueComment(ctx, comment)
44
-
}
56
+
m.fanout("NewIssueComment", ctx, comment)
45
57
}
46
58
47
59
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.NewIssueClosed(ctx, issue)
50
-
}
60
+
m.fanout("NewIssueClosed", ctx, issue)
51
61
}
52
62
53
63
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewFollow(ctx, follow)
56
-
}
64
+
m.fanout("NewFollow", ctx, follow)
57
65
}
66
+
58
67
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.DeleteFollow(ctx, follow)
61
-
}
68
+
m.fanout("DeleteFollow", ctx, follow)
62
69
}
63
70
64
71
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
65
-
for _, notifier := range m.notifiers {
66
-
notifier.NewPull(ctx, pull)
67
-
}
72
+
m.fanout("NewPull", ctx, pull)
68
73
}
74
+
69
75
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
-
for _, notifier := range m.notifiers {
71
-
notifier.NewPullComment(ctx, comment)
72
-
}
76
+
m.fanout("NewPullComment", ctx, comment)
73
77
}
74
78
75
79
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
-
for _, notifier := range m.notifiers {
77
-
notifier.NewPullMerged(ctx, pull)
78
-
}
80
+
m.fanout("NewPullMerged", ctx, pull)
79
81
}
80
82
81
83
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
-
for _, notifier := range m.notifiers {
83
-
notifier.NewPullClosed(ctx, pull)
84
-
}
84
+
m.fanout("NewPullClosed", ctx, pull)
85
85
}
86
86
87
87
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
-
for _, notifier := range m.notifiers {
89
-
notifier.UpdateProfile(ctx, profile)
90
-
}
88
+
m.fanout("UpdateProfile", ctx, profile)
91
89
}
92
90
93
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
-
for _, notifier := range m.notifiers {
95
-
notifier.NewString(ctx, string)
96
-
}
91
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
92
+
m.fanout("NewString", ctx, s)
97
93
}
98
94
99
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
-
for _, notifier := range m.notifiers {
101
-
notifier.EditString(ctx, string)
102
-
}
95
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
96
+
m.fanout("EditString", ctx, s)
103
97
}
104
98
105
99
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
for _, notifier := range m.notifiers {
107
-
notifier.DeleteString(ctx, did, rkey)
108
-
}
100
+
m.fanout("DeleteString", ctx, did, rkey)
109
101
}
-24
appview/oauth/client/oauth_client.go
-24
appview/oauth/client/oauth_client.go
···
1
-
package client
2
-
3
-
import (
4
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
5
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
6
-
)
7
-
8
-
type OAuthClient struct {
9
-
*oauth.Client
10
-
}
11
-
12
-
func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) {
13
-
k, err := helpers.ParseJWKFromBytes([]byte(clientJwk))
14
-
if err != nil {
15
-
return nil, err
16
-
}
17
-
18
-
cli, err := oauth.NewClient(oauth.ClientArgs{
19
-
ClientId: clientId,
20
-
ClientJwk: k,
21
-
RedirectUri: redirectUri,
22
-
})
23
-
return &OAuthClient{cli}, err
24
-
}
+2
-1
appview/oauth/consts.go
+2
-1
appview/oauth/consts.go
-538
appview/oauth/handler/handler.go
-538
appview/oauth/handler/handler.go
···
1
-
package oauth
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"encoding/json"
7
-
"fmt"
8
-
"log"
9
-
"net/http"
10
-
"net/url"
11
-
"slices"
12
-
"strings"
13
-
"time"
14
-
15
-
"github.com/go-chi/chi/v5"
16
-
"github.com/gorilla/sessions"
17
-
"github.com/lestrrat-go/jwx/v2/jwk"
18
-
"github.com/posthog/posthog-go"
19
-
tangled "tangled.org/core/api/tangled"
20
-
sessioncache "tangled.org/core/appview/cache/session"
21
-
"tangled.org/core/appview/config"
22
-
"tangled.org/core/appview/db"
23
-
"tangled.org/core/appview/middleware"
24
-
"tangled.org/core/appview/oauth"
25
-
"tangled.org/core/appview/oauth/client"
26
-
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/consts"
28
-
"tangled.org/core/idresolver"
29
-
"tangled.org/core/rbac"
30
-
"tangled.org/core/tid"
31
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
32
-
)
33
-
34
-
const (
35
-
oauthScope = "atproto transition:generic"
36
-
)
37
-
38
-
type OAuthHandler struct {
39
-
config *config.Config
40
-
pages *pages.Pages
41
-
idResolver *idresolver.Resolver
42
-
sess *sessioncache.SessionStore
43
-
db *db.DB
44
-
store *sessions.CookieStore
45
-
oauth *oauth.OAuth
46
-
enforcer *rbac.Enforcer
47
-
posthog posthog.Client
48
-
}
49
-
50
-
func New(
51
-
config *config.Config,
52
-
pages *pages.Pages,
53
-
idResolver *idresolver.Resolver,
54
-
db *db.DB,
55
-
sess *sessioncache.SessionStore,
56
-
store *sessions.CookieStore,
57
-
oauth *oauth.OAuth,
58
-
enforcer *rbac.Enforcer,
59
-
posthog posthog.Client,
60
-
) *OAuthHandler {
61
-
return &OAuthHandler{
62
-
config: config,
63
-
pages: pages,
64
-
idResolver: idResolver,
65
-
db: db,
66
-
sess: sess,
67
-
store: store,
68
-
oauth: oauth,
69
-
enforcer: enforcer,
70
-
posthog: posthog,
71
-
}
72
-
}
73
-
74
-
func (o *OAuthHandler) Router() http.Handler {
75
-
r := chi.NewRouter()
76
-
77
-
r.Get("/login", o.login)
78
-
r.Post("/login", o.login)
79
-
80
-
r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout)
81
-
82
-
r.Get("/oauth/client-metadata.json", o.clientMetadata)
83
-
r.Get("/oauth/jwks.json", o.jwks)
84
-
r.Get("/oauth/callback", o.callback)
85
-
return r
86
-
}
87
-
88
-
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
89
-
w.Header().Set("Content-Type", "application/json")
90
-
w.WriteHeader(http.StatusOK)
91
-
json.NewEncoder(w).Encode(o.oauth.ClientMetadata())
92
-
}
93
-
94
-
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
95
-
jwks := o.config.OAuth.Jwks
96
-
pubKey, err := pubKeyFromJwk(jwks)
97
-
if err != nil {
98
-
log.Printf("error parsing public key: %v", err)
99
-
http.Error(w, err.Error(), http.StatusInternalServerError)
100
-
return
101
-
}
102
-
103
-
response := helpers.CreateJwksResponseObject(pubKey)
104
-
105
-
w.Header().Set("Content-Type", "application/json")
106
-
w.WriteHeader(http.StatusOK)
107
-
json.NewEncoder(w).Encode(response)
108
-
}
109
-
110
-
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
111
-
switch r.Method {
112
-
case http.MethodGet:
113
-
returnURL := r.URL.Query().Get("return_url")
114
-
o.pages.Login(w, pages.LoginParams{
115
-
ReturnUrl: returnURL,
116
-
})
117
-
case http.MethodPost:
118
-
handle := r.FormValue("handle")
119
-
120
-
// when users copy their handle from bsky.app, it tends to have these characters around it:
121
-
//
122
-
// @nelind.dk:
123
-
// \u202a ensures that the handle is always rendered left to right and
124
-
// \u202c reverts that so the rest of the page renders however it should
125
-
handle = strings.TrimPrefix(handle, "\u202a")
126
-
handle = strings.TrimSuffix(handle, "\u202c")
127
-
128
-
// `@` is harmless
129
-
handle = strings.TrimPrefix(handle, "@")
130
-
131
-
// basic handle validation
132
-
if !strings.Contains(handle, ".") {
133
-
log.Println("invalid handle format", "raw", handle)
134
-
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle))
135
-
return
136
-
}
137
-
138
-
resolved, err := o.idResolver.ResolveIdent(r.Context(), handle)
139
-
if err != nil {
140
-
log.Println("failed to resolve handle:", err)
141
-
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
142
-
return
143
-
}
144
-
self := o.oauth.ClientMetadata()
145
-
oauthClient, err := client.NewClient(
146
-
self.ClientID,
147
-
o.config.OAuth.Jwks,
148
-
self.RedirectURIs[0],
149
-
)
150
-
151
-
if err != nil {
152
-
log.Println("failed to create oauth client:", err)
153
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
154
-
return
155
-
}
156
-
157
-
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
158
-
if err != nil {
159
-
log.Println("failed to resolve auth server:", err)
160
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
161
-
return
162
-
}
163
-
164
-
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
165
-
if err != nil {
166
-
log.Println("failed to fetch auth server metadata:", err)
167
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
168
-
return
169
-
}
170
-
171
-
dpopKey, err := helpers.GenerateKey(nil)
172
-
if err != nil {
173
-
log.Println("failed to generate dpop key:", err)
174
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
175
-
return
176
-
}
177
-
178
-
dpopKeyJson, err := json.Marshal(dpopKey)
179
-
if err != nil {
180
-
log.Println("failed to marshal dpop key:", err)
181
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
182
-
return
183
-
}
184
-
185
-
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
186
-
if err != nil {
187
-
log.Println("failed to send par auth request:", err)
188
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
189
-
return
190
-
}
191
-
192
-
err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{
193
-
Did: resolved.DID.String(),
194
-
PdsUrl: resolved.PDSEndpoint(),
195
-
Handle: handle,
196
-
AuthserverIss: authMeta.Issuer,
197
-
PkceVerifier: parResp.PkceVerifier,
198
-
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
199
-
DpopPrivateJwk: string(dpopKeyJson),
200
-
State: parResp.State,
201
-
ReturnUrl: r.FormValue("return_url"),
202
-
})
203
-
if err != nil {
204
-
log.Println("failed to save oauth request:", err)
205
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
206
-
return
207
-
}
208
-
209
-
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
210
-
query := url.Values{}
211
-
query.Add("client_id", self.ClientID)
212
-
query.Add("request_uri", parResp.RequestUri)
213
-
u.RawQuery = query.Encode()
214
-
o.pages.HxRedirect(w, u.String())
215
-
}
216
-
}
217
-
218
-
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
219
-
state := r.FormValue("state")
220
-
221
-
oauthRequest, err := o.sess.GetRequestByState(r.Context(), state)
222
-
if err != nil {
223
-
log.Println("failed to get oauth request:", err)
224
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
225
-
return
226
-
}
227
-
228
-
defer func() {
229
-
err := o.sess.DeleteRequestByState(r.Context(), state)
230
-
if err != nil {
231
-
log.Println("failed to delete oauth request for state:", state, err)
232
-
}
233
-
}()
234
-
235
-
error := r.FormValue("error")
236
-
errorDescription := r.FormValue("error_description")
237
-
if error != "" || errorDescription != "" {
238
-
log.Printf("error: %s, %s", error, errorDescription)
239
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
240
-
return
241
-
}
242
-
243
-
code := r.FormValue("code")
244
-
if code == "" {
245
-
log.Println("missing code for state: ", state)
246
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
247
-
return
248
-
}
249
-
250
-
iss := r.FormValue("iss")
251
-
if iss == "" {
252
-
log.Println("missing iss for state: ", state)
253
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
254
-
return
255
-
}
256
-
257
-
if iss != oauthRequest.AuthserverIss {
258
-
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
259
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
260
-
return
261
-
}
262
-
263
-
self := o.oauth.ClientMetadata()
264
-
265
-
oauthClient, err := client.NewClient(
266
-
self.ClientID,
267
-
o.config.OAuth.Jwks,
268
-
self.RedirectURIs[0],
269
-
)
270
-
271
-
if err != nil {
272
-
log.Println("failed to create oauth client:", err)
273
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
274
-
return
275
-
}
276
-
277
-
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
278
-
if err != nil {
279
-
log.Println("failed to parse jwk:", err)
280
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
281
-
return
282
-
}
283
-
284
-
tokenResp, err := oauthClient.InitialTokenRequest(
285
-
r.Context(),
286
-
code,
287
-
oauthRequest.AuthserverIss,
288
-
oauthRequest.PkceVerifier,
289
-
oauthRequest.DpopAuthserverNonce,
290
-
jwk,
291
-
)
292
-
if err != nil {
293
-
log.Println("failed to get token:", err)
294
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
295
-
return
296
-
}
297
-
298
-
if tokenResp.Scope != oauthScope {
299
-
log.Println("scope doesn't match:", tokenResp.Scope)
300
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
301
-
return
302
-
}
303
-
304
-
err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp)
305
-
if err != nil {
306
-
log.Println("failed to save session:", err)
307
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
308
-
return
309
-
}
310
-
311
-
log.Println("session saved successfully")
312
-
go o.addToDefaultKnot(oauthRequest.Did)
313
-
go o.addToDefaultSpindle(oauthRequest.Did)
314
-
315
-
if !o.config.Core.Dev {
316
-
err = o.posthog.Enqueue(posthog.Capture{
317
-
DistinctId: oauthRequest.Did,
318
-
Event: "signin",
319
-
})
320
-
if err != nil {
321
-
log.Println("failed to enqueue posthog event:", err)
322
-
}
323
-
}
324
-
325
-
returnUrl := oauthRequest.ReturnUrl
326
-
if returnUrl == "" {
327
-
returnUrl = "/"
328
-
}
329
-
330
-
http.Redirect(w, r, returnUrl, http.StatusFound)
331
-
}
332
-
333
-
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
334
-
err := o.oauth.ClearSession(r, w)
335
-
if err != nil {
336
-
log.Println("failed to clear session:", err)
337
-
http.Redirect(w, r, "/", http.StatusFound)
338
-
return
339
-
}
340
-
341
-
log.Println("session cleared successfully")
342
-
o.pages.HxRedirect(w, "/login")
343
-
}
344
-
345
-
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
346
-
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
347
-
if err != nil {
348
-
return nil, err
349
-
}
350
-
pubKey, err := k.PublicKey()
351
-
if err != nil {
352
-
return nil, err
353
-
}
354
-
return pubKey, nil
355
-
}
356
-
357
-
func (o *OAuthHandler) addToDefaultSpindle(did string) {
358
-
// use the tangled.sh app password to get an accessJwt
359
-
// and create an sh.tangled.spindle.member record with that
360
-
spindleMembers, err := db.GetSpindleMembers(
361
-
o.db,
362
-
db.FilterEq("instance", "spindle.tangled.sh"),
363
-
db.FilterEq("subject", did),
364
-
)
365
-
if err != nil {
366
-
log.Printf("failed to get spindle members for did %s: %v", did, err)
367
-
return
368
-
}
369
-
370
-
if len(spindleMembers) != 0 {
371
-
log.Printf("did %s is already a member of the default spindle", did)
372
-
return
373
-
}
374
-
375
-
log.Printf("adding %s to default spindle", did)
376
-
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid)
377
-
if err != nil {
378
-
log.Printf("failed to create session: %s", err)
379
-
return
380
-
}
381
-
382
-
record := tangled.SpindleMember{
383
-
LexiconTypeID: "sh.tangled.spindle.member",
384
-
Subject: did,
385
-
Instance: consts.DefaultSpindle,
386
-
CreatedAt: time.Now().Format(time.RFC3339),
387
-
}
388
-
389
-
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
390
-
log.Printf("failed to add member to default spindle: %s", err)
391
-
return
392
-
}
393
-
394
-
log.Printf("successfully added %s to default spindle", did)
395
-
}
396
-
397
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
398
-
// use the tangled.sh app password to get an accessJwt
399
-
// and create an sh.tangled.spindle.member record with that
400
-
401
-
allKnots, err := o.enforcer.GetKnotsForUser(did)
402
-
if err != nil {
403
-
log.Printf("failed to get knot members for did %s: %v", did, err)
404
-
return
405
-
}
406
-
407
-
if slices.Contains(allKnots, consts.DefaultKnot) {
408
-
log.Printf("did %s is already a member of the default knot", did)
409
-
return
410
-
}
411
-
412
-
log.Printf("adding %s to default knot", did)
413
-
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid)
414
-
if err != nil {
415
-
log.Printf("failed to create session: %s", err)
416
-
return
417
-
}
418
-
419
-
record := tangled.KnotMember{
420
-
LexiconTypeID: "sh.tangled.knot.member",
421
-
Subject: did,
422
-
Domain: consts.DefaultKnot,
423
-
CreatedAt: time.Now().Format(time.RFC3339),
424
-
}
425
-
426
-
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
427
-
log.Printf("failed to add member to default knot: %s", err)
428
-
return
429
-
}
430
-
431
-
if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
432
-
log.Printf("failed to set up enforcer rules: %s", err)
433
-
return
434
-
}
435
-
436
-
log.Printf("successfully added %s to default Knot", did)
437
-
}
438
-
439
-
// create a session using apppasswords
440
-
type session struct {
441
-
AccessJwt string `json:"accessJwt"`
442
-
PdsEndpoint string
443
-
Did string
444
-
}
445
-
446
-
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
447
-
if appPassword == "" {
448
-
return nil, fmt.Errorf("no app password configured, skipping member addition")
449
-
}
450
-
451
-
resolved, err := o.idResolver.ResolveIdent(context.Background(), did)
452
-
if err != nil {
453
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
454
-
}
455
-
456
-
pdsEndpoint := resolved.PDSEndpoint()
457
-
if pdsEndpoint == "" {
458
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
459
-
}
460
-
461
-
sessionPayload := map[string]string{
462
-
"identifier": did,
463
-
"password": appPassword,
464
-
}
465
-
sessionBytes, err := json.Marshal(sessionPayload)
466
-
if err != nil {
467
-
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
468
-
}
469
-
470
-
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
471
-
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
472
-
if err != nil {
473
-
return nil, fmt.Errorf("failed to create session request: %v", err)
474
-
}
475
-
sessionReq.Header.Set("Content-Type", "application/json")
476
-
477
-
client := &http.Client{Timeout: 30 * time.Second}
478
-
sessionResp, err := client.Do(sessionReq)
479
-
if err != nil {
480
-
return nil, fmt.Errorf("failed to create session: %v", err)
481
-
}
482
-
defer sessionResp.Body.Close()
483
-
484
-
if sessionResp.StatusCode != http.StatusOK {
485
-
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
486
-
}
487
-
488
-
var session session
489
-
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
490
-
return nil, fmt.Errorf("failed to decode session response: %v", err)
491
-
}
492
-
493
-
session.PdsEndpoint = pdsEndpoint
494
-
session.Did = did
495
-
496
-
return &session, nil
497
-
}
498
-
499
-
func (s *session) putRecord(record any, collection string) error {
500
-
recordBytes, err := json.Marshal(record)
501
-
if err != nil {
502
-
return fmt.Errorf("failed to marshal knot member record: %w", err)
503
-
}
504
-
505
-
payload := map[string]any{
506
-
"repo": s.Did,
507
-
"collection": collection,
508
-
"rkey": tid.TID(),
509
-
"record": json.RawMessage(recordBytes),
510
-
}
511
-
512
-
payloadBytes, err := json.Marshal(payload)
513
-
if err != nil {
514
-
return fmt.Errorf("failed to marshal request payload: %w", err)
515
-
}
516
-
517
-
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
518
-
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
519
-
if err != nil {
520
-
return fmt.Errorf("failed to create HTTP request: %w", err)
521
-
}
522
-
523
-
req.Header.Set("Content-Type", "application/json")
524
-
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
525
-
526
-
client := &http.Client{Timeout: 30 * time.Second}
527
-
resp, err := client.Do(req)
528
-
if err != nil {
529
-
return fmt.Errorf("failed to add user to default service: %w", err)
530
-
}
531
-
defer resp.Body.Close()
532
-
533
-
if resp.StatusCode != http.StatusOK {
534
-
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
535
-
}
536
-
537
-
return nil
538
-
}
+286
appview/oauth/handler.go
+286
appview/oauth/handler.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"errors"
8
+
"fmt"
9
+
"net/http"
10
+
"slices"
11
+
"time"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
14
+
"github.com/go-chi/chi/v5"
15
+
"github.com/lestrrat-go/jwx/v2/jwk"
16
+
"github.com/posthog/posthog-go"
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/consts"
20
+
"tangled.org/core/tid"
21
+
)
22
+
23
+
func (o *OAuth) Router() http.Handler {
24
+
r := chi.NewRouter()
25
+
26
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
27
+
r.Get("/oauth/jwks.json", o.jwks)
28
+
r.Get("/oauth/callback", o.callback)
29
+
return r
30
+
}
31
+
32
+
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
33
+
doc := o.ClientApp.Config.ClientMetadata()
34
+
doc.JWKSURI = &o.JwksUri
35
+
36
+
w.Header().Set("Content-Type", "application/json")
37
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
38
+
http.Error(w, err.Error(), http.StatusInternalServerError)
39
+
return
40
+
}
41
+
}
42
+
43
+
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
44
+
jwks := o.Config.OAuth.Jwks
45
+
pubKey, err := pubKeyFromJwk(jwks)
46
+
if err != nil {
47
+
o.Logger.Error("error parsing public key", "err", err)
48
+
http.Error(w, err.Error(), http.StatusInternalServerError)
49
+
return
50
+
}
51
+
52
+
response := map[string]any{
53
+
"keys": []jwk.Key{pubKey},
54
+
}
55
+
56
+
w.Header().Set("Content-Type", "application/json")
57
+
w.WriteHeader(http.StatusOK)
58
+
json.NewEncoder(w).Encode(response)
59
+
}
60
+
61
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
62
+
ctx := r.Context()
63
+
l := o.Logger.With("query", r.URL.Query())
64
+
65
+
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
66
+
if err != nil {
67
+
var callbackErr *oauth.AuthRequestCallbackError
68
+
if errors.As(err, &callbackErr) {
69
+
l.Debug("callback error", "err", callbackErr)
70
+
http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
71
+
return
72
+
}
73
+
l.Error("failed to process callback", "err", err)
74
+
http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
75
+
return
76
+
}
77
+
78
+
if err := o.SaveSession(w, r, sessData); err != nil {
79
+
l.Error("failed to save session", "data", sessData, "err", err)
80
+
http.Redirect(w, r, "/login?error=session", http.StatusFound)
81
+
return
82
+
}
83
+
84
+
o.Logger.Debug("session saved successfully")
85
+
go o.addToDefaultKnot(sessData.AccountDID.String())
86
+
go o.addToDefaultSpindle(sessData.AccountDID.String())
87
+
88
+
if !o.Config.Core.Dev {
89
+
err = o.Posthog.Enqueue(posthog.Capture{
90
+
DistinctId: sessData.AccountDID.String(),
91
+
Event: "signin",
92
+
})
93
+
if err != nil {
94
+
o.Logger.Error("failed to enqueue posthog event", "err", err)
95
+
}
96
+
}
97
+
98
+
http.Redirect(w, r, "/", http.StatusFound)
99
+
}
100
+
101
+
func (o *OAuth) addToDefaultSpindle(did string) {
102
+
l := o.Logger.With("subject", did)
103
+
104
+
// use the tangled.sh app password to get an accessJwt
105
+
// and create an sh.tangled.spindle.member record with that
106
+
spindleMembers, err := db.GetSpindleMembers(
107
+
o.Db,
108
+
db.FilterEq("instance", "spindle.tangled.sh"),
109
+
db.FilterEq("subject", did),
110
+
)
111
+
if err != nil {
112
+
l.Error("failed to get spindle members", "err", err)
113
+
return
114
+
}
115
+
116
+
if len(spindleMembers) != 0 {
117
+
l.Warn("already a member of the default spindle")
118
+
return
119
+
}
120
+
121
+
l.Debug("adding to default spindle")
122
+
session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid)
123
+
if err != nil {
124
+
l.Error("failed to create session", "err", err)
125
+
return
126
+
}
127
+
128
+
record := tangled.SpindleMember{
129
+
LexiconTypeID: "sh.tangled.spindle.member",
130
+
Subject: did,
131
+
Instance: consts.DefaultSpindle,
132
+
CreatedAt: time.Now().Format(time.RFC3339),
133
+
}
134
+
135
+
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
136
+
l.Error("failed to add to default spindle", "err", err)
137
+
return
138
+
}
139
+
140
+
l.Debug("successfully added to default spindle", "did", did)
141
+
}
142
+
143
+
func (o *OAuth) addToDefaultKnot(did string) {
144
+
l := o.Logger.With("subject", did)
145
+
146
+
// use the tangled.sh app password to get an accessJwt
147
+
// and create an sh.tangled.spindle.member record with that
148
+
149
+
allKnots, err := o.Enforcer.GetKnotsForUser(did)
150
+
if err != nil {
151
+
l.Error("failed to get knot members for did", "err", err)
152
+
return
153
+
}
154
+
155
+
if slices.Contains(allKnots, consts.DefaultKnot) {
156
+
l.Warn("already a member of the default knot")
157
+
return
158
+
}
159
+
160
+
l.Debug("addings to default knot")
161
+
session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid)
162
+
if err != nil {
163
+
l.Error("failed to create session", "err", err)
164
+
return
165
+
}
166
+
167
+
record := tangled.KnotMember{
168
+
LexiconTypeID: "sh.tangled.knot.member",
169
+
Subject: did,
170
+
Domain: consts.DefaultKnot,
171
+
CreatedAt: time.Now().Format(time.RFC3339),
172
+
}
173
+
174
+
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
175
+
l.Error("failed to add to default knot", "err", err)
176
+
return
177
+
}
178
+
179
+
if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
180
+
l.Error("failed to set up enforcer rules", "err", err)
181
+
return
182
+
}
183
+
184
+
l.Debug("successfully addeds to default Knot")
185
+
}
186
+
187
+
// create a session using apppasswords
188
+
type session struct {
189
+
AccessJwt string `json:"accessJwt"`
190
+
PdsEndpoint string
191
+
Did string
192
+
}
193
+
194
+
func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) {
195
+
if appPassword == "" {
196
+
return nil, fmt.Errorf("no app password configured, skipping member addition")
197
+
}
198
+
199
+
resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
200
+
if err != nil {
201
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
202
+
}
203
+
204
+
pdsEndpoint := resolved.PDSEndpoint()
205
+
if pdsEndpoint == "" {
206
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
207
+
}
208
+
209
+
sessionPayload := map[string]string{
210
+
"identifier": did,
211
+
"password": appPassword,
212
+
}
213
+
sessionBytes, err := json.Marshal(sessionPayload)
214
+
if err != nil {
215
+
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
216
+
}
217
+
218
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
219
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
220
+
if err != nil {
221
+
return nil, fmt.Errorf("failed to create session request: %v", err)
222
+
}
223
+
sessionReq.Header.Set("Content-Type", "application/json")
224
+
225
+
client := &http.Client{Timeout: 30 * time.Second}
226
+
sessionResp, err := client.Do(sessionReq)
227
+
if err != nil {
228
+
return nil, fmt.Errorf("failed to create session: %v", err)
229
+
}
230
+
defer sessionResp.Body.Close()
231
+
232
+
if sessionResp.StatusCode != http.StatusOK {
233
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
234
+
}
235
+
236
+
var session session
237
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
238
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
239
+
}
240
+
241
+
session.PdsEndpoint = pdsEndpoint
242
+
session.Did = did
243
+
244
+
return &session, nil
245
+
}
246
+
247
+
func (s *session) putRecord(record any, collection string) error {
248
+
recordBytes, err := json.Marshal(record)
249
+
if err != nil {
250
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
251
+
}
252
+
253
+
payload := map[string]any{
254
+
"repo": s.Did,
255
+
"collection": collection,
256
+
"rkey": tid.TID(),
257
+
"record": json.RawMessage(recordBytes),
258
+
}
259
+
260
+
payloadBytes, err := json.Marshal(payload)
261
+
if err != nil {
262
+
return fmt.Errorf("failed to marshal request payload: %w", err)
263
+
}
264
+
265
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
266
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
267
+
if err != nil {
268
+
return fmt.Errorf("failed to create HTTP request: %w", err)
269
+
}
270
+
271
+
req.Header.Set("Content-Type", "application/json")
272
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
273
+
274
+
client := &http.Client{Timeout: 30 * time.Second}
275
+
resp, err := client.Do(req)
276
+
if err != nil {
277
+
return fmt.Errorf("failed to add user to default service: %w", err)
278
+
}
279
+
defer resp.Body.Close()
280
+
281
+
if resp.StatusCode != http.StatusOK {
282
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
283
+
}
284
+
285
+
return nil
286
+
}
+124
-201
appview/oauth/oauth.go
+124
-201
appview/oauth/oauth.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
-
"log"
6
+
"log/slog"
6
7
"net/http"
7
-
"net/url"
8
8
"time"
9
9
10
-
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
10
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
xrpc "github.com/bluesky-social/indigo/xrpc"
11
15
"github.com/gorilla/sessions"
12
-
sessioncache "tangled.org/core/appview/cache/session"
16
+
"github.com/lestrrat-go/jwx/v2/jwk"
17
+
"github.com/posthog/posthog-go"
13
18
"tangled.org/core/appview/config"
14
-
"tangled.org/core/appview/oauth/client"
15
-
xrpc "tangled.org/core/appview/xrpcclient"
16
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
17
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
18
22
)
19
23
20
24
type OAuth struct {
21
-
store *sessions.CookieStore
22
-
config *config.Config
23
-
sess *sessioncache.SessionStore
25
+
ClientApp *oauth.ClientApp
26
+
SessStore *sessions.CookieStore
27
+
Config *config.Config
28
+
JwksUri string
29
+
Posthog posthog.Client
30
+
Db *db.DB
31
+
Enforcer *rbac.Enforcer
32
+
IdResolver *idresolver.Resolver
33
+
Logger *slog.Logger
24
34
}
25
35
26
-
func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth {
27
-
return &OAuth{
28
-
store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
29
-
config: config,
30
-
sess: sess,
36
+
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
37
+
38
+
var oauthConfig oauth.ClientConfig
39
+
var clientUri string
40
+
41
+
if config.Core.Dev {
42
+
clientUri = "http://127.0.0.1:3000"
43
+
callbackUri := clientUri + "/oauth/callback"
44
+
oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"})
45
+
} else {
46
+
clientUri = config.Core.AppviewHost
47
+
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
48
+
callbackUri := clientUri + "/oauth/callback"
49
+
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
31
50
}
32
-
}
51
+
52
+
jwksUri := clientUri + "/oauth/jwks.json"
53
+
54
+
authStore, err := NewRedisStore(config.Redis.ToURL())
55
+
if err != nil {
56
+
return nil, err
57
+
}
58
+
59
+
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
60
+
61
+
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
62
+
clientApp.Dir = res.Directory()
33
63
34
-
func (o *OAuth) Stores() *sessions.CookieStore {
35
-
return o.store
64
+
return &OAuth{
65
+
ClientApp: clientApp,
66
+
Config: config,
67
+
SessStore: sessStore,
68
+
JwksUri: jwksUri,
69
+
Posthog: ph,
70
+
Db: db,
71
+
Enforcer: enforcer,
72
+
IdResolver: res,
73
+
Logger: logger,
74
+
}, nil
36
75
}
37
76
38
-
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error {
77
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
39
78
// first we save the did in the user session
40
-
userSession, err := o.store.Get(r, SessionName)
79
+
userSession, err := o.SessStore.Get(r, SessionName)
41
80
if err != nil {
42
81
return err
43
82
}
44
83
45
-
userSession.Values[SessionDid] = oreq.Did
46
-
userSession.Values[SessionHandle] = oreq.Handle
47
-
userSession.Values[SessionPds] = oreq.PdsUrl
84
+
userSession.Values[SessionDid] = sessData.AccountDID.String()
85
+
userSession.Values[SessionPds] = sessData.HostURL
86
+
userSession.Values[SessionId] = sessData.SessionID
48
87
userSession.Values[SessionAuthenticated] = true
49
-
err = userSession.Save(r, w)
88
+
return userSession.Save(r, w)
89
+
}
90
+
91
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
92
+
userSession, err := o.SessStore.Get(r, SessionName)
50
93
if err != nil {
51
-
return fmt.Errorf("error saving user session: %w", err)
94
+
return nil, fmt.Errorf("error getting user session: %w", err)
52
95
}
53
-
54
-
// then save the whole thing in the db
55
-
session := sessioncache.OAuthSession{
56
-
Did: oreq.Did,
57
-
Handle: oreq.Handle,
58
-
PdsUrl: oreq.PdsUrl,
59
-
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
60
-
AuthServerIss: oreq.AuthserverIss,
61
-
DpopPrivateJwk: oreq.DpopPrivateJwk,
62
-
AccessJwt: oresp.AccessToken,
63
-
RefreshJwt: oresp.RefreshToken,
64
-
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
96
+
if userSession.IsNew {
97
+
return nil, fmt.Errorf("no session available for user")
65
98
}
66
99
67
-
return o.sess.SaveSession(r.Context(), session)
68
-
}
69
-
70
-
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
71
-
userSession, err := o.store.Get(r, SessionName)
72
-
if err != nil || userSession.IsNew {
73
-
return fmt.Errorf("error getting user session (or new session?): %w", err)
100
+
d := userSession.Values[SessionDid].(string)
101
+
sessDid, err := syntax.ParseDID(d)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
74
104
}
75
105
76
-
did := userSession.Values[SessionDid].(string)
106
+
sessId := userSession.Values[SessionId].(string)
77
107
78
-
err = o.sess.DeleteSession(r.Context(), did)
108
+
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
79
109
if err != nil {
80
-
return fmt.Errorf("error deleting oauth session: %w", err)
110
+
return nil, fmt.Errorf("failed to resume session: %w", err)
81
111
}
82
112
83
-
userSession.Options.MaxAge = -1
84
-
85
-
return userSession.Save(r, w)
113
+
return clientSess, nil
86
114
}
87
115
88
-
func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) {
89
-
userSession, err := o.store.Get(r, SessionName)
90
-
if err != nil || userSession.IsNew {
91
-
return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err)
92
-
}
93
-
94
-
did := userSession.Values[SessionDid].(string)
95
-
auth := userSession.Values[SessionAuthenticated].(bool)
96
-
97
-
session, err := o.sess.GetSession(r.Context(), did)
116
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
117
+
userSession, err := o.SessStore.Get(r, SessionName)
98
118
if err != nil {
99
-
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
119
+
return fmt.Errorf("error getting user session: %w", err)
120
+
}
121
+
if userSession.IsNew {
122
+
return fmt.Errorf("no session available for user")
100
123
}
101
124
102
-
expiry, err := time.Parse(time.RFC3339, session.Expiry)
125
+
d := userSession.Values[SessionDid].(string)
126
+
sessDid, err := syntax.ParseDID(d)
103
127
if err != nil {
104
-
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
128
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
105
129
}
106
-
if time.Until(expiry) <= 5*time.Minute {
107
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
108
-
if err != nil {
109
-
return nil, false, err
110
-
}
111
130
112
-
self := o.ClientMetadata()
131
+
sessId := userSession.Values[SessionId].(string)
113
132
114
-
oauthClient, err := client.NewClient(
115
-
self.ClientID,
116
-
o.config.OAuth.Jwks,
117
-
self.RedirectURIs[0],
118
-
)
133
+
// delete the session
134
+
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
119
135
120
-
if err != nil {
121
-
return nil, false, err
122
-
}
136
+
// remove the cookie
137
+
userSession.Options.MaxAge = -1
138
+
err2 := o.SessStore.Save(r, w, userSession)
123
139
124
-
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
125
-
if err != nil {
126
-
return nil, false, err
127
-
}
140
+
return errors.Join(err1, err2)
141
+
}
128
142
129
-
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
130
-
err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry)
131
-
if err != nil {
132
-
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
133
-
}
134
-
135
-
// update the current session
136
-
session.AccessJwt = resp.AccessToken
137
-
session.RefreshJwt = resp.RefreshToken
138
-
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
139
-
session.Expiry = newExpiry
143
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
144
+
k, err := jwk.ParseKey([]byte(jwks))
145
+
if err != nil {
146
+
return nil, err
147
+
}
148
+
pubKey, err := k.PublicKey()
149
+
if err != nil {
150
+
return nil, err
140
151
}
141
-
142
-
return session, auth, nil
152
+
return pubKey, nil
143
153
}
144
154
145
155
type User struct {
146
-
Handle string
147
-
Did string
148
-
Pds string
156
+
Did string
157
+
Pds string
149
158
}
150
159
151
-
func (a *OAuth) GetUser(r *http.Request) *User {
152
-
clientSession, err := a.store.Get(r, SessionName)
160
+
func (o *OAuth) GetUser(r *http.Request) *User {
161
+
sess, err := o.SessStore.Get(r, SessionName)
153
162
154
-
if err != nil || clientSession.IsNew {
163
+
if err != nil || sess.IsNew {
155
164
return nil
156
165
}
157
166
158
167
return &User{
159
-
Handle: clientSession.Values[SessionHandle].(string),
160
-
Did: clientSession.Values[SessionDid].(string),
161
-
Pds: clientSession.Values[SessionPds].(string),
168
+
Did: sess.Values[SessionDid].(string),
169
+
Pds: sess.Values[SessionPds].(string),
162
170
}
163
171
}
164
172
165
-
func (a *OAuth) GetDid(r *http.Request) string {
166
-
clientSession, err := a.store.Get(r, SessionName)
167
-
168
-
if err != nil || clientSession.IsNew {
169
-
return ""
173
+
func (o *OAuth) GetDid(r *http.Request) string {
174
+
if u := o.GetUser(r); u != nil {
175
+
return u.Did
170
176
}
171
177
172
-
return clientSession.Values[SessionDid].(string)
178
+
return ""
173
179
}
174
180
175
-
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
176
-
session, auth, err := o.GetSession(r)
181
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
182
+
session, err := o.ResumeSession(r)
177
183
if err != nil {
178
184
return nil, fmt.Errorf("error getting session: %w", err)
179
185
}
180
-
if !auth {
181
-
return nil, fmt.Errorf("not authorized")
182
-
}
183
-
184
-
client := &oauth.XrpcClient{
185
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
186
-
err := o.sess.UpdateNonce(r.Context(), did, newNonce)
187
-
if err != nil {
188
-
log.Printf("error updating dpop pds nonce: %v", err)
189
-
}
190
-
},
191
-
}
192
-
193
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
194
-
if err != nil {
195
-
return nil, fmt.Errorf("error parsing private jwk: %w", err)
196
-
}
197
-
198
-
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
199
-
Did: session.Did,
200
-
PdsUrl: session.PdsUrl,
201
-
DpopPdsNonce: session.PdsUrl,
202
-
AccessToken: session.AccessJwt,
203
-
Issuer: session.AuthServerIss,
204
-
DpopPrivateJwk: privateJwk,
205
-
})
206
-
207
-
return xrpcClient, nil
186
+
return session.APIClient(), nil
208
187
}
209
188
210
-
// use this to create a client to communicate with knots or spindles
211
-
//
212
189
// this is a higher level abstraction on ServerGetServiceAuth
213
190
type ServiceClientOpts struct {
214
191
service string
···
259
236
return scheme + s.service
260
237
}
261
238
262
-
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
239
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
263
240
opts := ServiceClientOpts{}
264
241
for _, o := range os {
265
242
o(&opts)
266
243
}
267
244
268
-
authorizedClient, err := o.AuthorizedClient(r)
245
+
client, err := o.AuthorizedClient(r)
269
246
if err != nil {
270
247
return nil, err
271
248
}
···
276
253
opts.exp = sixty
277
254
}
278
255
279
-
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
256
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
280
257
if err != nil {
281
258
return nil, err
282
259
}
283
260
284
-
return &indigo_xrpc.Client{
285
-
Auth: &indigo_xrpc.AuthInfo{
261
+
return &xrpc.Client{
262
+
Auth: &xrpc.AuthInfo{
286
263
AccessJwt: resp.Token,
287
264
},
288
265
Host: opts.Host(),
···
291
268
},
292
269
}, nil
293
270
}
294
-
295
-
type ClientMetadata struct {
296
-
ClientID string `json:"client_id"`
297
-
ClientName string `json:"client_name"`
298
-
SubjectType string `json:"subject_type"`
299
-
ClientURI string `json:"client_uri"`
300
-
RedirectURIs []string `json:"redirect_uris"`
301
-
GrantTypes []string `json:"grant_types"`
302
-
ResponseTypes []string `json:"response_types"`
303
-
ApplicationType string `json:"application_type"`
304
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
305
-
JwksURI string `json:"jwks_uri"`
306
-
Scope string `json:"scope"`
307
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
308
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
309
-
}
310
-
311
-
func (o *OAuth) ClientMetadata() ClientMetadata {
312
-
makeRedirectURIs := func(c string) []string {
313
-
return []string{fmt.Sprintf("%s/oauth/callback", c)}
314
-
}
315
-
316
-
clientURI := o.config.Core.AppviewHost
317
-
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
318
-
redirectURIs := makeRedirectURIs(clientURI)
319
-
320
-
if o.config.Core.Dev {
321
-
clientURI = "http://127.0.0.1:3000"
322
-
redirectURIs = makeRedirectURIs(clientURI)
323
-
324
-
query := url.Values{}
325
-
query.Add("redirect_uri", redirectURIs[0])
326
-
query.Add("scope", "atproto transition:generic")
327
-
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
328
-
}
329
-
330
-
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
331
-
332
-
return ClientMetadata{
333
-
ClientID: clientID,
334
-
ClientName: "Tangled",
335
-
SubjectType: "public",
336
-
ClientURI: clientURI,
337
-
RedirectURIs: redirectURIs,
338
-
GrantTypes: []string{"authorization_code", "refresh_token"},
339
-
ResponseTypes: []string{"code"},
340
-
ApplicationType: "web",
341
-
DpopBoundAccessTokens: true,
342
-
JwksURI: jwksURI,
343
-
Scope: "atproto transition:generic",
344
-
TokenEndpointAuthMethod: "private_key_jwt",
345
-
TokenEndpointAuthSigningAlg: "ES256",
346
-
}
347
-
}
+147
appview/oauth/store.go
+147
appview/oauth/store.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/redis/go-redis/v9"
12
+
)
13
+
14
+
// redis-backed implementation of ClientAuthStore.
15
+
type RedisStore struct {
16
+
client *redis.Client
17
+
SessionTTL time.Duration
18
+
AuthRequestTTL time.Duration
19
+
}
20
+
21
+
var _ oauth.ClientAuthStore = &RedisStore{}
22
+
23
+
func NewRedisStore(redisURL string) (*RedisStore, error) {
24
+
opts, err := redis.ParseURL(redisURL)
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
27
+
}
28
+
29
+
client := redis.NewClient(opts)
30
+
31
+
// test the connection
32
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
33
+
defer cancel()
34
+
35
+
if err := client.Ping(ctx).Err(); err != nil {
36
+
return nil, fmt.Errorf("failed to connect to redis: %w", err)
37
+
}
38
+
39
+
return &RedisStore{
40
+
client: client,
41
+
SessionTTL: 30 * 24 * time.Hour, // 30 days
42
+
AuthRequestTTL: 10 * time.Minute, // 10 minutes
43
+
}, nil
44
+
}
45
+
46
+
func (r *RedisStore) Close() error {
47
+
return r.client.Close()
48
+
}
49
+
50
+
func sessionKey(did syntax.DID, sessionID string) string {
51
+
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
52
+
}
53
+
54
+
func authRequestKey(state string) string {
55
+
return fmt.Sprintf("oauth:auth_request:%s", state)
56
+
}
57
+
58
+
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
59
+
key := sessionKey(did, sessionID)
60
+
data, err := r.client.Get(ctx, key).Bytes()
61
+
if err == redis.Nil {
62
+
return nil, fmt.Errorf("session not found: %s", did)
63
+
}
64
+
if err != nil {
65
+
return nil, fmt.Errorf("failed to get session: %w", err)
66
+
}
67
+
68
+
var sess oauth.ClientSessionData
69
+
if err := json.Unmarshal(data, &sess); err != nil {
70
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
71
+
}
72
+
73
+
return &sess, nil
74
+
}
75
+
76
+
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
77
+
key := sessionKey(sess.AccountDID, sess.SessionID)
78
+
79
+
data, err := json.Marshal(sess)
80
+
if err != nil {
81
+
return fmt.Errorf("failed to marshal session: %w", err)
82
+
}
83
+
84
+
if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil {
85
+
return fmt.Errorf("failed to save session: %w", err)
86
+
}
87
+
88
+
return nil
89
+
}
90
+
91
+
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
92
+
key := sessionKey(did, sessionID)
93
+
if err := r.client.Del(ctx, key).Err(); err != nil {
94
+
return fmt.Errorf("failed to delete session: %w", err)
95
+
}
96
+
return nil
97
+
}
98
+
99
+
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
100
+
key := authRequestKey(state)
101
+
data, err := r.client.Get(ctx, key).Bytes()
102
+
if err == redis.Nil {
103
+
return nil, fmt.Errorf("request info not found: %s", state)
104
+
}
105
+
if err != nil {
106
+
return nil, fmt.Errorf("failed to get auth request: %w", err)
107
+
}
108
+
109
+
var req oauth.AuthRequestData
110
+
if err := json.Unmarshal(data, &req); err != nil {
111
+
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
112
+
}
113
+
114
+
return &req, nil
115
+
}
116
+
117
+
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
118
+
key := authRequestKey(info.State)
119
+
120
+
// check if already exists (to match MemStore behavior)
121
+
exists, err := r.client.Exists(ctx, key).Result()
122
+
if err != nil {
123
+
return fmt.Errorf("failed to check auth request existence: %w", err)
124
+
}
125
+
if exists > 0 {
126
+
return fmt.Errorf("auth request already saved for state %s", info.State)
127
+
}
128
+
129
+
data, err := json.Marshal(info)
130
+
if err != nil {
131
+
return fmt.Errorf("failed to marshal auth request: %w", err)
132
+
}
133
+
134
+
if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil {
135
+
return fmt.Errorf("failed to save auth request: %w", err)
136
+
}
137
+
138
+
return nil
139
+
}
140
+
141
+
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
142
+
key := authRequestKey(state)
143
+
if err := r.client.Del(ctx, key).Err(); err != nil {
144
+
return fmt.Errorf("failed to delete auth request: %w", err)
145
+
}
146
+
return nil
147
+
}
+535
appview/ogcard/card.go
+535
appview/ogcard/card.go
···
1
+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
+
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
+
// SPDX-License-Identifier: MIT
4
+
5
+
package ogcard
6
+
7
+
import (
8
+
"bytes"
9
+
"fmt"
10
+
"image"
11
+
"image/color"
12
+
"io"
13
+
"log"
14
+
"math"
15
+
"net/http"
16
+
"strings"
17
+
"sync"
18
+
"time"
19
+
20
+
"github.com/goki/freetype"
21
+
"github.com/goki/freetype/truetype"
22
+
"github.com/srwiley/oksvg"
23
+
"github.com/srwiley/rasterx"
24
+
"golang.org/x/image/draw"
25
+
"golang.org/x/image/font"
26
+
"tangled.org/core/appview/pages"
27
+
28
+
_ "golang.org/x/image/webp" // for processing webp images
29
+
)
30
+
31
+
type Card struct {
32
+
Img *image.RGBA
33
+
Font *truetype.Font
34
+
Margin int
35
+
Width int
36
+
Height int
37
+
}
38
+
39
+
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
+
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
+
if err != nil {
42
+
return nil, err
43
+
}
44
+
return truetype.Parse(interVar)
45
+
})
46
+
47
+
// DefaultSize returns the default size for a card
48
+
func DefaultSize() (int, int) {
49
+
return 1200, 630
50
+
}
51
+
52
+
// NewCard creates a new card with the given dimensions in pixels
53
+
func NewCard(width, height int) (*Card, error) {
54
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
+
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
+
57
+
font, err := fontCache()
58
+
if err != nil {
59
+
return nil, err
60
+
}
61
+
62
+
return &Card{
63
+
Img: img,
64
+
Font: font,
65
+
Margin: 0,
66
+
Width: width,
67
+
Height: height,
68
+
}, nil
69
+
}
70
+
71
+
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
+
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
+
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
+
bounds := c.Img.Bounds()
75
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
+
if vertical {
77
+
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
+
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
+
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
+
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
+
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
+
}
83
+
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
+
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
+
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
+
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
+
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
+
}
89
+
90
+
// SetMargin sets the margins for the card
91
+
func (c *Card) SetMargin(margin int) {
92
+
c.Margin = margin
93
+
}
94
+
95
+
type (
96
+
VAlign int64
97
+
HAlign int64
98
+
)
99
+
100
+
const (
101
+
Top VAlign = iota
102
+
Middle
103
+
Bottom
104
+
)
105
+
106
+
const (
107
+
Left HAlign = iota
108
+
Center
109
+
Right
110
+
)
111
+
112
+
// DrawText draws text within the card, respecting margins and alignment
113
+
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
+
ft := freetype.NewContext()
115
+
ft.SetDPI(72)
116
+
ft.SetFont(c.Font)
117
+
ft.SetFontSize(sizePt)
118
+
ft.SetClip(c.Img.Bounds())
119
+
ft.SetDst(c.Img)
120
+
ft.SetSrc(image.NewUniform(textColor))
121
+
122
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
+
125
+
bounds := c.Img.Bounds()
126
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
+
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
+
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
+
130
+
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
+
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
+
// knowing the total height, which is related to how many lines we'll have.
133
+
lines := make([]string, 0)
134
+
textWords := strings.Split(text, " ")
135
+
currentLine := ""
136
+
heightTotal := 0
137
+
138
+
for {
139
+
if len(textWords) == 0 {
140
+
// Ran out of words.
141
+
if currentLine != "" {
142
+
heightTotal += fontHeight
143
+
lines = append(lines, currentLine)
144
+
}
145
+
break
146
+
}
147
+
148
+
nextWord := textWords[0]
149
+
proposedLine := currentLine
150
+
if proposedLine != "" {
151
+
proposedLine += " "
152
+
}
153
+
proposedLine += nextWord
154
+
155
+
proposedLineWidth := font.MeasureString(face, proposedLine)
156
+
if proposedLineWidth.Ceil() > boxWidth {
157
+
// no, proposed line is too big; we'll use the last "currentLine"
158
+
heightTotal += fontHeight
159
+
if currentLine != "" {
160
+
lines = append(lines, currentLine)
161
+
currentLine = ""
162
+
// leave nextWord in textWords and keep going
163
+
} else {
164
+
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
+
// regardless as a line by itself. It will be clipped by the drawing routine.
166
+
lines = append(lines, nextWord)
167
+
textWords = textWords[1:]
168
+
}
169
+
} else {
170
+
// yes, it will fit
171
+
currentLine = proposedLine
172
+
textWords = textWords[1:]
173
+
}
174
+
}
175
+
176
+
textY := 0
177
+
switch valign {
178
+
case Top:
179
+
textY = fontHeight
180
+
case Bottom:
181
+
textY = boxHeight - heightTotal + fontHeight
182
+
case Middle:
183
+
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
+
}
185
+
186
+
for _, line := range lines {
187
+
lineWidth := font.MeasureString(face, line)
188
+
189
+
textX := 0
190
+
switch halign {
191
+
case Left:
192
+
textX = 0
193
+
case Right:
194
+
textX = boxWidth - lineWidth.Ceil()
195
+
case Center:
196
+
textX = (boxWidth - lineWidth.Ceil()) / 2
197
+
}
198
+
199
+
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
+
_, err := ft.DrawString(line, pt)
201
+
if err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
textY += fontHeight
206
+
}
207
+
208
+
return lines, nil
209
+
}
210
+
211
+
// DrawTextAt draws text at a specific position with the given alignment
212
+
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
+
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
+
return err
215
+
}
216
+
217
+
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
+
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
+
ft := freetype.NewContext()
220
+
ft.SetDPI(72)
221
+
ft.SetFont(c.Font)
222
+
ft.SetFontSize(sizePt)
223
+
ft.SetClip(c.Img.Bounds())
224
+
ft.SetDst(c.Img)
225
+
ft.SetSrc(image.NewUniform(textColor))
226
+
227
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
+
lineWidth := font.MeasureString(face, text)
230
+
textWidth := lineWidth.Ceil()
231
+
232
+
// Adjust position based on alignment
233
+
adjustedX := x
234
+
adjustedY := y
235
+
236
+
switch halign {
237
+
case Left:
238
+
// x is already at the left position
239
+
case Right:
240
+
adjustedX = x - textWidth
241
+
case Center:
242
+
adjustedX = x - textWidth/2
243
+
}
244
+
245
+
switch valign {
246
+
case Top:
247
+
adjustedY = y + fontHeight
248
+
case Bottom:
249
+
adjustedY = y
250
+
case Middle:
251
+
adjustedY = y + fontHeight/2
252
+
}
253
+
254
+
pt := freetype.Pt(adjustedX, adjustedY)
255
+
_, err := ft.DrawString(text, pt)
256
+
return textWidth, err
257
+
}
258
+
259
+
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
+
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
+
// Draw the text multiple times with slight offsets to create bold effect
262
+
offsets := []struct{ dx, dy int }{
263
+
{0, 0}, // original
264
+
{1, 0}, // right
265
+
{0, 1}, // down
266
+
{1, 1}, // diagonal
267
+
}
268
+
269
+
var width int
270
+
for _, offset := range offsets {
271
+
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
+
if err != nil {
273
+
return 0, err
274
+
}
275
+
if width == 0 {
276
+
width = w
277
+
}
278
+
}
279
+
return width, nil
280
+
}
281
+
282
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
+
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
+
svgData, err := pages.Files.ReadFile(svgPath)
285
+
if err != nil {
286
+
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
+
}
288
+
289
+
// Convert color to hex string for SVG
290
+
rgba, isRGBA := iconColor.(color.RGBA)
291
+
if !isRGBA {
292
+
r, g, b, a := iconColor.RGBA()
293
+
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
+
}
295
+
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
+
297
+
// Replace currentColor with our desired color in the SVG
298
+
svgString := string(svgData)
299
+
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
+
301
+
// Make the stroke thicker
302
+
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
+
304
+
// Parse SVG
305
+
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
+
if err != nil {
307
+
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
+
}
309
+
310
+
// Set the icon size
311
+
w, h := float64(size), float64(size)
312
+
icon.SetTarget(0, 0, w, h)
313
+
314
+
// Create a temporary RGBA image for the icon
315
+
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
+
317
+
// Create scanner and rasterizer
318
+
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
+
raster := rasterx.NewDasher(size, size, scanner)
320
+
321
+
// Draw the icon
322
+
icon.Draw(raster, 1.0)
323
+
324
+
// Draw the icon onto the card at the specified position
325
+
bounds := c.Img.Bounds()
326
+
destRect := image.Rect(x, y, x+size, y+size)
327
+
328
+
// Make sure we don't draw outside the card bounds
329
+
if destRect.Max.X > bounds.Max.X {
330
+
destRect.Max.X = bounds.Max.X
331
+
}
332
+
if destRect.Max.Y > bounds.Max.Y {
333
+
destRect.Max.Y = bounds.Max.Y
334
+
}
335
+
336
+
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
+
338
+
return nil
339
+
}
340
+
341
+
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
+
func (c *Card) DrawImage(img image.Image) {
343
+
bounds := c.Img.Bounds()
344
+
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
+
srcBounds := img.Bounds()
346
+
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
+
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
+
349
+
var scale float64
350
+
if srcAspect > targetAspect {
351
+
// Image is wider than target, scale by width
352
+
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
+
} else {
354
+
// Image is taller or equal, scale by height
355
+
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
+
}
357
+
358
+
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
+
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
+
361
+
// Center the image within the target rectangle
362
+
offsetX := (targetRect.Dx() - newWidth) / 2
363
+
offsetY := (targetRect.Dy() - newHeight) / 2
364
+
365
+
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
+
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
+
}
368
+
369
+
func fallbackImage() image.Image {
370
+
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
+
img.Set(0, 0, color.White)
373
+
return img
374
+
}
375
+
376
+
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
+
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
+
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
+
// this rendering process to be slowed down
380
+
client := &http.Client{
381
+
Timeout: 1 * time.Second, // 1 second timeout
382
+
}
383
+
384
+
resp, err := client.Get(url)
385
+
if err != nil {
386
+
log.Printf("error when fetching external image from %s: %v", url, err)
387
+
return nil, false
388
+
}
389
+
defer resp.Body.Close()
390
+
391
+
if resp.StatusCode != http.StatusOK {
392
+
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
+
return nil, false
394
+
}
395
+
396
+
contentType := resp.Header.Get("Content-Type")
397
+
398
+
body := resp.Body
399
+
bodyBytes, err := io.ReadAll(body)
400
+
if err != nil {
401
+
log.Printf("error when fetching external image from %s: %v", url, err)
402
+
return nil, false
403
+
}
404
+
405
+
// Handle SVG separately
406
+
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
407
+
return c.convertSVGToPNG(bodyBytes)
408
+
}
409
+
410
+
// Support content types are in-sync with the allowed custom avatar file types
411
+
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
412
+
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
413
+
return nil, false
414
+
}
415
+
416
+
bodyBuffer := bytes.NewReader(bodyBytes)
417
+
_, imgType, err := image.DecodeConfig(bodyBuffer)
418
+
if err != nil {
419
+
log.Printf("error when decoding external image from %s: %v", url, err)
420
+
return nil, false
421
+
}
422
+
423
+
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
424
+
if (contentType == "image/png" && imgType != "png") ||
425
+
(contentType == "image/jpeg" && imgType != "jpeg") ||
426
+
(contentType == "image/gif" && imgType != "gif") ||
427
+
(contentType == "image/webp" && imgType != "webp") {
428
+
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
429
+
return nil, false
430
+
}
431
+
432
+
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
433
+
if err != nil {
434
+
log.Printf("error w/ bodyBuffer.Seek")
435
+
return nil, false
436
+
}
437
+
img, _, err := image.Decode(bodyBuffer)
438
+
if err != nil {
439
+
log.Printf("error when decoding external image from %s: %v", url, err)
440
+
return nil, false
441
+
}
442
+
443
+
return img, true
444
+
}
445
+
446
+
// convertSVGToPNG converts SVG data to a PNG image
447
+
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
448
+
// Parse the SVG
449
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
450
+
if err != nil {
451
+
log.Printf("error parsing SVG: %v", err)
452
+
return nil, false
453
+
}
454
+
455
+
// Set a reasonable size for the rasterized image
456
+
width := 256
457
+
height := 256
458
+
icon.SetTarget(0, 0, float64(width), float64(height))
459
+
460
+
// Create an image to draw on
461
+
rgba := image.NewRGBA(image.Rect(0, 0, width, height))
462
+
463
+
// Fill with white background
464
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
465
+
466
+
// Create a scanner and rasterize the SVG
467
+
scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
468
+
raster := rasterx.NewDasher(width, height, scanner)
469
+
470
+
icon.Draw(raster, 1.0)
471
+
472
+
return rgba, true
473
+
}
474
+
475
+
func (c *Card) DrawExternalImage(url string) {
476
+
image, ok := c.fetchExternalImage(url)
477
+
if !ok {
478
+
image = fallbackImage()
479
+
}
480
+
c.DrawImage(image)
481
+
}
482
+
483
+
// DrawCircularExternalImage draws an external image as a circle at the specified position
484
+
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
485
+
img, ok := c.fetchExternalImage(url)
486
+
if !ok {
487
+
img = fallbackImage()
488
+
}
489
+
490
+
// Create a circular mask
491
+
circle := image.NewRGBA(image.Rect(0, 0, size, size))
492
+
center := size / 2
493
+
radius := float64(size / 2)
494
+
495
+
// Scale the source image to fit the circle
496
+
srcBounds := img.Bounds()
497
+
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
498
+
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
499
+
500
+
// Draw the image with circular clipping
501
+
for cy := 0; cy < size; cy++ {
502
+
for cx := 0; cx < size; cx++ {
503
+
// Calculate distance from center
504
+
dx := float64(cx - center)
505
+
dy := float64(cy - center)
506
+
distance := math.Sqrt(dx*dx + dy*dy)
507
+
508
+
// Only draw pixels within the circle
509
+
if distance <= radius {
510
+
circle.Set(cx, cy, scaledImg.At(cx, cy))
511
+
}
512
+
}
513
+
}
514
+
515
+
// Draw the circle onto the card
516
+
bounds := c.Img.Bounds()
517
+
destRect := image.Rect(x, y, x+size, y+size)
518
+
519
+
// Make sure we don't draw outside the card bounds
520
+
if destRect.Max.X > bounds.Max.X {
521
+
destRect.Max.X = bounds.Max.X
522
+
}
523
+
if destRect.Max.Y > bounds.Max.Y {
524
+
destRect.Max.Y = bounds.Max.Y
525
+
}
526
+
527
+
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
528
+
529
+
return nil
530
+
}
531
+
532
+
// DrawRect draws a rect with the given color
533
+
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
534
+
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
535
+
}
+10
-9
appview/pages/funcmap.go
+10
-9
appview/pages/funcmap.go
···
265
265
return nil
266
266
},
267
267
"i": func(name string, classes ...string) template.HTML {
268
-
data, err := icon(name, classes)
268
+
data, err := p.icon(name, classes)
269
269
if err != nil {
270
270
log.Printf("icon %s does not exist", name)
271
-
data, _ = icon("airplay", classes)
271
+
data, _ = p.icon("airplay", classes)
272
272
}
273
273
return template.HTML(data)
274
274
},
275
-
"cssContentHash": CssContentHash,
275
+
"cssContentHash": p.CssContentHash,
276
276
"fileTree": filetree.FileTree,
277
277
"pathEscape": func(s string) string {
278
278
return url.PathEscape(s)
···
283
283
},
284
284
285
285
"tinyAvatar": func(handle string) string {
286
-
return p.avatarUri(handle, "tiny")
286
+
return p.AvatarUrl(handle, "tiny")
287
287
},
288
288
"fullAvatar": func(handle string) string {
289
-
return p.avatarUri(handle, "")
289
+
return p.AvatarUrl(handle, "")
290
290
},
291
291
"langColor": enry.GetColor,
292
292
"layoutSide": func() string {
···
297
297
},
298
298
299
299
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
300
+
normalized := strings.ReplaceAll(s, ":", "_")
301
+
normalized = strings.ReplaceAll(normalized, ".", "_")
302
+
return normalized
302
303
},
303
304
"sshFingerprint": func(pubKey string) string {
304
305
fp, err := crypto.SSHFingerprint(pubKey)
···
310
311
}
311
312
}
312
313
313
-
func (p *Pages) avatarUri(handle, size string) string {
314
+
func (p *Pages) AvatarUrl(handle, size string) string {
314
315
handle = strings.TrimPrefix(handle, "@")
315
316
316
317
secret := p.avatar.SharedSecret
···
325
326
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
326
327
}
327
328
328
-
func icon(name string, classes []string) (template.HTML, error) {
329
+
func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
329
330
iconPath := filepath.Join("static", "icons", name)
330
331
331
332
if filepath.Ext(name) == "" {
+5
-2
appview/pages/funcmap_test.go
+5
-2
appview/pages/funcmap_test.go
···
2
2
3
3
import (
4
4
"html/template"
5
+
"log/slog"
6
+
"testing"
7
+
5
8
"tangled.org/core/appview/config"
6
9
"tangled.org/core/idresolver"
7
-
"testing"
8
10
)
9
11
10
12
func TestPages_funcMap(t *testing.T) {
···
13
15
// Named input parameters for receiver constructor.
14
16
config *config.Config
15
17
res *idresolver.Resolver
18
+
l *slog.Logger
16
19
want template.FuncMap
17
20
}{
18
21
// TODO: Add test cases.
19
22
}
20
23
for _, tt := range tests {
21
24
t.Run(tt.name, func(t *testing.T) {
22
-
p := NewPages(tt.config, tt.res)
25
+
p := NewPages(tt.config, tt.res, tt.l)
23
26
got := p.funcMap()
24
27
// TODO: update the condition below to compare got with tt.want.
25
28
if true {
+6
-1
appview/pages/markup/markdown.go
+6
-1
appview/pages/markup/markdown.go
···
5
5
"bytes"
6
6
"fmt"
7
7
"io"
8
+
"io/fs"
8
9
"net/url"
9
10
"path"
10
11
"strings"
···
20
21
"github.com/yuin/goldmark/renderer/html"
21
22
"github.com/yuin/goldmark/text"
22
23
"github.com/yuin/goldmark/util"
24
+
callout "gitlab.com/staticnoise/goldmark-callout"
23
25
htmlparse "golang.org/x/net/html"
24
26
25
27
"tangled.org/core/api/tangled"
···
45
47
IsDev bool
46
48
RendererType RendererType
47
49
Sanitizer Sanitizer
50
+
Files fs.FS
48
51
}
49
52
50
53
func (rctx *RenderContext) RenderMarkdown(source string) string {
···
62
65
extension.WithFootnoteIDPrefix([]byte("footnote")),
63
66
),
64
67
treeblood.MathML(),
68
+
callout.CalloutExtention,
65
69
),
66
70
goldmark.WithParserOptions(
67
71
parser.WithAutoHeadingID(),
···
140
144
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
141
145
switch node.Type {
142
146
case htmlparse.ElementNode:
143
-
if node.Data == "img" || node.Data == "source" {
147
+
switch node.Data {
148
+
case "img", "source":
144
149
for i, attr := range node.Attr {
145
150
if attr.Key != "src" {
146
151
continue
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
+42
-23
appview/pages/pages.go
+42
-23
appview/pages/pages.go
···
54
54
logger *slog.Logger
55
55
}
56
56
57
-
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
57
+
func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages {
58
58
// initialized with safe defaults, can be overriden per use
59
59
rctx := &markup.RenderContext{
60
60
IsDev: config.Core.Dev,
61
61
CamoUrl: config.Camo.Host,
62
62
CamoSecret: config.Camo.SharedSecret,
63
63
Sanitizer: markup.NewSanitizer(),
64
+
Files: Files,
64
65
}
65
66
66
67
p := &Pages{
···
71
72
rctx: rctx,
72
73
resolver: res,
73
74
templateDir: "appview/pages",
74
-
logger: slog.Default().With("component", "pages"),
75
+
logger: logger,
75
76
}
76
77
77
78
if p.dev {
···
220
221
221
222
type LoginParams struct {
222
223
ReturnUrl string
224
+
ErrorCode string
223
225
}
224
226
225
227
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
306
308
LoggedInUser *oauth.User
307
309
Timeline []models.TimelineEvent
308
310
Repos []models.Repo
311
+
GfiLabel *models.LabelDefinition
309
312
}
310
313
311
314
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
312
315
return p.execute("timeline/timeline", w, params)
316
+
}
317
+
318
+
type GoodFirstIssuesParams struct {
319
+
LoggedInUser *oauth.User
320
+
Issues []models.Issue
321
+
RepoGroups []*models.RepoGroup
322
+
LabelDefs map[string]*models.LabelDefinition
323
+
GfiLabel *models.LabelDefinition
324
+
Page pagination.Page
325
+
}
326
+
327
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
328
+
return p.execute("goodfirstissues/index", w, params)
313
329
}
314
330
315
331
type UserProfileSettingsParams struct {
···
971
987
LabelDefs map[string]*models.LabelDefinition
972
988
973
989
OrderedReactionKinds []models.ReactionKind
974
-
Reactions map[models.ReactionKind]int
990
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
975
991
UserReacted map[models.ReactionKind]bool
976
992
}
977
993
···
996
1012
ThreadAt syntax.ATURI
997
1013
Kind models.ReactionKind
998
1014
Count int
1015
+
Users []string
999
1016
IsReacted bool
1000
1017
}
1001
1018
···
1113
1130
}
1114
1131
1115
1132
type RepoSinglePullParams struct {
1116
-
LoggedInUser *oauth.User
1117
-
RepoInfo repoinfo.RepoInfo
1118
-
Active string
1119
-
Pull *models.Pull
1120
-
Stack models.Stack
1121
-
AbandonedPulls []*models.Pull
1122
-
MergeCheck types.MergeCheckResponse
1123
-
ResubmitCheck ResubmitResult
1124
-
Pipelines map[string]models.Pipeline
1133
+
LoggedInUser *oauth.User
1134
+
RepoInfo repoinfo.RepoInfo
1135
+
Active string
1136
+
Pull *models.Pull
1137
+
Stack models.Stack
1138
+
AbandonedPulls []*models.Pull
1139
+
BranchDeleteStatus *models.BranchDeleteStatus
1140
+
MergeCheck types.MergeCheckResponse
1141
+
ResubmitCheck ResubmitResult
1142
+
Pipelines map[string]models.Pipeline
1125
1143
1126
1144
OrderedReactionKinds []models.ReactionKind
1127
-
Reactions map[models.ReactionKind]int
1145
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
1128
1146
UserReacted map[models.ReactionKind]bool
1129
1147
1130
1148
LabelDefs map[string]*models.LabelDefinition
···
1217
1235
}
1218
1236
1219
1237
type PullActionsParams struct {
1220
-
LoggedInUser *oauth.User
1221
-
RepoInfo repoinfo.RepoInfo
1222
-
Pull *models.Pull
1223
-
RoundNumber int
1224
-
MergeCheck types.MergeCheckResponse
1225
-
ResubmitCheck ResubmitResult
1226
-
Stack models.Stack
1238
+
LoggedInUser *oauth.User
1239
+
RepoInfo repoinfo.RepoInfo
1240
+
Pull *models.Pull
1241
+
RoundNumber int
1242
+
MergeCheck types.MergeCheckResponse
1243
+
ResubmitCheck ResubmitResult
1244
+
BranchDeleteStatus *models.BranchDeleteStatus
1245
+
Stack models.Stack
1227
1246
}
1228
1247
1229
1248
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1460
1479
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1461
1480
}
1462
1481
1463
-
sub, err := fs.Sub(Files, "static")
1482
+
sub, err := fs.Sub(p.embedFS, "static")
1464
1483
if err != nil {
1465
1484
p.logger.Error("no static dir found? that's crazy", "err", err)
1466
1485
panic(err)
···
1483
1502
})
1484
1503
}
1485
1504
1486
-
func CssContentHash() string {
1487
-
cssFile, err := Files.Open("static/tw.css")
1505
+
func (p *Pages) CssContentHash() string {
1506
+
cssFile, err := p.embedFS.Open("static/tw.css")
1488
1507
if err != nil {
1489
1508
slog.Debug("Error opening CSS file", "err", err)
1490
1509
return ""
+44
appview/pages/templates/fragments/dolly/silhouette.svg
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
1
+
<svg
2
+
version="1.1"
3
+
id="svg1"
4
+
width="32"
5
+
height="32"
6
+
viewBox="0 0 25 25"
7
+
sodipodi:docname="tangled_dolly_silhouette.png"
8
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
9
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
xmlns:svg="http://www.w3.org/2000/svg">
12
+
<title>Dolly</title>
13
+
<defs
14
+
id="defs1" />
15
+
<sodipodi:namedview
16
+
id="namedview1"
17
+
pagecolor="#ffffff"
18
+
bordercolor="#000000"
19
+
borderopacity="0.25"
20
+
inkscape:showpageshadow="2"
21
+
inkscape:pageopacity="0.0"
22
+
inkscape:pagecheckerboard="true"
23
+
inkscape:deskcolor="#d1d1d1">
24
+
<inkscape:page
25
+
x="0"
26
+
y="0"
27
+
width="25"
28
+
height="25"
29
+
id="page2"
30
+
margin="0"
31
+
bleed="0" />
32
+
</sodipodi:namedview>
33
+
<g
34
+
inkscape:groupmode="layer"
35
+
inkscape:label="Image"
36
+
id="g1">
37
+
<path
38
+
class="dolly"
39
+
fill="currentColor"
40
+
style="stroke-width:1.12248"
41
+
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
42
+
id="path1" />
43
+
</g>
44
+
</svg>
+167
appview/pages/templates/goodfirstissues/index.html
+167
appview/pages/templates/goodfirstissues/index.html
···
1
+
{{ define "title" }}good first issues{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="good first issues · tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.org/goodfirstissues" />
7
+
<meta property="og:description" content="Find good first issues to contribute to open source projects" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
<div class="grid grid-cols-10">
12
+
<header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8">
13
+
<h1 class="scale-150 dark:text-white mb-4">
14
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
17
+
Find beginner-friendly issues across all repositories to get started with open source contributions.
18
+
</p>
19
+
</header>
20
+
21
+
<div class="col-span-full md:col-span-10 space-y-6">
22
+
{{ if eq (len .RepoGroups) 0 }}
23
+
<div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
24
+
<div class="text-center py-16">
25
+
<div class="text-gray-500 dark:text-gray-400 mb-4">
26
+
{{ i "circle-dot" "w-16 h-16 mx-auto" }}
27
+
</div>
28
+
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3>
29
+
<p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto">
30
+
There are currently no open issues labeled as "good-first-issue" across all repositories.
31
+
</p>
32
+
<p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto">
33
+
Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started.
34
+
</p>
35
+
</div>
36
+
</div>
37
+
{{ else }}
38
+
{{ range .RepoGroups }}
39
+
<div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
40
+
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap">
41
+
<div class="font-medium dark:text-white flex items-center justify-between">
42
+
<div class="flex items-center min-w-0 flex-1 mr-2">
43
+
{{ if .Repo.Source }}
44
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
45
+
{{ else }}
46
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
47
+
{{ end }}
48
+
{{ $repoOwner := resolve .Repo.Did }}
49
+
<a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a>
50
+
</div>
51
+
</div>
52
+
53
+
54
+
{{ if .Repo.RepoStats }}
55
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4">
56
+
{{ with .Repo.RepoStats.Language }}
57
+
<div class="flex gap-2 items-center text-sm">
58
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
59
+
<span>{{ . }}</span>
60
+
</div>
61
+
{{ end }}
62
+
{{ with .Repo.RepoStats.StarCount }}
63
+
<div class="flex gap-1 items-center text-sm">
64
+
{{ i "star" "w-3 h-3 fill-current" }}
65
+
<span>{{ . }}</span>
66
+
</div>
67
+
{{ end }}
68
+
{{ with .Repo.RepoStats.IssueCount.Open }}
69
+
<div class="flex gap-1 items-center text-sm">
70
+
{{ i "circle-dot" "w-3 h-3" }}
71
+
<span>{{ . }}</span>
72
+
</div>
73
+
{{ end }}
74
+
{{ with .Repo.RepoStats.PullCount.Open }}
75
+
<div class="flex gap-1 items-center text-sm">
76
+
{{ i "git-pull-request" "w-3 h-3" }}
77
+
<span>{{ . }}</span>
78
+
</div>
79
+
{{ end }}
80
+
</div>
81
+
{{ end }}
82
+
</div>
83
+
84
+
{{ with .Repo.Description }}
85
+
<div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
86
+
{{ . | description }}
87
+
</div>
88
+
{{ end }}
89
+
90
+
{{ if gt (len .Issues) 0 }}
91
+
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
92
+
{{ range .Issues }}
93
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
94
+
<div class="py-2 px-6">
95
+
<div class="flex-grow min-w-0 w-full">
96
+
<div class="flex text-sm items-center justify-between w-full">
97
+
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
98
+
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
99
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
100
+
{{ .Title | description }}
101
+
</span>
102
+
</div>
103
+
<div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400">
104
+
<span>
105
+
<div class="inline-flex items-center gap-1">
106
+
{{ i "message-square" "w-3 h-3" }}
107
+
{{ len .Comments }}
108
+
</div>
109
+
</span>
110
+
<span class="before:content-['·'] before:select-none"></span>
111
+
<span class="text-sm">
112
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
113
+
</span>
114
+
<div class="hidden md:inline-flex md:gap-1">
115
+
{{ $labelState := .Labels }}
116
+
{{ range $k, $d := $.LabelDefs }}
117
+
{{ range $v, $s := $labelState.GetValSet $d.AtUri.String }}
118
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
119
+
{{ end }}
120
+
{{ end }}
121
+
</div>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
</div>
126
+
</a>
127
+
{{ end }}
128
+
</div>
129
+
{{ end }}
130
+
</div>
131
+
{{ end }}
132
+
133
+
{{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }}
134
+
<div class="flex justify-center mt-8">
135
+
<div class="flex gap-2">
136
+
{{ if gt .Page.Offset 0 }}
137
+
{{ $prev := .Page.Previous }}
138
+
<a
139
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
140
+
hx-boost="true"
141
+
href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
142
+
>
143
+
{{ i "chevron-left" "w-4 h-4" }}
144
+
previous
145
+
</a>
146
+
{{ else }}
147
+
<div></div>
148
+
{{ end }}
149
+
150
+
{{ if eq (len .RepoGroups) .Page.Limit }}
151
+
{{ $next := .Page.Next }}
152
+
<a
153
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
154
+
hx-boost="true"
155
+
href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
156
+
>
157
+
next
158
+
{{ i "chevron-right" "w-4 h-4" }}
159
+
</a>
160
+
{{ end }}
161
+
</div>
162
+
</div>
163
+
{{ end }}
164
+
{{ end }}
165
+
</div>
166
+
</div>
167
+
{{ end }}
+1
-1
appview/pages/templates/labels/fragments/label.html
+1
-1
appview/pages/templates/labels/fragments/label.html
···
2
2
{{ $d := .def }}
3
3
{{ $v := .val }}
4
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
7
8
8
{{ $lhs := printf "%s" $d.Name }}
+16
-12
appview/pages/templates/layouts/base.html
+16
-12
appview/pages/templates/layouts/base.html
···
14
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
17
20
<!-- preload main font -->
18
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
22
···
21
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
25
{{ block "extrameta" . }}{{ end }}
23
26
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"
25
-
style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);">
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
26
28
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
29
+
<header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
30
29
31
{{ if .LoggedInUser }}
30
32
<div id="upgrade-banner"
···
38
40
{{ end }}
39
41
40
42
{{ block "mainLayout" . }}
41
-
<div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4">
42
-
{{ block "contentLayout" . }}
43
-
<main class="col-span-1 md:col-span-8">
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
44
47
{{ block "content" . }}{{ end }}
45
48
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
50
53
{{ block "contentAfter" . }}{{ end }}
51
54
</main>
52
-
{{ end }}
55
+
{{ end }}
56
+
</div>
53
57
</div>
54
58
{{ end }}
55
59
56
60
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
61
+
<footer class="mt-12">
58
62
{{ template "layouts/fragments/footer" . }}
59
63
</footer>
60
64
{{ end }}
+2
-2
appview/pages/templates/layouts/fragments/topbar.html
+2
-2
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
2
+
<nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
···
51
51
<summary
52
52
class="cursor-pointer list-none flex items-center gap-1"
53
53
>
54
-
{{ $user := didOrHandle .Did .Handle }}
54
+
{{ $user := .Did }}
55
55
<img
56
56
src="{{ tinyAvatar $user }}"
57
57
alt=""
+9
appview/pages/templates/layouts/profilebase.html
+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
+5
-2
appview/pages/templates/notifications/fragments/item.html
···
8
8
">
9
9
{{ template "notificationIcon" . }}
10
10
<div class="flex-1 w-full flex flex-col gap-1">
11
-
<span>{{ template "notificationHeader" . }}</span>
11
+
<div class="flex items-center gap-1">
12
+
<span>{{ template "notificationHeader" . }}</span>
13
+
<span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span>
14
+
</div>
12
15
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
16
</div>
14
17
···
19
22
{{ define "notificationIcon" }}
20
23
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
24
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
-
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
25
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10">
23
26
{{ i .Icon "size-3 text-black dark:text-white" }}
24
27
</div>
25
28
</div>
+3
-3
appview/pages/templates/repo/commit.html
+3
-3
appview/pages/templates/repo/commit.html
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<header class="px-1 col-span-full" style="z-index: 20;">
83
+
<header class="col-span-full" style="z-index: 20;">
84
84
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
88
88
{{ define "mainLayout" }}
89
-
<div class="px-1 col-span-full flex flex-col gap-4">
89
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
90
90
{{ block "contentLayout" . }}
91
91
{{ block "content" . }}{{ end }}
92
92
{{ end }}
···
105
105
{{ end }}
106
106
107
107
{{ define "footerLayout" }}
108
-
<footer class="px-1 col-span-full mt-12">
108
+
<footer class="col-span-full mt-12">
109
109
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
+1
-1
appview/pages/templates/repo/fragments/cloneDropdown.html
+1
-1
appview/pages/templates/repo/fragments/cloneDropdown.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+9
-1
appview/pages/templates/repo/fragments/og.html
+9
-1
appview/pages/templates/repo/fragments/og.html
···
2
2
{{ $title := or .Title .RepoInfo.FullName }}
3
3
{{ $description := or .Description .RepoInfo.Description }}
4
4
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
-
5
+
{{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }}
6
6
7
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
8
<meta property="og:type" content="object" />
9
9
<meta property="og:url" content="{{ $url }}" />
10
10
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
11
19
{{ end }}
+1
-1
appview/pages/templates/repo/fragments/participants.html
+1
-1
appview/pages/templates/repo/fragments/participants.html
···
1
1
{{ define "repo/fragments/participants" }}
2
2
{{ $all := . }}
3
3
{{ $ps := take $all 5 }}
4
-
<div class="px-6 md:px-0">
4
+
<div class="px-2 md:px-0">
5
5
<div class="py-1 flex items-center text-sm">
6
6
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
7
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+6
-1
appview/pages/templates/repo/fragments/reaction.html
+6
-1
appview/pages/templates/repo/fragments/reaction.html
···
2
2
<button
3
3
id="reactIndi-{{ .Kind }}"
4
4
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
-
leading-4 px-3 gap-1
5
+
leading-4 px-3 gap-1 relative group
6
6
{{ if eq .Count 0 }}
7
7
hidden
8
8
{{ end }}
···
20
20
dark:hover:border-gray-600
21
21
{{ end }}
22
22
"
23
+
{{ if gt (length .Users) 0 }}
24
+
title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}"
25
+
{{ else }}
26
+
title="{{ .Kind }}"
27
+
{{ end }}
23
28
{{ if .IsReacted }}
24
29
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
25
30
{{ else }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
1
+
{{ define "repo/issues/fragments/globalIssueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2 mb-3">
6
+
<div class="flex items-center gap-3 mb-2">
7
+
<a
8
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
+
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
+
>
11
+
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
+
</a>
13
+
</div>
14
+
<a
15
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
+
class="no-underline hover:underline"
17
+
>
18
+
{{ .Title | description }}
19
+
<span class="text-gray-500">#{{ .IssueId }}</span>
20
+
</a>
21
+
</div>
22
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
+
{{ $icon := "ban" }}
25
+
{{ $state := "closed" }}
26
+
{{ if .Open }}
27
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
+
{{ $icon = "circle-dot" }}
29
+
{{ $state = "open" }}
30
+
{{ end }}
31
+
32
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
+
<span class="text-white dark:text-white">{{ $state }}</span>
35
+
</span>
36
+
37
+
<span class="ml-1">
38
+
{{ template "user/fragments/picHandleLink" .Did }}
39
+
</span>
40
+
41
+
<span class="before:content-['·']">
42
+
{{ template "repo/fragments/time" .Created }}
43
+
</span>
44
+
45
+
<span class="before:content-['·']">
46
+
{{ $s := "s" }}
47
+
{{ if eq (len .Comments) 1 }}
48
+
{{ $s = "" }}
49
+
{{ end }}
50
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
+
</span>
52
+
53
+
{{ $state := .Labels }}
54
+
{{ range $k, $d := $.LabelDefs }}
55
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
+
{{ end }}
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
34
34
35
35
{{ define "editIssueComment" }}
36
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
38
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
44
45
45
{{ define "deleteIssueComment" }}
46
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
48
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
1
+
{{ define "repo/issues/fragments/issueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2">
6
+
<a
7
+
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
+
class="no-underline hover:underline"
9
+
>
10
+
{{ .Title | description }}
11
+
<span class="text-gray-500">#{{ .IssueId }}</span>
12
+
</a>
13
+
</div>
14
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
+
{{ $icon := "ban" }}
17
+
{{ $state := "closed" }}
18
+
{{ if .Open }}
19
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
+
{{ $icon = "circle-dot" }}
21
+
{{ $state = "open" }}
22
+
{{ end }}
23
+
24
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
+
<span class="text-white dark:text-white">{{ $state }}</span>
27
+
</span>
28
+
29
+
<span class="ml-1">
30
+
{{ template "user/fragments/picHandleLink" .Did }}
31
+
</span>
32
+
33
+
<span class="before:content-['·']">
34
+
{{ template "repo/fragments/time" .Created }}
35
+
</span>
36
+
37
+
<span class="before:content-['·']">
38
+
{{ $s := "s" }}
39
+
{{ if eq (len .Comments) 1 }}
40
+
{{ $s = "" }}
41
+
{{ end }}
42
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
+
</span>
44
+
45
+
{{ $state := .Labels }}
46
+
{{ range $k, $d := $.LabelDefs }}
47
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
+
{{ end }}
50
+
{{ end }}
51
+
</div>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
···
138
138
</div>
139
139
</form>
140
140
{{ else }}
141
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
142
-
<a href="/login" class="underline">login</a> to join the discussion
141
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center">
142
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
143
+
sign up
144
+
</a>
145
+
<span class="text-gray-500 dark:text-gray-400">or</span>
146
+
<a href="/login" class="underline">login</a>
147
+
to add to the discussion
143
148
</div>
144
149
{{ end }}
145
150
{{ end }}
+19
appview/pages/templates/repo/issues/fragments/og.html
+19
appview/pages/templates/repo/issues/fragments/og.html
···
1
+
{{ define "repo/issues/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
3
+
{{ $description := or .Issue.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+7
-8
appview/pages/templates/repo/issues/issue.html
+7
-8
appview/pages/templates/repo/issues/issue.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
5
-
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
-
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
-
8
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
5
+
{{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }}
9
6
{{ end }}
10
7
11
8
{{ define "repoContentLayout" }}
···
87
84
88
85
{{ define "editIssue" }}
89
86
<a
90
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
87
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
91
88
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
92
89
hx-swap="innerHTML"
93
90
hx-target="#issue-{{.Issue.IssueId}}">
···
97
94
98
95
{{ define "deleteIssue" }}
99
96
<a
100
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
97
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
101
98
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
102
99
hx-confirm="Are you sure you want to delete your issue?"
103
100
hx-swap="none">
···
110
107
<div class="flex items-center gap-2">
111
108
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
109
{{ range $kind := .OrderedReactionKinds }}
110
+
{{ $reactionData := index $.Reactions $kind }}
113
111
{{
114
112
template "repo/fragments/reaction"
115
113
(dict
116
114
"Kind" $kind
117
-
"Count" (index $.Reactions $kind)
115
+
"Count" $reactionData.Count
118
116
"IsReacted" (index $.UserReacted $kind)
119
-
"ThreadAt" $.Issue.AtUri)
117
+
"ThreadAt" $.Issue.AtUri
118
+
"Users" $reactionData.Users)
120
119
}}
121
120
{{ end }}
122
121
</div>
+2
-52
appview/pages/templates/repo/issues/issues.html
+2
-52
appview/pages/templates/repo/issues/issues.html
···
37
37
{{ end }}
38
38
39
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
83
-
{{ $state := .Labels }}
84
-
{{ range $k, $d := $.LabelDefs }}
85
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
86
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
87
-
{{ end }}
88
-
{{ end }}
89
-
</div>
90
-
</div>
91
-
{{ end }}
40
+
<div class="mt-2">
41
+
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
92
42
</div>
93
43
{{ block "pagination" . }} {{ end }}
94
44
{{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
+19
appview/pages/templates/repo/pulls/fragments/og.html
···
1
+
{{ define "repo/pulls/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
3
+
{{ $description := or .Pull.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
33
33
<span>comment</span>
34
34
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
35
</button>
36
+
{{ if .BranchDeleteStatus }}
37
+
<button
38
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
39
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
40
+
hx-swap="none"
41
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
42
+
{{ i "git-branch" "w-4 h-4" }}
43
+
<span>delete branch</span>
44
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
+
</button>
46
+
{{ end }}
36
47
{{ if and $isPushAllowed $isOpen $isLastRound }}
37
48
{{ $disabled := "" }}
38
49
{{ if $isConflicted }}
+15
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+15
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
42
42
{{ if not .Pull.IsPatchBased }}
43
43
from
44
44
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
45
-
{{ if .Pull.IsForkBased }}
46
-
{{ if .Pull.PullSource.Repo }}
47
-
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
-
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
-
{{- else -}}
50
-
<span class="italic">[deleted fork]</span>
51
-
{{- end -}}
52
-
{{- end -}}
53
-
{{- .Pull.PullSource.Branch -}}
45
+
{{ if not .Pull.IsForkBased }}
46
+
{{ $repoPath := .RepoInfo.FullName }}
47
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
48
+
{{ else if .Pull.PullSource.Repo }}
49
+
{{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }}
50
+
<a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>:
51
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
52
+
{{ else }}
53
+
<span class="italic">[deleted fork]</span>:
54
+
{{ .Pull.PullSource.Branch }}
55
+
{{ end }}
54
56
</span>
55
57
{{ end }}
56
58
</span>
···
66
68
<div class="flex items-center gap-2 mt-2">
67
69
{{ template "repo/fragments/reactionsPopUp" . }}
68
70
{{ range $kind := . }}
71
+
{{ $reactionData := index $.Reactions $kind }}
69
72
{{
70
73
template "repo/fragments/reaction"
71
74
(dict
72
75
"Kind" $kind
73
-
"Count" (index $.Reactions $kind)
76
+
"Count" $reactionData.Count
74
77
"IsReacted" (index $.UserReacted $kind)
75
-
"ThreadAt" $.Pull.PullAt)
78
+
"ThreadAt" $.Pull.PullAt
79
+
"Users" $reactionData.Users)
76
80
}}
77
81
{{ end }}
78
82
</div>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
3
3
id="pull-comment-card-{{ .RoundNumber }}"
4
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
6
+
{{ resolve .LoggedInUser.Did }}
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
···
28
28
29
29
{{ end }}
30
30
31
-
{{ define "topbarLayout" }}
32
-
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/fragments/topbar" . }}
34
-
</header>
35
-
{{ end }}
36
-
37
31
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex flex-col gap-4">
32
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
39
33
{{ block "contentLayout" . }}
40
34
{{ block "content" . }}{{ end }}
41
35
{{ end }}
···
52
46
{{ end }}
53
47
</div>
54
48
{{ end }}
55
-
56
-
{{ define "footerLayout" }}
57
-
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/fragments/footer" . }}
59
-
</footer>
60
-
{{ end }}
61
-
62
49
63
50
{{ define "contentAfter" }}
64
51
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1
-13
appview/pages/templates/repo/pulls/patch.html
+1
-13
appview/pages/templates/repo/pulls/patch.html
···
34
34
</section>
35
35
{{ end }}
36
36
37
-
{{ define "topbarLayout" }}
38
-
<header class="px-1 col-span-full" style="z-index: 20;">
39
-
{{ template "layouts/fragments/topbar" . }}
40
-
</header>
41
-
{{ end }}
42
-
43
37
{{ define "mainLayout" }}
44
-
<div class="px-1 col-span-full flex flex-col gap-4">
38
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
45
39
{{ block "contentLayout" . }}
46
40
{{ block "content" . }}{{ end }}
47
41
{{ end }}
···
57
51
</div>
58
52
{{ end }}
59
53
</div>
60
-
{{ end }}
61
-
62
-
{{ define "footerLayout" }}
63
-
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/fragments/footer" . }}
65
-
</footer>
66
54
{{ end }}
67
55
68
56
{{ define "contentAfter" }}
+18
-8
appview/pages/templates/repo/pulls/pull.html
+18
-8
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "extrameta" }}
6
-
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
-
9
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
6
+
{{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }}
10
7
{{ end }}
11
8
12
9
{{ define "repoContentLayout" }}
···
187
184
{{ end }}
188
185
189
186
{{ if $.LoggedInUser }}
190
-
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
187
+
{{ template "repo/pulls/fragments/pullActions"
188
+
(dict
189
+
"LoggedInUser" $.LoggedInUser
190
+
"Pull" $.Pull
191
+
"RepoInfo" $.RepoInfo
192
+
"RoundNumber" .RoundNumber
193
+
"MergeCheck" $.MergeCheck
194
+
"ResubmitCheck" $.ResubmitCheck
195
+
"BranchDeleteStatus" $.BranchDeleteStatus
196
+
"Stack" $.Stack) }}
191
197
{{ else }}
192
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
193
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
194
-
<a href="/login" class="underline">login</a> to join the discussion
198
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit">
199
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
200
+
sign up
201
+
</a>
202
+
<span class="text-gray-500 dark:text-gray-400">or</span>
203
+
<a href="/login" class="underline">login</a>
204
+
to add to the discussion
195
205
</div>
196
206
{{ end }}
197
207
</div>
+2
appview/pages/templates/repo/settings/access.html
+2
appview/pages/templates/repo/settings/access.html
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
47
47
</span>
48
48
</section>
49
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
50
-
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
50
+
<div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
51
51
<span>
52
52
{{ .String.Filename }}
53
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
1
+
{{ define "timeline/fragments/goodfirstissues" }}
2
+
{{ if .GfiLabel }}
3
+
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
+
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
+
<div class="flex-1 flex flex-col gap-2">
6
+
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
+
<p>
8
+
Make your first contribution to an open-source project this October.
9
+
<em>good-first-issue</em> helps new contributors find easy ways to
10
+
start contributing to open-source projects.
11
+
</p>
12
+
<span class="flex items-center gap-2 text-purple-500 dark:text-purple-400">
13
+
Browse issues {{ i "arrow-right" "size-4" }}
14
+
</span>
15
+
</div>
16
+
<div class="hidden md:block relative px-16 scale-150">
17
+
<div class="relative opacity-60">
18
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
19
+
</div>
20
+
<div class="relative -mt-4 ml-2 opacity-80">
21
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
22
+
</div>
23
+
<div class="relative -mt-4 ml-4">
24
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
25
+
</div>
26
+
</div>
27
+
</div>
28
+
</a>
29
+
{{ end }}
30
+
{{ end }}
+1
appview/pages/templates/timeline/home.html
+1
appview/pages/templates/timeline/home.html
···
12
12
<div class="flex flex-col gap-4">
13
13
{{ template "timeline/fragments/hero" . }}
14
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
15
16
{{ template "timeline/fragments/trending" . }}
16
17
{{ template "timeline/fragments/timeline" . }}
17
18
<div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/completeSignup.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
3
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
7
</div>
8
8
9
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+24
-2
appview/pages/templates/user/login.html
+24
-2
appview/pages/templates/user/login.html
···
8
8
<meta property="og:url" content="https://tangled.org/login" />
9
9
<meta property="og:description" content="login to for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>login · tangled</title>
13
14
</head>
14
15
<body class="flex items-center justify-center min-h-screen">
15
-
<main class="max-w-md px-6 -mt-4">
16
+
<main class="max-w-md px-7 mt-4">
16
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
17
18
{{ template "fragments/logotype" }}
18
19
</h1>
···
20
21
tightly-knit social coding.
21
22
</h2>
22
23
<form
23
-
class="mt-4 max-w-sm mx-auto"
24
+
class="mt-4"
24
25
hx-post="/login"
25
26
hx-swap="none"
26
27
hx-disabled-elt="#login-button"
···
28
29
<div class="flex flex-col">
29
30
<label for="handle">handle</label>
30
31
<input
32
+
autocapitalize="none"
33
+
autocorrect="off"
34
+
autocomplete="username"
31
35
type="text"
32
36
id="handle"
33
37
name="handle"
···
52
56
<span>login</span>
53
57
</button>
54
58
</form>
59
+
{{ if .ErrorCode }}
60
+
<div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300">
61
+
<span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span>
62
+
<div>
63
+
<h5 class="font-medium">Login error</h5>
64
+
<p class="text-sm">
65
+
{{ if eq .ErrorCode "access_denied" }}
66
+
You have not authorized the app.
67
+
{{ else if eq .ErrorCode "session" }}
68
+
Server failed to create user session.
69
+
{{ else }}
70
+
Internal Server error.
71
+
{{ end }}
72
+
Please try again.
73
+
</p>
74
+
</div>
75
+
</div>
76
+
{{ end }}
55
77
<p class="text-sm text-gray-500">
56
78
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
57
79
</p>
+1
-3
appview/pages/templates/user/settings/profile.html
+1
-3
appview/pages/templates/user/settings/profile.html
···
33
33
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
34
<span>Handle</span>
35
35
</div>
36
-
{{ if .LoggedInUser.Handle }}
37
36
<span class="font-bold">
38
-
@{{ .LoggedInUser.Handle }}
37
+
{{ resolve .LoggedInUser.Did }}
39
38
</span>
40
-
{{ end }}
41
39
</div>
42
40
</div>
43
41
<div class="flex items-center justify-between p-4">
+1
appview/pages/templates/user/signup.html
+1
appview/pages/templates/user/signup.html
···
8
8
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>sign up · tangled</title>
13
14
+23
appview/pagination/page.go
+23
appview/pagination/page.go
···
1
1
package pagination
2
2
3
+
import "context"
4
+
3
5
type Page struct {
4
6
Offset int // where to start from
5
7
Limit int // number of items in a page
···
10
12
Offset: 0,
11
13
Limit: 30,
12
14
}
15
+
}
16
+
17
+
type ctxKey struct{}
18
+
19
+
func IntoContext(ctx context.Context, page Page) context.Context {
20
+
return context.WithValue(ctx, ctxKey{}, page)
21
+
}
22
+
23
+
func FromContext(ctx context.Context) Page {
24
+
if ctx == nil {
25
+
return FirstPage()
26
+
}
27
+
v := ctx.Value(ctxKey{})
28
+
if v == nil {
29
+
return FirstPage()
30
+
}
31
+
page, ok := v.(Page)
32
+
if !ok {
33
+
return FirstPage()
34
+
}
35
+
return page
13
36
}
14
37
15
38
func (p Page) Previous() Page {
+3
-4
appview/pipelines/pipelines.go
+3
-4
appview/pipelines/pipelines.go
···
16
16
"tangled.org/core/appview/reporesolver"
17
17
"tangled.org/core/eventconsumer"
18
18
"tangled.org/core/idresolver"
19
-
"tangled.org/core/log"
20
19
"tangled.org/core/rbac"
21
20
spindlemodel "tangled.org/core/spindle/models"
22
21
···
45
44
db *db.DB,
46
45
config *config.Config,
47
46
enforcer *rbac.Enforcer,
47
+
logger *slog.Logger,
48
48
) *Pipelines {
49
-
logger := log.New("pipelines")
50
-
51
-
return &Pipelines{oauth: oauth,
49
+
return &Pipelines{
50
+
oauth: oauth,
52
51
repoResolver: repoResolver,
53
52
pages: pages,
54
53
idResolver: idResolver,
+321
appview/pulls/opengraph.go
+321
appview/pulls/opengraph.go
···
1
+
package pulls
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/ogcard"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
18
+
)
19
+
20
+
func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) {
21
+
width, height := ogcard.DefaultSize()
22
+
mainCard, err := ogcard.NewCard(width, height)
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
// Split: content area (75%) and status/stats area (25%)
28
+
contentCard, statsArea := mainCard.Split(false, 75)
29
+
30
+
// Add padding to content
31
+
contentCard.SetMargin(50)
32
+
33
+
// Split content horizontally: main content (80%) and avatar area (20%)
34
+
mainContent, avatarArea := contentCard.Split(true, 80)
35
+
36
+
// Add margin to main content
37
+
mainContent.SetMargin(10)
38
+
39
+
// Use full main content area for repo name and title
40
+
bounds := mainContent.Img.Bounds()
41
+
startX := bounds.Min.X + mainContent.Margin
42
+
startY := bounds.Min.Y + mainContent.Margin
43
+
44
+
// Draw full repository name at top (owner/repo format)
45
+
var repoOwner string
46
+
owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did)
47
+
if err != nil {
48
+
repoOwner = repo.Did
49
+
} else {
50
+
repoOwner = "@" + owner.Handle.String()
51
+
}
52
+
53
+
fullRepoName := repoOwner + " / " + repo.Name
54
+
if len(fullRepoName) > 60 {
55
+
fullRepoName = fullRepoName[:60] + "…"
56
+
}
57
+
58
+
grayColor := color.RGBA{88, 96, 105, 255}
59
+
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
60
+
if err != nil {
61
+
return nil, err
62
+
}
63
+
64
+
// Draw pull request title below repo name with wrapping
65
+
titleY := startY + 60
66
+
titleX := startX
67
+
68
+
// Truncate title if too long
69
+
pullTitle := pull.Title
70
+
maxTitleLength := 80
71
+
if len(pullTitle) > maxTitleLength {
72
+
pullTitle = pullTitle[:maxTitleLength] + "…"
73
+
}
74
+
75
+
// Create a temporary card for the title area to enable wrapping
76
+
titleBounds := mainContent.Img.Bounds()
77
+
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
78
+
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID
79
+
80
+
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
81
+
titleCard := &ogcard.Card{
82
+
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
83
+
Font: mainContent.Font,
84
+
Margin: 0,
85
+
}
86
+
87
+
// Draw wrapped title
88
+
lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left)
89
+
if err != nil {
90
+
return nil, err
91
+
}
92
+
93
+
// Calculate where title ends (number of lines * line height)
94
+
lineHeight := 60 // Approximate line height for 54pt font
95
+
titleEndY := titleY + (len(lines) * lineHeight) + 10
96
+
97
+
// Draw pull ID in gray below the title
98
+
pullIdText := fmt.Sprintf("#%d", pull.PullId)
99
+
err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
// Get pull author handle (needed for avatar and metadata)
105
+
var authorHandle string
106
+
author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid)
107
+
if err != nil {
108
+
authorHandle = pull.OwnerDid
109
+
} else {
110
+
authorHandle = "@" + author.Handle.String()
111
+
}
112
+
113
+
// Draw avatar circle on the right side
114
+
avatarBounds := avatarArea.Img.Bounds()
115
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
116
+
if avatarSize > 220 {
117
+
avatarSize = 220
118
+
}
119
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
120
+
avatarY := avatarBounds.Min.Y + 20
121
+
122
+
// Get avatar URL for pull author
123
+
avatarURL := s.pages.AvatarUrl(authorHandle, "256")
124
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
125
+
if err != nil {
126
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
127
+
}
128
+
129
+
// Split stats area: left side for status/stats (80%), right side for dolly (20%)
130
+
statusStatsArea, dollyArea := statsArea.Split(true, 80)
131
+
132
+
// Draw status and stats
133
+
statsBounds := statusStatsArea.Img.Bounds()
134
+
statsX := statsBounds.Min.X + 60 // left padding
135
+
statsY := statsBounds.Min.Y
136
+
137
+
iconColor := color.RGBA{88, 96, 105, 255}
138
+
iconSize := 36
139
+
textSize := 36.0
140
+
labelSize := 28.0
141
+
iconBaselineOffset := int(textSize) / 2
142
+
143
+
// Draw status (open/merged/closed) with colored icon and text
144
+
var statusIcon string
145
+
var statusText string
146
+
var statusColor color.RGBA
147
+
148
+
if pull.State.IsOpen() {
149
+
statusIcon = "static/icons/git-pull-request.svg"
150
+
statusText = "open"
151
+
statusColor = color.RGBA{34, 139, 34, 255} // green
152
+
} else if pull.State.IsMerged() {
153
+
statusIcon = "static/icons/git-merge.svg"
154
+
statusText = "merged"
155
+
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
+
} else {
157
+
statusIcon = "static/icons/git-pull-request-closed.svg"
158
+
statusText = "closed"
159
+
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
+
}
161
+
162
+
statusIconSize := 36
163
+
164
+
// Draw icon with status color
165
+
err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
+
if err != nil {
167
+
log.Printf("failed to draw status icon: %v", err)
168
+
}
169
+
170
+
// Draw text with status color
171
+
textX := statsX + statusIconSize + 12
172
+
statusTextSize := 32.0
173
+
err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
174
+
if err != nil {
175
+
log.Printf("failed to draw status text: %v", err)
176
+
}
177
+
178
+
statusTextWidth := len(statusText) * 20
179
+
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
+
181
+
// Draw comment count
182
+
err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
+
if err != nil {
184
+
log.Printf("failed to draw comment icon: %v", err)
185
+
}
186
+
187
+
currentX += iconSize + 15
188
+
commentText := fmt.Sprintf("%d comments", commentCount)
189
+
if commentCount == 1 {
190
+
commentText = "1 comment"
191
+
}
192
+
err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
193
+
if err != nil {
194
+
log.Printf("failed to draw comment text: %v", err)
195
+
}
196
+
197
+
commentTextWidth := len(commentText) * 20
198
+
currentX += commentTextWidth + 40
199
+
200
+
// Draw files changed
201
+
err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
+
if err != nil {
203
+
log.Printf("failed to draw file diff icon: %v", err)
204
+
}
205
+
206
+
currentX += iconSize + 15
207
+
filesText := fmt.Sprintf("%d files", filesChanged)
208
+
if filesChanged == 1 {
209
+
filesText = "1 file"
210
+
}
211
+
err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
212
+
if err != nil {
213
+
log.Printf("failed to draw files text: %v", err)
214
+
}
215
+
216
+
filesTextWidth := len(filesText) * 20
217
+
currentX += filesTextWidth
218
+
219
+
// Draw additions (green +)
220
+
greenColor := color.RGBA{34, 139, 34, 255}
221
+
additionsText := fmt.Sprintf("+%d", diffStats.Insertions)
222
+
err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
223
+
if err != nil {
224
+
log.Printf("failed to draw additions text: %v", err)
225
+
}
226
+
227
+
additionsTextWidth := len(additionsText) * 20
228
+
currentX += additionsTextWidth + 30
229
+
230
+
// Draw deletions (red -) right next to additions
231
+
redColor := color.RGBA{220, 20, 60, 255}
232
+
deletionsText := fmt.Sprintf("-%d", diffStats.Deletions)
233
+
err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
234
+
if err != nil {
235
+
log.Printf("failed to draw deletions text: %v", err)
236
+
}
237
+
238
+
// Draw dolly logo on the right side
239
+
dollyBounds := dollyArea.Img.Bounds()
240
+
dollySize := 90
241
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
+
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
245
+
if err != nil {
246
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
+
}
248
+
249
+
// Draw "opened by @author" and date at the bottom with more spacing
250
+
labelY := statsY + iconSize + 30
251
+
252
+
// Format the opened date
253
+
openedDate := pull.Created.Format("Jan 2, 2006")
254
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
255
+
256
+
err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
257
+
if err != nil {
258
+
log.Printf("failed to draw metadata: %v", err)
259
+
}
260
+
261
+
return mainCard, nil
262
+
}
263
+
264
+
func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
265
+
f, err := s.repoResolver.Resolve(r)
266
+
if err != nil {
267
+
log.Println("failed to get repo and knot", err)
268
+
return
269
+
}
270
+
271
+
pull, ok := r.Context().Value("pull").(*models.Pull)
272
+
if !ok {
273
+
log.Println("pull not found in context")
274
+
http.Error(w, "pull not found", http.StatusNotFound)
275
+
return
276
+
}
277
+
278
+
// Get comment count from database
279
+
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
+
if err != nil {
281
+
log.Printf("failed to get pull comments: %v", err)
282
+
}
283
+
commentCount := len(comments)
284
+
285
+
// Calculate diff stats from latest submission using patchutil
286
+
var diffStats types.DiffStat
287
+
filesChanged := 0
288
+
if len(pull.Submissions) > 0 {
289
+
latestSubmission := pull.Submissions[len(pull.Submissions)-1]
290
+
niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
291
+
diffStats.Insertions = int64(niceDiff.Stat.Insertions)
292
+
diffStats.Deletions = int64(niceDiff.Stat.Deletions)
293
+
filesChanged = niceDiff.Stat.FilesChanged
294
+
}
295
+
296
+
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297
+
if err != nil {
298
+
log.Println("failed to draw pull summary card", err)
299
+
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
300
+
return
301
+
}
302
+
303
+
var imageBuffer bytes.Buffer
304
+
err = png.Encode(&imageBuffer, card.Img)
305
+
if err != nil {
306
+
log.Println("failed to encode pull summary card", err)
307
+
http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
308
+
return
309
+
}
310
+
311
+
imageBytes := imageBuffer.Bytes()
312
+
313
+
w.Header().Set("Content-Type", "image/png")
314
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
315
+
w.WriteHeader(http.StatusOK)
316
+
_, err = w.Write(imageBytes)
317
+
if err != nil {
318
+
log.Println("failed to write pull summary card", err)
319
+
return
320
+
}
321
+
}
+189
-190
appview/pulls/pulls.go
+189
-190
appview/pulls/pulls.go
···
6
6
"errors"
7
7
"fmt"
8
8
"log"
9
+
"log/slog"
9
10
"net/http"
11
+
"slices"
10
12
"sort"
11
13
"strconv"
12
14
"strings"
···
21
23
"tangled.org/core/appview/pages"
22
24
"tangled.org/core/appview/pages/markup"
23
25
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/validator"
24
27
"tangled.org/core/appview/xrpcclient"
25
28
"tangled.org/core/idresolver"
26
29
"tangled.org/core/patchutil"
30
+
"tangled.org/core/rbac"
27
31
"tangled.org/core/tid"
28
32
"tangled.org/core/types"
29
33
30
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
31
34
comatproto "github.com/bluesky-social/indigo/api/atproto"
32
35
lexutil "github.com/bluesky-social/indigo/lex/util"
33
36
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
···
43
46
db *db.DB
44
47
config *config.Config
45
48
notifier notify.Notifier
49
+
enforcer *rbac.Enforcer
50
+
logger *slog.Logger
51
+
validator *validator.Validator
46
52
}
47
53
48
54
func New(
···
53
59
db *db.DB,
54
60
config *config.Config,
55
61
notifier notify.Notifier,
62
+
enforcer *rbac.Enforcer,
63
+
validator *validator.Validator,
64
+
logger *slog.Logger,
56
65
) *Pulls {
57
66
return &Pulls{
58
67
oauth: oauth,
···
62
71
db: db,
63
72
config: config,
64
73
notifier: notifier,
74
+
enforcer: enforcer,
75
+
logger: logger,
76
+
validator: validator,
65
77
}
66
78
}
67
79
···
98
110
}
99
111
100
112
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
113
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
101
114
resubmitResult := pages.Unknown
102
115
if user.Did == pull.OwnerDid {
103
116
resubmitResult = s.resubmitCheck(r, f, pull, stack)
104
117
}
105
118
106
119
s.pages.PullActionsFragment(w, pages.PullActionsParams{
107
-
LoggedInUser: user,
108
-
RepoInfo: f.RepoInfo(user),
109
-
Pull: pull,
110
-
RoundNumber: roundNumber,
111
-
MergeCheck: mergeCheckResponse,
112
-
ResubmitCheck: resubmitResult,
113
-
Stack: stack,
120
+
LoggedInUser: user,
121
+
RepoInfo: f.RepoInfo(user),
122
+
Pull: pull,
123
+
RoundNumber: roundNumber,
124
+
MergeCheck: mergeCheckResponse,
125
+
ResubmitCheck: resubmitResult,
126
+
BranchDeleteStatus: branchDeleteStatus,
127
+
Stack: stack,
114
128
})
115
129
return
116
130
}
···
135
149
stack, _ := r.Context().Value("stack").(models.Stack)
136
150
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
137
151
138
-
totalIdents := 1
139
-
for _, submission := range pull.Submissions {
140
-
totalIdents += len(submission.Comments)
141
-
}
142
-
143
-
identsToResolve := make([]string, totalIdents)
144
-
145
-
// populate idents
146
-
identsToResolve[0] = pull.OwnerDid
147
-
idx := 1
148
-
for _, submission := range pull.Submissions {
149
-
for _, comment := range submission.Comments {
150
-
identsToResolve[idx] = comment.OwnerDid
151
-
idx += 1
152
-
}
153
-
}
154
-
155
152
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
153
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
156
154
resubmitResult := pages.Unknown
157
155
if user != nil && user.Did == pull.OwnerDid {
158
156
resubmitResult = s.resubmitCheck(r, f, pull, stack)
···
189
187
m[p.Sha] = p
190
188
}
191
189
192
-
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
190
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
193
191
if err != nil {
194
192
log.Println("failed to get pull reactions")
195
193
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
217
215
}
218
216
219
217
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
220
-
LoggedInUser: user,
221
-
RepoInfo: repoInfo,
222
-
Pull: pull,
223
-
Stack: stack,
224
-
AbandonedPulls: abandonedPulls,
225
-
MergeCheck: mergeCheckResponse,
226
-
ResubmitCheck: resubmitResult,
227
-
Pipelines: m,
218
+
LoggedInUser: user,
219
+
RepoInfo: repoInfo,
220
+
Pull: pull,
221
+
Stack: stack,
222
+
AbandonedPulls: abandonedPulls,
223
+
BranchDeleteStatus: branchDeleteStatus,
224
+
MergeCheck: mergeCheckResponse,
225
+
ResubmitCheck: resubmitResult,
226
+
Pipelines: m,
228
227
229
228
OrderedReactionKinds: models.OrderedReactionKinds,
230
-
Reactions: reactionCountMap,
229
+
Reactions: reactionMap,
231
230
UserReacted: userReactions,
232
231
233
232
LabelDefs: defs,
···
301
300
return result
302
301
}
303
302
303
+
func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
304
+
if pull.State != models.PullMerged {
305
+
return nil
306
+
}
307
+
308
+
user := s.oauth.GetUser(r)
309
+
if user == nil {
310
+
return nil
311
+
}
312
+
313
+
var branch string
314
+
var repo *models.Repo
315
+
// check if the branch exists
316
+
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
317
+
if pull.IsBranchBased() {
318
+
branch = pull.PullSource.Branch
319
+
repo = &f.Repo
320
+
} else if pull.IsForkBased() {
321
+
branch = pull.PullSource.Branch
322
+
repo = pull.PullSource.Repo
323
+
} else {
324
+
return nil
325
+
}
326
+
327
+
// deleted fork
328
+
if repo == nil {
329
+
return nil
330
+
}
331
+
332
+
// user can only delete branch if they are a collaborator in the repo that the branch belongs to
333
+
perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo())
334
+
if !slices.Contains(perms, "repo:push") {
335
+
return nil
336
+
}
337
+
338
+
scheme := "http"
339
+
if !s.config.Core.Dev {
340
+
scheme = "https"
341
+
}
342
+
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
343
+
xrpcc := &indigoxrpc.Client{
344
+
Host: host,
345
+
}
346
+
347
+
resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name))
348
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
349
+
return nil
350
+
}
351
+
352
+
return &models.BranchDeleteStatus{
353
+
Repo: repo,
354
+
Branch: resp.Name,
355
+
}
356
+
}
357
+
304
358
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
305
359
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
306
360
return pages.Unknown
···
348
402
349
403
targetBranch := branchResp
350
404
351
-
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
405
+
latestSourceRev := pull.LatestSha()
352
406
353
407
if pull.IsStacked() && stack != nil {
354
408
top := stack[0]
355
-
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
409
+
latestSourceRev = top.LatestSha()
356
410
}
357
411
358
412
if latestSourceRev != targetBranch.Hash {
···
392
446
return
393
447
}
394
448
395
-
patch := pull.Submissions[roundIdInt].Patch
449
+
patch := pull.Submissions[roundIdInt].CombinedPatch()
396
450
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
397
451
398
452
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
···
443
497
return
444
498
}
445
499
446
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
500
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
447
501
if err != nil {
448
502
log.Println("failed to interdiff; current patch malformed")
449
503
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
450
504
return
451
505
}
452
506
453
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
507
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
454
508
if err != nil {
455
509
log.Println("failed to interdiff; previous patch malformed")
456
510
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
···
652
706
653
707
createdAt := time.Now().Format(time.RFC3339)
654
708
655
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
656
-
if err != nil {
657
-
log.Println("failed to get pull at", err)
658
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
659
-
return
660
-
}
661
-
662
709
client, err := s.oauth.AuthorizedClient(r)
663
710
if err != nil {
664
711
log.Println("failed to get authorized client", err)
665
712
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
666
713
return
667
714
}
668
-
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
715
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
669
716
Collection: tangled.RepoPullCommentNSID,
670
717
Repo: user.Did,
671
718
Rkey: tid.TID(),
672
719
Record: &lexutil.LexiconTypeDecoder{
673
720
Val: &tangled.RepoPullComment{
674
-
Pull: string(pullAt),
721
+
Pull: pull.PullAt().String(),
675
722
Body: body,
676
723
CreatedAt: createdAt,
677
724
},
···
919
966
}
920
967
921
968
sourceRev := comparison.Rev2
922
-
patch := comparison.Patch
969
+
patch := comparison.FormatPatchRaw
970
+
combined := comparison.CombinedPatchRaw
923
971
924
-
if !patchutil.IsPatchValid(patch) {
972
+
if err := s.validator.ValidatePatch(&patch); err != nil {
973
+
s.logger.Error("failed to validate patch", "err", err)
925
974
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
926
975
return
927
976
}
···
934
983
Sha: comparison.Rev2,
935
984
}
936
985
937
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
986
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
938
987
}
939
988
940
989
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
941
-
if !patchutil.IsPatchValid(patch) {
990
+
if err := s.validator.ValidatePatch(&patch); err != nil {
991
+
s.logger.Error("patch validation failed", "err", err)
942
992
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
943
993
return
944
994
}
945
995
946
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
996
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
947
997
}
948
998
949
999
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
···
1026
1076
}
1027
1077
1028
1078
sourceRev := comparison.Rev2
1029
-
patch := comparison.Patch
1079
+
patch := comparison.FormatPatchRaw
1080
+
combined := comparison.CombinedPatchRaw
1030
1081
1031
-
if !patchutil.IsPatchValid(patch) {
1082
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1083
+
s.logger.Error("failed to validate patch", "err", err)
1032
1084
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1033
1085
return
1034
1086
}
···
1046
1098
Sha: sourceRev,
1047
1099
}
1048
1100
1049
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1101
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1050
1102
}
1051
1103
1052
1104
func (s *Pulls) createPullRequest(
···
1056
1108
user *oauth.User,
1057
1109
title, body, targetBranch string,
1058
1110
patch string,
1111
+
combined string,
1059
1112
sourceRev string,
1060
1113
pullSource *models.PullSource,
1061
1114
recordPullSource *tangled.RepoPull_Source,
···
1093
1146
1094
1147
// We've already checked earlier if it's diff-based and title is empty,
1095
1148
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1096
-
if title == "" {
1149
+
if title == "" || body == "" {
1097
1150
formatPatches, err := patchutil.ExtractPatches(patch)
1098
1151
if err != nil {
1099
1152
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1104
1157
return
1105
1158
}
1106
1159
1107
-
title = formatPatches[0].Title
1108
-
body = formatPatches[0].Body
1160
+
if title == "" {
1161
+
title = formatPatches[0].Title
1162
+
}
1163
+
if body == "" {
1164
+
body = formatPatches[0].Body
1165
+
}
1109
1166
}
1110
1167
1111
1168
rkey := tid.TID()
1112
1169
initialSubmission := models.PullSubmission{
1113
1170
Patch: patch,
1171
+
Combined: combined,
1114
1172
SourceRev: sourceRev,
1115
1173
}
1116
1174
pull := &models.Pull{
···
1138
1196
return
1139
1197
}
1140
1198
1141
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1199
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1142
1200
Collection: tangled.RepoPullNSID,
1143
1201
Repo: user.Did,
1144
1202
Rkey: rkey,
···
1149
1207
Repo: string(f.RepoAt()),
1150
1208
Branch: targetBranch,
1151
1209
},
1152
-
Patch: patch,
1153
-
Source: recordPullSource,
1210
+
Patch: patch,
1211
+
Source: recordPullSource,
1212
+
CreatedAt: time.Now().Format(time.RFC3339),
1154
1213
},
1155
1214
},
1156
1215
})
···
1235
1294
}
1236
1295
writes = append(writes, &write)
1237
1296
}
1238
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1297
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1239
1298
Repo: user.Did,
1240
1299
Writes: writes,
1241
1300
})
···
1285
1344
return
1286
1345
}
1287
1346
1288
-
if patch == "" || !patchutil.IsPatchValid(patch) {
1347
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1348
+
s.logger.Error("faield to validate patch", "err", err)
1289
1349
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1290
1350
return
1291
1351
}
···
1539
1599
1540
1600
patch := r.FormValue("patch")
1541
1601
1542
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1602
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1543
1603
}
1544
1604
1545
1605
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
···
1600
1660
}
1601
1661
1602
1662
sourceRev := comparison.Rev2
1603
-
patch := comparison.Patch
1663
+
patch := comparison.FormatPatchRaw
1664
+
combined := comparison.CombinedPatchRaw
1604
1665
1605
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1666
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1606
1667
}
1607
1668
1608
1669
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
···
1634
1695
return
1635
1696
}
1636
1697
1637
-
// extract patch by performing compare
1638
-
forkScheme := "http"
1639
-
if !s.config.Core.Dev {
1640
-
forkScheme = "https"
1641
-
}
1642
-
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1643
-
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1644
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1645
-
if err != nil {
1646
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1647
-
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1648
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1649
-
return
1650
-
}
1651
-
log.Printf("failed to compare branches: %s", err)
1652
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1653
-
return
1654
-
}
1655
-
1656
-
var forkComparison types.RepoFormatPatchResponse
1657
-
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1658
-
log.Println("failed to decode XRPC compare response for fork", err)
1659
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1660
-
return
1661
-
}
1662
-
1663
1698
// update the hidden tracking branch to latest
1664
1699
client, err := s.oauth.ServiceClient(
1665
1700
r,
···
1691
1726
return
1692
1727
}
1693
1728
1729
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1730
+
// extract patch by performing compare
1731
+
forkScheme := "http"
1732
+
if !s.config.Core.Dev {
1733
+
forkScheme = "https"
1734
+
}
1735
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1736
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1737
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1738
+
if err != nil {
1739
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1740
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1741
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1742
+
return
1743
+
}
1744
+
log.Printf("failed to compare branches: %s", err)
1745
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1746
+
return
1747
+
}
1748
+
1749
+
var forkComparison types.RepoFormatPatchResponse
1750
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1751
+
log.Println("failed to decode XRPC compare response for fork", err)
1752
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1753
+
return
1754
+
}
1755
+
1694
1756
// Use the fork comparison we already made
1695
1757
comparison := forkComparison
1696
1758
1697
1759
sourceRev := comparison.Rev2
1698
-
patch := comparison.Patch
1760
+
patch := comparison.FormatPatchRaw
1761
+
combined := comparison.CombinedPatchRaw
1699
1762
1700
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1701
-
}
1702
-
1703
-
// validate a resubmission against a pull request
1704
-
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1705
-
if patch == "" {
1706
-
return fmt.Errorf("Patch is empty.")
1707
-
}
1708
-
1709
-
if patch == pull.LatestPatch() {
1710
-
return fmt.Errorf("Patch is identical to previous submission.")
1711
-
}
1712
-
1713
-
if !patchutil.IsPatchValid(patch) {
1714
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1715
-
}
1716
-
1717
-
return nil
1763
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1718
1764
}
1719
1765
1720
1766
func (s *Pulls) resubmitPullHelper(
···
1724
1770
user *oauth.User,
1725
1771
pull *models.Pull,
1726
1772
patch string,
1773
+
combined string,
1727
1774
sourceRev string,
1728
1775
) {
1729
1776
if pull.IsStacked() {
···
1732
1779
return
1733
1780
}
1734
1781
1735
-
if err := validateResubmittedPatch(pull, patch); err != nil {
1782
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1736
1783
s.pages.Notice(w, "resubmit-error", err.Error())
1784
+
return
1785
+
}
1786
+
1787
+
if patch == pull.LatestPatch() {
1788
+
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1737
1789
return
1738
1790
}
1739
1791
1740
1792
// validate sourceRev if branch/fork based
1741
1793
if pull.IsBranchBased() || pull.IsForkBased() {
1742
-
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1794
+
if sourceRev == pull.LatestSha() {
1743
1795
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1744
1796
return
1745
1797
}
···
1753
1805
}
1754
1806
defer tx.Rollback()
1755
1807
1756
-
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1808
+
pullAt := pull.PullAt()
1809
+
newRoundNumber := len(pull.Submissions)
1810
+
newPatch := patch
1811
+
newSourceRev := sourceRev
1812
+
combinedPatch := combined
1813
+
err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1757
1814
if err != nil {
1758
1815
log.Println("failed to create pull request", err)
1759
1816
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1766
1823
return
1767
1824
}
1768
1825
1769
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1826
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1770
1827
if err != nil {
1771
1828
// failed to get record
1772
1829
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
···
1789
1846
}
1790
1847
}
1791
1848
1792
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1849
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1793
1850
Collection: tangled.RepoPullNSID,
1794
1851
Repo: user.Did,
1795
1852
Rkey: pull.Rkey,
···
1801
1858
Repo: string(f.RepoAt()),
1802
1859
Branch: pull.TargetBranch,
1803
1860
},
1804
-
Patch: patch, // new patch
1805
-
Source: recordPullSource,
1861
+
Patch: patch, // new patch
1862
+
Source: recordPullSource,
1863
+
CreatedAt: time.Now().Format(time.RFC3339),
1806
1864
},
1807
1865
},
1808
1866
})
···
1853
1911
// commits that got deleted: corresponding pull is closed
1854
1912
// commits that got added: new pull is created
1855
1913
// commits that got updated: corresponding pull is resubmitted & new round begins
1856
-
//
1857
-
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1858
1914
additions := make(map[string]*models.Pull)
1859
1915
deletions := make(map[string]*models.Pull)
1860
-
unchanged := make(map[string]struct{})
1861
1916
updated := make(map[string]struct{})
1862
1917
1863
1918
// pulls in orignal stack but not in new one
···
1879
1934
for _, np := range newStack {
1880
1935
if op, ok := origById[np.ChangeId]; ok {
1881
1936
// pull exists in both stacks
1882
-
// TODO: can we avoid reparse?
1883
-
origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1884
-
newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1885
-
1886
-
origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1887
-
newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1888
-
1889
-
patchutil.SortPatch(newFiles)
1890
-
patchutil.SortPatch(origFiles)
1891
-
1892
-
// text content of patch may be identical, but a jj rebase might have forwarded it
1893
-
//
1894
-
// we still need to update the hash in submission.Patch and submission.SourceRev
1895
-
if patchutil.Equal(newFiles, origFiles) &&
1896
-
origHeader.Title == newHeader.Title &&
1897
-
origHeader.Body == newHeader.Body {
1898
-
unchanged[op.ChangeId] = struct{}{}
1899
-
} else {
1900
-
updated[op.ChangeId] = struct{}{}
1901
-
}
1937
+
updated[op.ChangeId] = struct{}{}
1902
1938
}
1903
1939
}
1904
1940
···
1965
2001
continue
1966
2002
}
1967
2003
1968
-
submission := np.Submissions[np.LastRoundNumber()]
1969
-
1970
-
// resubmit the old pull
1971
-
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1972
-
1973
-
if err != nil {
1974
-
log.Println("failed to update pull", err, op.PullId)
1975
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1976
-
return
1977
-
}
1978
-
1979
-
record := op.AsRecord()
1980
-
record.Patch = submission.Patch
1981
-
1982
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1983
-
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1984
-
Collection: tangled.RepoPullNSID,
1985
-
Rkey: op.Rkey,
1986
-
Value: &lexutil.LexiconTypeDecoder{
1987
-
Val: &record,
1988
-
},
1989
-
},
1990
-
})
1991
-
}
1992
-
1993
-
// unchanged pulls are edited without starting a new round
1994
-
//
1995
-
// update source-revs & patches without advancing rounds
1996
-
for changeId := range unchanged {
1997
-
op, _ := origById[changeId]
1998
-
np, _ := newById[changeId]
1999
-
2000
-
origSubmission := op.Submissions[op.LastRoundNumber()]
2001
-
newSubmission := np.Submissions[np.LastRoundNumber()]
2002
-
2003
-
log.Println("moving unchanged change id : ", changeId)
2004
-
2005
-
err := db.UpdatePull(
2006
-
tx,
2007
-
newSubmission.Patch,
2008
-
newSubmission.SourceRev,
2009
-
db.FilterEq("id", origSubmission.ID),
2010
-
)
2011
-
2004
+
// resubmit the new pull
2005
+
pullAt := op.PullAt()
2006
+
newRoundNumber := len(op.Submissions)
2007
+
newPatch := np.LatestPatch()
2008
+
combinedPatch := np.LatestSubmission().Combined
2009
+
newSourceRev := np.LatestSha()
2010
+
err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
2012
2011
if err != nil {
2013
2012
log.Println("failed to update pull", err, op.PullId)
2014
2013
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2015
2014
return
2016
2015
}
2017
2016
2018
-
record := op.AsRecord()
2019
-
record.Patch = newSubmission.Patch
2017
+
record := np.AsRecord()
2020
2018
2021
2019
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2022
2020
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
2061
2059
return
2062
2060
}
2063
2061
2064
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
2062
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2065
2063
Repo: user.Did,
2066
2064
Writes: writes,
2067
2065
})
···
2357
2355
initialSubmission := models.PullSubmission{
2358
2356
Patch: fp.Raw,
2359
2357
SourceRev: fp.SHA,
2358
+
Combined: fp.Raw,
2360
2359
}
2361
2360
pull := models.Pull{
2362
2361
Title: title,
+1
appview/pulls/router.go
+1
appview/pulls/router.go
+11
-10
appview/repo/artifact.go
+11
-10
appview/repo/artifact.go
···
10
10
"net/url"
11
11
"time"
12
12
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
-
"github.com/dustin/go-humanize"
17
-
"github.com/go-chi/chi/v5"
18
-
"github.com/go-git/go-git/v5/plumbing"
19
-
"github.com/ipfs/go-cid"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview/db"
22
15
"tangled.org/core/appview/models"
···
25
18
"tangled.org/core/appview/xrpcclient"
26
19
"tangled.org/core/tid"
27
20
"tangled.org/core/types"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
lexutil "github.com/bluesky-social/indigo/lex/util"
24
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
25
+
"github.com/dustin/go-humanize"
26
+
"github.com/go-chi/chi/v5"
27
+
"github.com/go-git/go-git/v5/plumbing"
28
+
"github.com/ipfs/go-cid"
28
29
)
29
30
30
31
// TODO: proper statuses here on early exit
···
60
61
return
61
62
}
62
63
63
-
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
64
+
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
64
65
if err != nil {
65
66
log.Println("failed to upload blob", err)
66
67
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
72
73
rkey := tid.TID()
73
74
createdAt := time.Now()
74
75
75
-
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
76
+
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
76
77
Collection: tangled.RepoArtifactNSID,
77
78
Repo: user.Did,
78
79
Rkey: rkey,
···
249
250
return
250
251
}
251
252
252
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
253
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
253
254
Collection: tangled.RepoArtifactNSID,
254
255
Repo: user.Did,
255
256
Rkey: artifact.Rkey,
+26
-12
appview/repo/index.go
+26
-12
appview/repo/index.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log"
6
+
"log/slog"
7
7
"net/http"
8
8
"net/url"
9
9
"slices"
···
31
31
)
32
32
33
33
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
34
+
l := rp.logger.With("handler", "RepoIndex")
35
+
34
36
ref := chi.URLParam(r, "ref")
35
37
ref, _ = url.PathUnescape(ref)
36
38
37
39
f, err := rp.repoResolver.Resolve(r)
38
40
if err != nil {
39
-
log.Println("failed to fully resolve repo", err)
41
+
l.Error("failed to fully resolve repo", "err", err)
40
42
return
41
43
}
42
44
···
56
58
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
57
59
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
58
60
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
59
-
log.Println("failed to call XRPC repo.index", err)
61
+
l.Error("failed to call XRPC repo.index", "err", err)
60
62
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
61
63
LoggedInUser: user,
62
64
NeedsKnotUpgrade: true,
···
66
68
}
67
69
68
70
rp.pages.Error503(w)
69
-
log.Println("failed to build index response", err)
71
+
l.Error("failed to build index response", "err", err)
70
72
return
71
73
}
72
74
···
119
121
emails := uniqueEmails(commitsTrunc)
120
122
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
121
123
if err != nil {
122
-
log.Println("failed to get email to did map", err)
124
+
l.Error("failed to get email to did map", "err", err)
123
125
}
124
126
125
127
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
126
128
if err != nil {
127
-
log.Println(err)
129
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
128
130
}
129
131
130
132
// TODO: a bit dirty
131
-
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
133
+
languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "")
132
134
if err != nil {
133
-
log.Printf("failed to compute language percentages: %s", err)
135
+
l.Warn("failed to compute language percentages", "err", err)
134
136
// non-fatal
135
137
}
136
138
···
140
142
}
141
143
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
142
144
if err != nil {
143
-
log.Printf("failed to fetch pipeline statuses: %s", err)
145
+
l.Error("failed to fetch pipeline statuses", "err", err)
144
146
// non-fatal
145
147
}
146
148
···
162
164
163
165
func (rp *Repo) getLanguageInfo(
164
166
ctx context.Context,
167
+
l *slog.Logger,
165
168
f *reporesolver.ResolvedRepo,
166
169
xrpcc *indigoxrpc.Client,
167
170
currentRef string,
···
180
183
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
181
184
if err != nil {
182
185
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
183
-
log.Println("failed to call XRPC repo.languages", xrpcerr)
186
+
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
184
187
return nil, xrpcerr
185
188
}
186
189
return nil, err
···
199
202
Bytes: lang.Size,
200
203
})
201
204
}
205
+
206
+
tx, err := rp.db.Begin()
207
+
if err != nil {
208
+
return nil, err
209
+
}
210
+
defer tx.Rollback()
202
211
203
212
// update appview's cache
204
-
err = db.InsertRepoLanguages(rp.db, langs)
213
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
205
214
if err != nil {
206
215
// non-fatal
207
-
log.Println("failed to cache lang results", err)
216
+
l.Error("failed to cache lang results", "err", err)
217
+
}
218
+
219
+
err = tx.Commit()
220
+
if err != nil {
221
+
return nil, err
208
222
}
209
223
}
210
224
+402
appview/repo/opengraph.go
+402
appview/repo/opengraph.go
···
1
+
package repo
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/hex"
7
+
"fmt"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
"sort"
13
+
"strings"
14
+
15
+
"github.com/go-enry/go-enry/v2"
16
+
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/models"
18
+
"tangled.org/core/appview/ogcard"
19
+
"tangled.org/core/types"
20
+
)
21
+
22
+
func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) {
23
+
width, height := ogcard.DefaultSize()
24
+
mainCard, err := ogcard.NewCard(width, height)
25
+
if err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
// Split: content area (75%) and language bar + icons (25%)
30
+
contentCard, bottomArea := mainCard.Split(false, 75)
31
+
32
+
// Add padding to content
33
+
contentCard.SetMargin(50)
34
+
35
+
// Split content horizontally: main content (80%) and avatar area (20%)
36
+
mainContent, avatarArea := contentCard.Split(true, 80)
37
+
38
+
// Use main content area for both repo name and description to allow dynamic wrapping.
39
+
mainContent.SetMargin(10)
40
+
41
+
var ownerHandle string
42
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
43
+
if err != nil {
44
+
ownerHandle = repo.Did
45
+
} else {
46
+
ownerHandle = "@" + owner.Handle.String()
47
+
}
48
+
49
+
bounds := mainContent.Img.Bounds()
50
+
startX := bounds.Min.X + mainContent.Margin
51
+
startY := bounds.Min.Y + mainContent.Margin
52
+
currentX := startX
53
+
currentY := startY
54
+
lineHeight := 64 // Font size 54 + padding
55
+
textColor := color.RGBA{88, 96, 105, 255}
56
+
57
+
// Draw owner handle
58
+
ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left)
59
+
if err != nil {
60
+
return nil, err
61
+
}
62
+
currentX += ownerWidth
63
+
64
+
// Draw separator
65
+
sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left)
66
+
if err != nil {
67
+
return nil, err
68
+
}
69
+
currentX += sepWidth
70
+
71
+
words := strings.Fields(repo.Name)
72
+
spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left)
73
+
if spaceWidth == 0 {
74
+
spaceWidth = 15
75
+
}
76
+
77
+
for _, word := range words {
78
+
// estimate bold width by measuring regular width and adding a multiplier
79
+
regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left)
80
+
estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text
81
+
82
+
if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) {
83
+
currentX = startX
84
+
currentY += lineHeight
85
+
}
86
+
87
+
_, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left)
88
+
if err != nil {
89
+
return nil, err
90
+
}
91
+
currentX += estimatedBoldWidth + spaceWidth
92
+
}
93
+
94
+
// update Y position for the description
95
+
currentY += lineHeight
96
+
97
+
// draw description
98
+
if currentY < bounds.Max.Y-mainContent.Margin {
99
+
totalHeight := float64(bounds.Dy())
100
+
repoNameHeight := float64(currentY - bounds.Min.Y)
101
+
102
+
if totalHeight > 0 && repoNameHeight < totalHeight {
103
+
repoNamePercent := (repoNameHeight / totalHeight) * 100
104
+
if repoNamePercent < 95 { // Ensure there's space left for description
105
+
_, descriptionCard := mainContent.Split(false, int(repoNamePercent))
106
+
descriptionCard.SetMargin(8)
107
+
108
+
description := repo.Description
109
+
if len(description) > 70 {
110
+
description = description[:70] + "…"
111
+
}
112
+
113
+
_, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left)
114
+
if err != nil {
115
+
log.Printf("failed to draw description: %v", err)
116
+
}
117
+
}
118
+
}
119
+
}
120
+
121
+
// Draw avatar circle on the right side
122
+
avatarBounds := avatarArea.Img.Bounds()
123
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
124
+
if avatarSize > 220 {
125
+
avatarSize = 220
126
+
}
127
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
128
+
avatarY := avatarBounds.Min.Y + 20
129
+
130
+
// Get avatar URL and draw it
131
+
avatarURL := rp.pages.AvatarUrl(ownerHandle, "256")
132
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
133
+
if err != nil {
134
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
135
+
}
136
+
137
+
// Split bottom area: icons area (65%) and language bar (35%)
138
+
iconsArea, languageBarCard := bottomArea.Split(false, 75)
139
+
140
+
// Split icons area: left side for stats (80%), right side for dolly (20%)
141
+
statsArea, dollyArea := iconsArea.Split(true, 80)
142
+
143
+
// Draw stats with icons in the stats area
144
+
starsText := repo.RepoStats.StarCount
145
+
issuesText := repo.RepoStats.IssueCount.Open
146
+
pullRequestsText := repo.RepoStats.PullCount.Open
147
+
148
+
iconColor := color.RGBA{88, 96, 105, 255}
149
+
iconSize := 36
150
+
textSize := 36.0
151
+
152
+
// Position stats in the middle of the stats area
153
+
statsBounds := statsArea.Img.Bounds()
154
+
statsX := statsBounds.Min.X + 60 // left padding
155
+
statsY := statsBounds.Min.Y
156
+
currentX = statsX
157
+
labelSize := 22.0
158
+
// Draw star icon, count, and label
159
+
// Align icon baseline with text baseline
160
+
iconBaselineOffset := int(textSize) / 2
161
+
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
+
if err != nil {
163
+
log.Printf("failed to draw star icon: %v", err)
164
+
}
165
+
starIconX := currentX
166
+
currentX += iconSize + 15
167
+
168
+
starText := fmt.Sprintf("%d", starsText)
169
+
err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
170
+
if err != nil {
171
+
log.Printf("failed to draw star text: %v", err)
172
+
}
173
+
starTextWidth := len(starText) * 20
174
+
starGroupWidth := iconSize + 15 + starTextWidth
175
+
176
+
// Draw "stars" label below and centered under the icon+text group
177
+
labelY := statsY + iconSize + 15
178
+
labelX := starIconX + starGroupWidth/2
179
+
err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
180
+
if err != nil {
181
+
log.Printf("failed to draw stars label: %v", err)
182
+
}
183
+
184
+
currentX += starTextWidth + 50
185
+
186
+
// Draw issues icon, count, and label
187
+
issueStartX := currentX
188
+
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
+
if err != nil {
190
+
log.Printf("failed to draw circle-dot icon: %v", err)
191
+
}
192
+
currentX += iconSize + 15
193
+
194
+
issueText := fmt.Sprintf("%d", issuesText)
195
+
err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
196
+
if err != nil {
197
+
log.Printf("failed to draw issue text: %v", err)
198
+
}
199
+
issueTextWidth := len(issueText) * 20
200
+
issueGroupWidth := iconSize + 15 + issueTextWidth
201
+
202
+
// Draw "issues" label below and centered under the icon+text group
203
+
labelX = issueStartX + issueGroupWidth/2
204
+
err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
205
+
if err != nil {
206
+
log.Printf("failed to draw issues label: %v", err)
207
+
}
208
+
209
+
currentX += issueTextWidth + 50
210
+
211
+
// Draw pull request icon, count, and label
212
+
prStartX := currentX
213
+
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
+
if err != nil {
215
+
log.Printf("failed to draw git-pull-request icon: %v", err)
216
+
}
217
+
currentX += iconSize + 15
218
+
219
+
prText := fmt.Sprintf("%d", pullRequestsText)
220
+
err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
221
+
if err != nil {
222
+
log.Printf("failed to draw PR text: %v", err)
223
+
}
224
+
prTextWidth := len(prText) * 20
225
+
prGroupWidth := iconSize + 15 + prTextWidth
226
+
227
+
// Draw "pulls" label below and centered under the icon+text group
228
+
labelX = prStartX + prGroupWidth/2
229
+
err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
230
+
if err != nil {
231
+
log.Printf("failed to draw pulls label: %v", err)
232
+
}
233
+
234
+
dollyBounds := dollyArea.Img.Bounds()
235
+
dollySize := 90
236
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
+
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
240
+
if err != nil {
241
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
+
}
243
+
244
+
// Draw language bar at bottom
245
+
err = drawLanguagesCard(languageBarCard, languageStats)
246
+
if err != nil {
247
+
log.Printf("failed to draw language bar: %v", err)
248
+
return nil, err
249
+
}
250
+
251
+
return mainCard, nil
252
+
}
253
+
254
+
// hexToColor converts a hex color to a go color
255
+
func hexToColor(colorStr string) (*color.RGBA, error) {
256
+
colorStr = strings.TrimLeft(colorStr, "#")
257
+
258
+
b, err := hex.DecodeString(colorStr)
259
+
if err != nil {
260
+
return nil, err
261
+
}
262
+
263
+
if len(b) < 3 {
264
+
return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
265
+
}
266
+
267
+
clr := color.RGBA{b[0], b[1], b[2], 255}
268
+
269
+
return &clr, nil
270
+
}
271
+
272
+
func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error {
273
+
bounds := card.Img.Bounds()
274
+
cardWidth := bounds.Dx()
275
+
276
+
if len(languageStats) == 0 {
277
+
// Draw a light gray bar if no languages detected
278
+
card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255})
279
+
return nil
280
+
}
281
+
282
+
// Limit to top 5 languages for the visual bar
283
+
displayLanguages := languageStats
284
+
if len(displayLanguages) > 5 {
285
+
displayLanguages = displayLanguages[:5]
286
+
}
287
+
288
+
currentX := bounds.Min.X
289
+
290
+
for _, lang := range displayLanguages {
291
+
var langColor *color.RGBA
292
+
var err error
293
+
294
+
if lang.Color != "" {
295
+
langColor, err = hexToColor(lang.Color)
296
+
if err != nil {
297
+
// Fallback to a default color
298
+
langColor = &color.RGBA{149, 157, 165, 255}
299
+
}
300
+
} else {
301
+
// Default color if no color specified
302
+
langColor = &color.RGBA{149, 157, 165, 255}
303
+
}
304
+
305
+
langWidth := float32(cardWidth) * (lang.Percentage / 100)
306
+
card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor)
307
+
currentX += int(langWidth)
308
+
}
309
+
310
+
// Fill remaining space with the last color (if any gap due to rounding)
311
+
if currentX < bounds.Max.X && len(displayLanguages) > 0 {
312
+
lastLang := displayLanguages[len(displayLanguages)-1]
313
+
var lastColor *color.RGBA
314
+
var err error
315
+
316
+
if lastLang.Color != "" {
317
+
lastColor, err = hexToColor(lastLang.Color)
318
+
if err != nil {
319
+
lastColor = &color.RGBA{149, 157, 165, 255}
320
+
}
321
+
} else {
322
+
lastColor = &color.RGBA{149, 157, 165, 255}
323
+
}
324
+
card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor)
325
+
}
326
+
327
+
return nil
328
+
}
329
+
330
+
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
331
+
f, err := rp.repoResolver.Resolve(r)
332
+
if err != nil {
333
+
log.Println("failed to get repo and knot", err)
334
+
return
335
+
}
336
+
337
+
// Get language stats directly from database
338
+
var languageStats []types.RepoLanguageDetails
339
+
langs, err := db.GetRepoLanguages(
340
+
rp.db,
341
+
db.FilterEq("repo_at", f.RepoAt()),
342
+
db.FilterEq("is_default_ref", 1),
343
+
)
344
+
if err != nil {
345
+
log.Printf("failed to get language stats from db: %v", err)
346
+
// non-fatal, continue without language stats
347
+
} else if len(langs) > 0 {
348
+
var total int64
349
+
for _, l := range langs {
350
+
total += l.Bytes
351
+
}
352
+
353
+
for _, l := range langs {
354
+
percentage := float32(l.Bytes) / float32(total) * 100
355
+
color := enry.GetColor(l.Language)
356
+
languageStats = append(languageStats, types.RepoLanguageDetails{
357
+
Name: l.Language,
358
+
Percentage: percentage,
359
+
Color: color,
360
+
})
361
+
}
362
+
363
+
sort.Slice(languageStats, func(i, j int) bool {
364
+
if languageStats[i].Name == enry.OtherLanguage {
365
+
return false
366
+
}
367
+
if languageStats[j].Name == enry.OtherLanguage {
368
+
return true
369
+
}
370
+
if languageStats[i].Percentage != languageStats[j].Percentage {
371
+
return languageStats[i].Percentage > languageStats[j].Percentage
372
+
}
373
+
return languageStats[i].Name < languageStats[j].Name
374
+
})
375
+
}
376
+
377
+
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
378
+
if err != nil {
379
+
log.Println("failed to draw repo summary card", err)
380
+
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
381
+
return
382
+
}
383
+
384
+
var imageBuffer bytes.Buffer
385
+
err = png.Encode(&imageBuffer, card.Img)
386
+
if err != nil {
387
+
log.Println("failed to encode repo summary card", err)
388
+
http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError)
389
+
return
390
+
}
391
+
392
+
imageBytes := imageBuffer.Bytes()
393
+
394
+
w.Header().Set("Content-Type", "image/png")
395
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
396
+
w.WriteHeader(http.StatusOK)
397
+
_, err = w.Write(imageBytes)
398
+
if err != nil {
399
+
log.Println("failed to write repo summary card", err)
400
+
return
401
+
}
402
+
}
+213
-126
appview/repo/repo.go
+213
-126
appview/repo/repo.go
···
7
7
"errors"
8
8
"fmt"
9
9
"io"
10
-
"log"
11
10
"log/slog"
12
11
"net/http"
13
12
"net/url"
···
17
16
"strings"
18
17
"time"
19
18
20
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
-
lexutil "github.com/bluesky-social/indigo/lex/util"
22
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
19
"tangled.org/core/api/tangled"
24
20
"tangled.org/core/appview/commitverify"
25
21
"tangled.org/core/appview/config"
···
40
36
"tangled.org/core/types"
41
37
"tangled.org/core/xrpc/serviceauth"
42
38
39
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
+
"github.com/bluesky-social/indigo/atproto/syntax"
42
+
lexutil "github.com/bluesky-social/indigo/lex/util"
43
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
43
44
securejoin "github.com/cyphar/filepath-securejoin"
44
45
"github.com/go-chi/chi/v5"
45
46
"github.com/go-git/go-git/v5/plumbing"
46
-
47
-
"github.com/bluesky-social/indigo/atproto/syntax"
48
47
)
49
48
50
49
type Repo struct {
···
90
89
}
91
90
92
91
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
92
+
l := rp.logger.With("handler", "DownloadArchive")
93
+
93
94
ref := chi.URLParam(r, "ref")
94
95
ref, _ = url.PathUnescape(ref)
95
96
96
97
f, err := rp.repoResolver.Resolve(r)
97
98
if err != nil {
98
-
log.Println("failed to get repo and knot", err)
99
+
l.Error("failed to get repo and knot", "err", err)
99
100
return
100
101
}
101
102
···
111
112
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
112
113
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
113
114
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
114
-
log.Println("failed to call XRPC repo.archive", xrpcerr)
115
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
115
116
rp.pages.Error503(w)
116
117
return
117
118
}
···
128
129
}
129
130
130
131
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
132
+
l := rp.logger.With("handler", "RepoLog")
133
+
131
134
f, err := rp.repoResolver.Resolve(r)
132
135
if err != nil {
133
-
log.Println("failed to fully resolve repo", err)
136
+
l.Error("failed to fully resolve repo", "err", err)
134
137
return
135
138
}
136
139
···
165
168
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
166
169
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
167
170
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
-
log.Println("failed to call XRPC repo.log", xrpcerr)
171
+
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
169
172
rp.pages.Error503(w)
170
173
return
171
174
}
172
175
173
176
var xrpcResp types.RepoLogResponse
174
177
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
175
-
log.Println("failed to decode XRPC response", err)
178
+
l.Error("failed to decode XRPC response", "err", err)
176
179
rp.pages.Error503(w)
177
180
return
178
181
}
179
182
180
183
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
181
184
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
185
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
183
186
rp.pages.Error503(w)
184
187
return
185
188
}
···
189
192
var tagResp types.RepoTagsResponse
190
193
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
191
194
for _, tag := range tagResp.Tags {
192
-
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
195
+
hash := tag.Hash
196
+
if tag.Tag != nil {
197
+
hash = tag.Tag.Target.String()
198
+
}
199
+
tagMap[hash] = append(tagMap[hash], tag.Name)
193
200
}
194
201
}
195
202
}
196
203
197
204
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
198
205
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
199
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
206
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
200
207
rp.pages.Error503(w)
201
208
return
202
209
}
···
214
221
215
222
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
216
223
if err != nil {
217
-
log.Println("failed to fetch email to did mapping", err)
224
+
l.Error("failed to fetch email to did mapping", "err", err)
218
225
}
219
226
220
227
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
221
228
if err != nil {
222
-
log.Println(err)
229
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
223
230
}
224
231
225
232
repoInfo := f.RepoInfo(user)
···
230
237
}
231
238
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
232
239
if err != nil {
233
-
log.Println(err)
240
+
l.Error("failed to getPipelineStatuses", "err", err)
234
241
// non-fatal
235
242
}
236
243
···
246
253
}
247
254
248
255
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
256
+
l := rp.logger.With("handler", "RepoDescriptionEdit")
257
+
249
258
f, err := rp.repoResolver.Resolve(r)
250
259
if err != nil {
251
-
log.Println("failed to get repo and knot", err)
260
+
l.Error("failed to get repo and knot", "err", err)
252
261
w.WriteHeader(http.StatusBadRequest)
253
262
return
254
263
}
···
260
269
}
261
270
262
271
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
272
+
l := rp.logger.With("handler", "RepoDescription")
273
+
263
274
f, err := rp.repoResolver.Resolve(r)
264
275
if err != nil {
265
-
log.Println("failed to get repo and knot", err)
276
+
l.Error("failed to get repo and knot", "err", err)
266
277
w.WriteHeader(http.StatusBadRequest)
267
278
return
268
279
}
···
270
281
repoAt := f.RepoAt()
271
282
rkey := repoAt.RecordKey().String()
272
283
if rkey == "" {
273
-
log.Println("invalid aturi for repo", err)
284
+
l.Error("invalid aturi for repo", "err", err)
274
285
w.WriteHeader(http.StatusInternalServerError)
275
286
return
276
287
}
···
287
298
newDescription := r.FormValue("description")
288
299
client, err := rp.oauth.AuthorizedClient(r)
289
300
if err != nil {
290
-
log.Println("failed to get client")
301
+
l.Error("failed to get client")
291
302
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
292
303
return
293
304
}
···
295
306
// optimistic update
296
307
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
297
308
if err != nil {
298
-
log.Println("failed to perferom update-description query", err)
309
+
l.Error("failed to perform update-description query", "err", err)
299
310
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
300
311
return
301
312
}
···
307
318
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308
319
//
309
320
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
321
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
311
322
if err != nil {
312
323
// failed to get record
313
324
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
314
325
return
315
326
}
316
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
327
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
317
328
Collection: tangled.RepoNSID,
318
329
Repo: newRepo.Did,
319
330
Rkey: newRepo.Rkey,
···
324
335
})
325
336
326
337
if err != nil {
327
-
log.Println("failed to perferom update-description query", err)
338
+
l.Error("failed to perferom update-description query", "err", err)
328
339
// failed to get record
329
340
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
330
341
return
···
341
352
}
342
353
343
354
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
355
+
l := rp.logger.With("handler", "RepoCommit")
356
+
344
357
f, err := rp.repoResolver.Resolve(r)
345
358
if err != nil {
346
-
log.Println("failed to fully resolve repo", err)
359
+
l.Error("failed to fully resolve repo", "err", err)
347
360
return
348
361
}
349
362
ref := chi.URLParam(r, "ref")
···
371
384
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
372
385
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
373
386
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
374
-
log.Println("failed to call XRPC repo.diff", xrpcerr)
387
+
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
375
388
rp.pages.Error503(w)
376
389
return
377
390
}
378
391
379
392
var result types.RepoCommitResponse
380
393
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
381
-
log.Println("failed to decode XRPC response", err)
394
+
l.Error("failed to decode XRPC response", "err", err)
382
395
rp.pages.Error503(w)
383
396
return
384
397
}
385
398
386
399
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
387
400
if err != nil {
388
-
log.Println("failed to get email to did mapping:", err)
401
+
l.Error("failed to get email to did mapping", "err", err)
389
402
}
390
403
391
404
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
392
405
if err != nil {
393
-
log.Println(err)
406
+
l.Error("failed to GetVerifiedCommits", "err", err)
394
407
}
395
408
396
409
user := rp.oauth.GetUser(r)
397
410
repoInfo := f.RepoInfo(user)
398
411
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
399
412
if err != nil {
400
-
log.Println(err)
413
+
l.Error("failed to getPipelineStatuses", "err", err)
401
414
// non-fatal
402
415
}
403
416
var pipeline *models.Pipeline
···
417
430
}
418
431
419
432
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
433
+
l := rp.logger.With("handler", "RepoTree")
434
+
420
435
f, err := rp.repoResolver.Resolve(r)
421
436
if err != nil {
422
-
log.Println("failed to fully resolve repo", err)
437
+
l.Error("failed to fully resolve repo", "err", err)
423
438
return
424
439
}
425
440
···
444
459
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
445
460
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
446
461
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
447
-
log.Println("failed to call XRPC repo.tree", xrpcerr)
462
+
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
448
463
rp.pages.Error503(w)
449
464
return
450
465
}
···
519
534
}
520
535
521
536
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
537
+
l := rp.logger.With("handler", "RepoTags")
538
+
522
539
f, err := rp.repoResolver.Resolve(r)
523
540
if err != nil {
524
-
log.Println("failed to get repo and knot", err)
541
+
l.Error("failed to get repo and knot", "err", err)
525
542
return
526
543
}
527
544
···
537
554
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
538
555
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
539
556
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
540
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
557
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
541
558
rp.pages.Error503(w)
542
559
return
543
560
}
544
561
545
562
var result types.RepoTagsResponse
546
563
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
547
-
log.Println("failed to decode XRPC response", err)
564
+
l.Error("failed to decode XRPC response", "err", err)
548
565
rp.pages.Error503(w)
549
566
return
550
567
}
551
568
552
569
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
553
570
if err != nil {
554
-
log.Println("failed grab artifacts", err)
571
+
l.Error("failed grab artifacts", "err", err)
555
572
return
556
573
}
557
574
···
588
605
}
589
606
590
607
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
608
+
l := rp.logger.With("handler", "RepoBranches")
609
+
591
610
f, err := rp.repoResolver.Resolve(r)
592
611
if err != nil {
593
-
log.Println("failed to get repo and knot", err)
612
+
l.Error("failed to get repo and knot", "err", err)
594
613
return
595
614
}
596
615
···
606
625
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
607
626
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
608
627
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
609
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
628
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
610
629
rp.pages.Error503(w)
611
630
return
612
631
}
613
632
614
633
var result types.RepoBranchesResponse
615
634
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
616
-
log.Println("failed to decode XRPC response", err)
635
+
l.Error("failed to decode XRPC response", "err", err)
617
636
rp.pages.Error503(w)
618
637
return
619
638
}
···
628
647
})
629
648
}
630
649
650
+
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
651
+
l := rp.logger.With("handler", "DeleteBranch")
652
+
653
+
f, err := rp.repoResolver.Resolve(r)
654
+
if err != nil {
655
+
l.Error("failed to get repo and knot", "err", err)
656
+
return
657
+
}
658
+
659
+
noticeId := "delete-branch-error"
660
+
fail := func(msg string, err error) {
661
+
l.Error(msg, "err", err)
662
+
rp.pages.Notice(w, noticeId, msg)
663
+
}
664
+
665
+
branch := r.FormValue("branch")
666
+
if branch == "" {
667
+
fail("No branch provided.", nil)
668
+
return
669
+
}
670
+
671
+
client, err := rp.oauth.ServiceClient(
672
+
r,
673
+
oauth.WithService(f.Knot),
674
+
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
675
+
oauth.WithDev(rp.config.Core.Dev),
676
+
)
677
+
if err != nil {
678
+
fail("Failed to connect to knotserver", nil)
679
+
return
680
+
}
681
+
682
+
err = tangled.RepoDeleteBranch(
683
+
r.Context(),
684
+
client,
685
+
&tangled.RepoDeleteBranch_Input{
686
+
Branch: branch,
687
+
Repo: f.RepoAt().String(),
688
+
},
689
+
)
690
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
691
+
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
692
+
return
693
+
}
694
+
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
695
+
696
+
rp.pages.HxRefresh(w)
697
+
}
698
+
631
699
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
700
+
l := rp.logger.With("handler", "RepoBlob")
701
+
632
702
f, err := rp.repoResolver.Resolve(r)
633
703
if err != nil {
634
-
log.Println("failed to get repo and knot", err)
704
+
l.Error("failed to get repo and knot", "err", err)
635
705
return
636
706
}
637
707
···
653
723
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
654
724
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
655
725
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
656
-
log.Println("failed to call XRPC repo.blob", xrpcerr)
726
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
657
727
rp.pages.Error503(w)
658
728
return
659
729
}
···
753
823
}
754
824
755
825
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
826
+
l := rp.logger.With("handler", "RepoBlobRaw")
827
+
756
828
f, err := rp.repoResolver.Resolve(r)
757
829
if err != nil {
758
-
log.Println("failed to get repo and knot", err)
830
+
l.Error("failed to get repo and knot", "err", err)
759
831
w.WriteHeader(http.StatusBadRequest)
760
832
return
761
833
}
···
787
859
788
860
req, err := http.NewRequest("GET", blobURL, nil)
789
861
if err != nil {
790
-
log.Println("failed to create request", err)
862
+
l.Error("failed to create request", "err", err)
791
863
return
792
864
}
793
865
···
799
871
client := &http.Client{}
800
872
resp, err := client.Do(req)
801
873
if err != nil {
802
-
log.Println("failed to reach knotserver", err)
874
+
l.Error("failed to reach knotserver", "err", err)
803
875
rp.pages.Error503(w)
804
876
return
805
877
}
···
812
884
}
813
885
814
886
if resp.StatusCode != http.StatusOK {
815
-
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
887
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
816
888
w.WriteHeader(resp.StatusCode)
817
889
_, _ = io.Copy(w, resp.Body)
818
890
return
···
821
893
contentType := resp.Header.Get("Content-Type")
822
894
body, err := io.ReadAll(resp.Body)
823
895
if err != nil {
824
-
log.Printf("error reading response body from knotserver: %v", err)
896
+
l.Error("error reading response body from knotserver", "err", err)
825
897
w.WriteHeader(http.StatusInternalServerError)
826
898
return
827
899
}
···
863
935
user := rp.oauth.GetUser(r)
864
936
l := rp.logger.With("handler", "EditSpindle")
865
937
l = l.With("did", user.Did)
866
-
l = l.With("handle", user.Handle)
867
938
868
939
errorId := "operation-error"
869
940
fail := func(msg string, err error) {
···
916
987
return
917
988
}
918
989
919
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
990
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
920
991
if err != nil {
921
992
fail("Failed to update spindle, no record found on PDS.", err)
922
993
return
923
994
}
924
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
995
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
925
996
Collection: tangled.RepoNSID,
926
997
Repo: newRepo.Did,
927
998
Rkey: newRepo.Rkey,
···
951
1022
user := rp.oauth.GetUser(r)
952
1023
l := rp.logger.With("handler", "AddLabel")
953
1024
l = l.With("did", user.Did)
954
-
l = l.With("handle", user.Handle)
955
1025
956
1026
f, err := rp.repoResolver.Resolve(r)
957
1027
if err != nil {
···
1020
1090
1021
1091
// emit a labelRecord
1022
1092
labelRecord := label.AsRecord()
1023
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1093
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1024
1094
Collection: tangled.LabelDefinitionNSID,
1025
1095
Repo: label.Did,
1026
1096
Rkey: label.Rkey,
···
1043
1113
newRepo.Labels = append(newRepo.Labels, aturi)
1044
1114
repoRecord := newRepo.AsRecord()
1045
1115
1046
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1116
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1047
1117
if err != nil {
1048
1118
fail("Failed to update labels, no record found on PDS.", err)
1049
1119
return
1050
1120
}
1051
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1121
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1052
1122
Collection: tangled.RepoNSID,
1053
1123
Repo: newRepo.Did,
1054
1124
Rkey: newRepo.Rkey,
···
1111
1181
user := rp.oauth.GetUser(r)
1112
1182
l := rp.logger.With("handler", "DeleteLabel")
1113
1183
l = l.With("did", user.Did)
1114
-
l = l.With("handle", user.Handle)
1115
1184
1116
1185
f, err := rp.repoResolver.Resolve(r)
1117
1186
if err != nil {
···
1141
1210
}
1142
1211
1143
1212
// delete label record from PDS
1144
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1213
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1145
1214
Collection: tangled.LabelDefinitionNSID,
1146
1215
Repo: label.Did,
1147
1216
Rkey: label.Rkey,
···
1163
1232
newRepo.Labels = updated
1164
1233
repoRecord := newRepo.AsRecord()
1165
1234
1166
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1235
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1167
1236
if err != nil {
1168
1237
fail("Failed to update labels, no record found on PDS.", err)
1169
1238
return
1170
1239
}
1171
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1240
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1172
1241
Collection: tangled.RepoNSID,
1173
1242
Repo: newRepo.Did,
1174
1243
Rkey: newRepo.Rkey,
···
1220
1289
user := rp.oauth.GetUser(r)
1221
1290
l := rp.logger.With("handler", "SubscribeLabel")
1222
1291
l = l.With("did", user.Did)
1223
-
l = l.With("handle", user.Handle)
1224
1292
1225
1293
f, err := rp.repoResolver.Resolve(r)
1226
1294
if err != nil {
···
1261
1329
return
1262
1330
}
1263
1331
1264
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1332
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1265
1333
if err != nil {
1266
1334
fail("Failed to update labels, no record found on PDS.", err)
1267
1335
return
1268
1336
}
1269
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1337
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1270
1338
Collection: tangled.RepoNSID,
1271
1339
Repo: newRepo.Did,
1272
1340
Rkey: newRepo.Rkey,
···
1307
1375
user := rp.oauth.GetUser(r)
1308
1376
l := rp.logger.With("handler", "UnsubscribeLabel")
1309
1377
l = l.With("did", user.Did)
1310
-
l = l.With("handle", user.Handle)
1311
1378
1312
1379
f, err := rp.repoResolver.Resolve(r)
1313
1380
if err != nil {
···
1350
1417
return
1351
1418
}
1352
1419
1353
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1420
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1354
1421
if err != nil {
1355
1422
fail("Failed to update labels, no record found on PDS.", err)
1356
1423
return
1357
1424
}
1358
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1425
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1359
1426
Collection: tangled.RepoNSID,
1360
1427
Repo: newRepo.Did,
1361
1428
Rkey: newRepo.Rkey,
···
1401
1468
db.FilterContains("scope", subject.Collection().String()),
1402
1469
)
1403
1470
if err != nil {
1404
-
log.Println("failed to fetch label defs", err)
1471
+
l.Error("failed to fetch label defs", "err", err)
1405
1472
return
1406
1473
}
1407
1474
···
1412
1479
1413
1480
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1414
1481
if err != nil {
1415
-
log.Println("failed to build label state", err)
1482
+
l.Error("failed to build label state", "err", err)
1416
1483
return
1417
1484
}
1418
1485
state := states[subject]
···
1449
1516
db.FilterContains("scope", subject.Collection().String()),
1450
1517
)
1451
1518
if err != nil {
1452
-
log.Println("failed to fetch labels", err)
1519
+
l.Error("failed to fetch labels", "err", err)
1453
1520
return
1454
1521
}
1455
1522
···
1460
1527
1461
1528
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1462
1529
if err != nil {
1463
-
log.Println("failed to build label state", err)
1530
+
l.Error("failed to build label state", "err", err)
1464
1531
return
1465
1532
}
1466
1533
state := states[subject]
···
1479
1546
user := rp.oauth.GetUser(r)
1480
1547
l := rp.logger.With("handler", "AddCollaborator")
1481
1548
l = l.With("did", user.Did)
1482
-
l = l.With("handle", user.Handle)
1483
1549
1484
1550
f, err := rp.repoResolver.Resolve(r)
1485
1551
if err != nil {
···
1526
1592
currentUser := rp.oauth.GetUser(r)
1527
1593
rkey := tid.TID()
1528
1594
createdAt := time.Now()
1529
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1595
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1530
1596
Collection: tangled.RepoCollaboratorNSID,
1531
1597
Repo: currentUser.Did,
1532
1598
Rkey: rkey,
···
1608
1674
1609
1675
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1610
1676
user := rp.oauth.GetUser(r)
1677
+
l := rp.logger.With("handler", "DeleteRepo")
1611
1678
1612
1679
noticeId := "operation-error"
1613
1680
f, err := rp.repoResolver.Resolve(r)
1614
1681
if err != nil {
1615
-
log.Println("failed to get repo and knot", err)
1682
+
l.Error("failed to get repo and knot", "err", err)
1616
1683
return
1617
1684
}
1618
1685
1619
1686
// remove record from pds
1620
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1687
+
atpClient, err := rp.oauth.AuthorizedClient(r)
1621
1688
if err != nil {
1622
-
log.Println("failed to get authorized client", err)
1689
+
l.Error("failed to get authorized client", "err", err)
1623
1690
return
1624
1691
}
1625
-
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1692
+
_, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1626
1693
Collection: tangled.RepoNSID,
1627
1694
Repo: user.Did,
1628
1695
Rkey: f.Rkey,
1629
1696
})
1630
1697
if err != nil {
1631
-
log.Printf("failed to delete record: %s", err)
1698
+
l.Error("failed to delete record", "err", err)
1632
1699
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1633
1700
return
1634
1701
}
1635
-
log.Println("removed repo record ", f.RepoAt().String())
1702
+
l.Info("removed repo record", "aturi", f.RepoAt().String())
1636
1703
1637
1704
client, err := rp.oauth.ServiceClient(
1638
1705
r,
···
1641
1708
oauth.WithDev(rp.config.Core.Dev),
1642
1709
)
1643
1710
if err != nil {
1644
-
log.Println("failed to connect to knot server:", err)
1711
+
l.Error("failed to connect to knot server", "err", err)
1645
1712
return
1646
1713
}
1647
1714
···
1658
1725
rp.pages.Notice(w, noticeId, err.Error())
1659
1726
return
1660
1727
}
1661
-
log.Println("deleted repo from knot")
1728
+
l.Info("deleted repo from knot")
1662
1729
1663
1730
tx, err := rp.db.BeginTx(r.Context(), nil)
1664
1731
if err != nil {
1665
-
log.Println("failed to start tx")
1732
+
l.Error("failed to start tx")
1666
1733
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1667
1734
return
1668
1735
}
···
1670
1737
tx.Rollback()
1671
1738
err = rp.enforcer.E.LoadPolicy()
1672
1739
if err != nil {
1673
-
log.Println("failed to rollback policies")
1740
+
l.Error("failed to rollback policies")
1674
1741
}
1675
1742
}()
1676
1743
···
1684
1751
did := c[0]
1685
1752
rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1686
1753
}
1687
-
log.Println("removed collaborators")
1754
+
l.Info("removed collaborators")
1688
1755
1689
1756
// remove repo RBAC
1690
1757
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
···
1699
1766
rp.pages.Notice(w, noticeId, "Failed to update appview")
1700
1767
return
1701
1768
}
1702
-
log.Println("removed repo from db")
1769
+
l.Info("removed repo from db")
1703
1770
1704
1771
err = tx.Commit()
1705
1772
if err != nil {
1706
-
log.Println("failed to commit changes", err)
1773
+
l.Error("failed to commit changes", "err", err)
1707
1774
http.Error(w, err.Error(), http.StatusInternalServerError)
1708
1775
return
1709
1776
}
1710
1777
1711
1778
err = rp.enforcer.E.SavePolicy()
1712
1779
if err != nil {
1713
-
log.Println("failed to update ACLs", err)
1780
+
l.Error("failed to update ACLs", "err", err)
1714
1781
http.Error(w, err.Error(), http.StatusInternalServerError)
1715
1782
return
1716
1783
}
···
1719
1786
}
1720
1787
1721
1788
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1789
+
l := rp.logger.With("handler", "SetDefaultBranch")
1790
+
1722
1791
f, err := rp.repoResolver.Resolve(r)
1723
1792
if err != nil {
1724
-
log.Println("failed to get repo and knot", err)
1793
+
l.Error("failed to get repo and knot", "err", err)
1725
1794
return
1726
1795
}
1727
1796
···
1739
1808
oauth.WithDev(rp.config.Core.Dev),
1740
1809
)
1741
1810
if err != nil {
1742
-
log.Println("failed to connect to knot server:", err)
1811
+
l.Error("failed to connect to knot server", "err", err)
1743
1812
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1744
1813
return
1745
1814
}
···
1753
1822
},
1754
1823
)
1755
1824
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1756
-
log.Println("xrpc failed", "err", xe)
1825
+
l.Error("xrpc failed", "err", xe)
1757
1826
rp.pages.Notice(w, noticeId, err.Error())
1758
1827
return
1759
1828
}
···
1764
1833
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1765
1834
user := rp.oauth.GetUser(r)
1766
1835
l := rp.logger.With("handler", "Secrets")
1767
-
l = l.With("handle", user.Handle)
1768
1836
l = l.With("did", user.Did)
1769
1837
1770
1838
f, err := rp.repoResolver.Resolve(r)
1771
1839
if err != nil {
1772
-
log.Println("failed to get repo and knot", err)
1840
+
l.Error("failed to get repo and knot", "err", err)
1773
1841
return
1774
1842
}
1775
1843
1776
1844
if f.Spindle == "" {
1777
-
log.Println("empty spindle cannot add/rm secret", err)
1845
+
l.Error("empty spindle cannot add/rm secret", "err", err)
1778
1846
return
1779
1847
}
1780
1848
···
1791
1859
oauth.WithDev(rp.config.Core.Dev),
1792
1860
)
1793
1861
if err != nil {
1794
-
log.Println("failed to create spindle client", err)
1862
+
l.Error("failed to create spindle client", "err", err)
1795
1863
return
1796
1864
}
1797
1865
···
1877
1945
}
1878
1946
1879
1947
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1948
+
l := rp.logger.With("handler", "generalSettings")
1949
+
1880
1950
f, err := rp.repoResolver.Resolve(r)
1881
1951
user := rp.oauth.GetUser(r)
1882
1952
···
1892
1962
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1893
1963
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1894
1964
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1895
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
1965
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1896
1966
rp.pages.Error503(w)
1897
1967
return
1898
1968
}
1899
1969
1900
1970
var result types.RepoBranchesResponse
1901
1971
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1902
-
log.Println("failed to decode XRPC response", err)
1972
+
l.Error("failed to decode XRPC response", "err", err)
1903
1973
rp.pages.Error503(w)
1904
1974
return
1905
1975
}
1906
1976
1907
1977
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1908
1978
if err != nil {
1909
-
log.Println("failed to fetch labels", err)
1979
+
l.Error("failed to fetch labels", "err", err)
1910
1980
rp.pages.Error503(w)
1911
1981
return
1912
1982
}
1913
1983
1914
1984
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1915
1985
if err != nil {
1916
-
log.Println("failed to fetch labels", err)
1986
+
l.Error("failed to fetch labels", "err", err)
1917
1987
rp.pages.Error503(w)
1918
1988
return
1919
1989
}
···
1961
2031
}
1962
2032
1963
2033
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2034
+
l := rp.logger.With("handler", "accessSettings")
2035
+
1964
2036
f, err := rp.repoResolver.Resolve(r)
1965
2037
user := rp.oauth.GetUser(r)
1966
2038
1967
2039
repoCollaborators, err := f.Collaborators(r.Context())
1968
2040
if err != nil {
1969
-
log.Println("failed to get collaborators", err)
2041
+
l.Error("failed to get collaborators", "err", err)
1970
2042
}
1971
2043
1972
2044
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
···
1979
2051
}
1980
2052
1981
2053
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2054
+
l := rp.logger.With("handler", "pipelineSettings")
2055
+
1982
2056
f, err := rp.repoResolver.Resolve(r)
1983
2057
user := rp.oauth.GetUser(r)
1984
2058
1985
2059
// all spindles that the repo owner is a member of
1986
2060
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1987
2061
if err != nil {
1988
-
log.Println("failed to fetch spindles", err)
2062
+
l.Error("failed to fetch spindles", "err", err)
1989
2063
return
1990
2064
}
1991
2065
···
1998
2072
oauth.WithExp(60),
1999
2073
oauth.WithDev(rp.config.Core.Dev),
2000
2074
); err != nil {
2001
-
log.Println("failed to create spindle client", err)
2075
+
l.Error("failed to create spindle client", "err", err)
2002
2076
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2003
-
log.Println("failed to fetch secrets", err)
2077
+
l.Error("failed to fetch secrets", "err", err)
2004
2078
} else {
2005
2079
secrets = resp.Secrets
2006
2080
}
···
2040
2114
}
2041
2115
2042
2116
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2117
+
l := rp.logger.With("handler", "SyncRepoFork")
2118
+
2043
2119
ref := chi.URLParam(r, "ref")
2044
2120
ref, _ = url.PathUnescape(ref)
2045
2121
2046
2122
user := rp.oauth.GetUser(r)
2047
2123
f, err := rp.repoResolver.Resolve(r)
2048
2124
if err != nil {
2049
-
log.Printf("failed to resolve source repo: %v", err)
2125
+
l.Error("failed to resolve source repo", "err", err)
2050
2126
return
2051
2127
}
2052
2128
···
2090
2166
}
2091
2167
2092
2168
func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
2169
+
l := rp.logger.With("handler", "ForkRepo")
2170
+
2093
2171
user := rp.oauth.GetUser(r)
2094
2172
f, err := rp.repoResolver.Resolve(r)
2095
2173
if err != nil {
2096
-
log.Printf("failed to resolve source repo: %v", err)
2174
+
l.Error("failed to resolve source repo", "err", err)
2097
2175
return
2098
2176
}
2099
2177
···
2144
2222
)
2145
2223
if err != nil {
2146
2224
if !errors.Is(err, sql.ErrNoRows) {
2147
-
log.Println("error fetching existing repo from db", "err", err)
2225
+
l.Error("error fetching existing repo from db", "err", err)
2148
2226
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2149
2227
return
2150
2228
}
···
2179
2257
}
2180
2258
record := repo.AsRecord()
2181
2259
2182
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
2260
+
atpClient, err := rp.oauth.AuthorizedClient(r)
2183
2261
if err != nil {
2184
2262
l.Error("failed to create xrpcclient", "err", err)
2185
2263
rp.pages.Notice(w, "repo", "Failed to fork repository.")
2186
2264
return
2187
2265
}
2188
2266
2189
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
2267
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2190
2268
Collection: tangled.RepoNSID,
2191
2269
Repo: user.Did,
2192
2270
Rkey: rkey,
···
2218
2296
rollback := func() {
2219
2297
err1 := tx.Rollback()
2220
2298
err2 := rp.enforcer.E.LoadPolicy()
2221
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
2299
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
2222
2300
2223
2301
// ignore txn complete errors, this is okay
2224
2302
if errors.Is(err1, sql.ErrTxDone) {
···
2259
2337
2260
2338
err = db.AddRepo(tx, repo)
2261
2339
if err != nil {
2262
-
log.Println(err)
2340
+
l.Error("failed to AddRepo", "err", err)
2263
2341
rp.pages.Notice(w, "repo", "Failed to save repository information.")
2264
2342
return
2265
2343
}
···
2268
2346
p, _ := securejoin.SecureJoin(user.Did, forkName)
2269
2347
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
2270
2348
if err != nil {
2271
-
log.Println(err)
2349
+
l.Error("failed to add ACLs", "err", err)
2272
2350
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2273
2351
return
2274
2352
}
2275
2353
2276
2354
err = tx.Commit()
2277
2355
if err != nil {
2278
-
log.Println("failed to commit changes", err)
2356
+
l.Error("failed to commit changes", "err", err)
2279
2357
http.Error(w, err.Error(), http.StatusInternalServerError)
2280
2358
return
2281
2359
}
2282
2360
2283
2361
err = rp.enforcer.E.SavePolicy()
2284
2362
if err != nil {
2285
-
log.Println("failed to update ACLs", err)
2363
+
l.Error("failed to update ACLs", "err", err)
2286
2364
http.Error(w, err.Error(), http.StatusInternalServerError)
2287
2365
return
2288
2366
}
···
2291
2369
aturi = ""
2292
2370
2293
2371
rp.notifier.NewRepo(r.Context(), repo)
2294
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
2372
+
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
2295
2373
}
2296
2374
}
2297
2375
2298
2376
// this is used to rollback changes made to the PDS
2299
2377
//
2300
2378
// it is a no-op if the provided ATURI is empty
2301
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
2379
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2302
2380
if aturi == "" {
2303
2381
return nil
2304
2382
}
···
2309
2387
repo := parsed.Authority().String()
2310
2388
rkey := parsed.RecordKey().String()
2311
2389
2312
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
2390
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2313
2391
Collection: collection,
2314
2392
Repo: repo,
2315
2393
Rkey: rkey,
···
2318
2396
}
2319
2397
2320
2398
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2399
+
l := rp.logger.With("handler", "RepoCompareNew")
2400
+
2321
2401
user := rp.oauth.GetUser(r)
2322
2402
f, err := rp.repoResolver.Resolve(r)
2323
2403
if err != nil {
2324
-
log.Println("failed to get repo and knot", err)
2404
+
l.Error("failed to get repo and knot", "err", err)
2325
2405
return
2326
2406
}
2327
2407
···
2337
2417
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2338
2418
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2339
2419
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2340
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
2420
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2341
2421
rp.pages.Error503(w)
2342
2422
return
2343
2423
}
2344
2424
2345
2425
var branchResult types.RepoBranchesResponse
2346
2426
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2347
-
log.Println("failed to decode XRPC branches response", err)
2427
+
l.Error("failed to decode XRPC branches response", "err", err)
2348
2428
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2349
2429
return
2350
2430
}
···
2374
2454
2375
2455
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2376
2456
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2377
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
2457
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2378
2458
rp.pages.Error503(w)
2379
2459
return
2380
2460
}
2381
2461
2382
2462
var tags types.RepoTagsResponse
2383
2463
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2384
-
log.Println("failed to decode XRPC tags response", err)
2464
+
l.Error("failed to decode XRPC tags response", "err", err)
2385
2465
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2386
2466
return
2387
2467
}
···
2399
2479
}
2400
2480
2401
2481
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2482
+
l := rp.logger.With("handler", "RepoCompare")
2483
+
2402
2484
user := rp.oauth.GetUser(r)
2403
2485
f, err := rp.repoResolver.Resolve(r)
2404
2486
if err != nil {
2405
-
log.Println("failed to get repo and knot", err)
2487
+
l.Error("failed to get repo and knot", "err", err)
2406
2488
return
2407
2489
}
2408
2490
···
2429
2511
head, _ = url.PathUnescape(head)
2430
2512
2431
2513
if base == "" || head == "" {
2432
-
log.Printf("invalid comparison")
2514
+
l.Error("invalid comparison")
2433
2515
rp.pages.Error404(w)
2434
2516
return
2435
2517
}
···
2447
2529
2448
2530
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2449
2531
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2450
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
2532
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2451
2533
rp.pages.Error503(w)
2452
2534
return
2453
2535
}
2454
2536
2455
2537
var branches types.RepoBranchesResponse
2456
2538
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2457
-
log.Println("failed to decode XRPC branches response", err)
2539
+
l.Error("failed to decode XRPC branches response", "err", err)
2458
2540
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2459
2541
return
2460
2542
}
2461
2543
2462
2544
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2463
2545
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2464
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
2546
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2465
2547
rp.pages.Error503(w)
2466
2548
return
2467
2549
}
2468
2550
2469
2551
var tags types.RepoTagsResponse
2470
2552
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2471
-
log.Println("failed to decode XRPC tags response", err)
2553
+
l.Error("failed to decode XRPC tags response", "err", err)
2472
2554
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2473
2555
return
2474
2556
}
2475
2557
2476
2558
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2477
2559
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2478
-
log.Println("failed to call XRPC repo.compare", xrpcerr)
2560
+
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2479
2561
rp.pages.Error503(w)
2480
2562
return
2481
2563
}
2482
2564
2483
2565
var formatPatch types.RepoFormatPatchResponse
2484
2566
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2485
-
log.Println("failed to decode XRPC compare response", err)
2567
+
l.Error("failed to decode XRPC compare response", "err", err)
2486
2568
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2487
2569
return
2488
2570
}
2489
2571
2490
-
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2572
+
var diff types.NiceDiff
2573
+
if formatPatch.CombinedPatchRaw != "" {
2574
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2575
+
} else {
2576
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2577
+
}
2491
2578
2492
2579
repoinfo := f.RepoInfo(user)
2493
2580
+2
appview/repo/router.go
+2
appview/repo/router.go
···
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
12
r.Get("/", rp.RepoIndex)
13
+
r.Get("/opengraph", rp.RepoOpenGraphSummary)
13
14
r.Get("/feed.atom", rp.RepoAtomFeed)
14
15
r.Get("/commits/{ref}", rp.RepoLog)
15
16
r.Route("/tree/{ref}", func(r chi.Router) {
···
18
19
})
19
20
r.Get("/commit/{ref}", rp.RepoCommit)
20
21
r.Get("/branches", rp.RepoBranches)
22
+
r.Delete("/branches", rp.DeleteBranch)
21
23
r.Route("/tags", func(r chi.Router) {
22
24
r.Get("/", rp.RepoTags)
23
25
r.Route("/{tag}", func(r chi.Router) {
+5
-4
appview/settings/settings.go
+5
-4
appview/settings/settings.go
···
22
22
"tangled.org/core/tid"
23
23
24
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
26
lexutil "github.com/bluesky-social/indigo/lex/util"
26
27
"github.com/gliderlabs/ssh"
27
28
"github.com/google/uuid"
···
91
92
user := s.OAuth.GetUser(r)
92
93
did := s.OAuth.GetDid(r)
93
94
94
-
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
prefs, err := db.GetNotificationPreference(s.Db, did)
95
96
if err != nil {
96
97
log.Printf("failed to get notification preferences: %s", err)
97
98
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
···
110
111
did := s.OAuth.GetDid(r)
111
112
112
113
prefs := &models.NotificationPreferences{
113
-
UserDid: did,
114
+
UserDid: syntax.DID(did),
114
115
RepoStarred: r.FormValue("repo_starred") == "on",
115
116
IssueCreated: r.FormValue("issue_created") == "on",
116
117
IssueCommented: r.FormValue("issue_commented") == "on",
···
470
471
}
471
472
472
473
// store in pds too
473
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
474
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
474
475
Collection: tangled.PublicKeyNSID,
475
476
Repo: did,
476
477
Rkey: rkey,
···
527
528
528
529
if rkey != "" {
529
530
// remove from pds too
530
-
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
531
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
531
532
Collection: tangled.PublicKeyNSID,
532
533
Repo: did,
533
534
Rkey: rkey,
+18
appview/signup/requests.go
+18
appview/signup/requests.go
···
102
102
103
103
return result.DID, nil
104
104
}
105
+
106
+
func (s *Signup) deleteAccountRequest(did string) error {
107
+
body := map[string]string{
108
+
"did": did,
109
+
}
110
+
111
+
resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true)
112
+
if err != nil {
113
+
return err
114
+
}
115
+
defer resp.Body.Close()
116
+
117
+
if resp.StatusCode != http.StatusOK {
118
+
return s.handlePdsError(resp, "delete account")
119
+
}
120
+
121
+
return nil
122
+
}
+95
-40
appview/signup/signup.go
+95
-40
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"context"
5
6
"encoding/json"
6
7
"errors"
7
8
"fmt"
···
20
21
"tangled.org/core/appview/models"
21
22
"tangled.org/core/appview/pages"
22
23
"tangled.org/core/appview/state/userutil"
23
-
"tangled.org/core/appview/xrpcclient"
24
24
"tangled.org/core/idresolver"
25
25
)
26
26
···
29
29
db *db.DB
30
30
cf *dns.Cloudflare
31
31
posthog posthog.Client
32
-
xrpc *xrpcclient.Client
33
32
idResolver *idresolver.Resolver
34
33
pages *pages.Pages
35
34
l *slog.Logger
···
64
63
disallowed := make(map[string]bool)
65
64
66
65
if filepath == "" {
67
-
logger.Debug("no disallowed nicknames file configured")
66
+
logger.Warn("no disallowed nicknames file configured")
68
67
return disallowed
69
68
}
70
69
···
133
132
noticeId := "signup-msg"
134
133
135
134
if err := s.validateCaptcha(cfToken, r); err != nil {
136
-
s.l.Warn("turnstile validation failed", "error", err)
135
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
136
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
137
return
139
138
}
···
218
217
return
219
218
}
220
219
221
-
did, err := s.createAccountRequest(username, password, email, code)
222
-
if err != nil {
223
-
s.l.Error("failed to create account", "error", err)
224
-
s.pages.Notice(w, "signup-error", err.Error())
225
-
return
226
-
}
227
-
228
220
if s.cf == nil {
229
221
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
230
222
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
231
223
return
232
224
}
233
225
234
-
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
235
-
Type: "TXT",
236
-
Name: "_atproto." + username,
237
-
Content: fmt.Sprintf(`"did=%s"`, did),
238
-
TTL: 6400,
239
-
Proxied: false,
240
-
})
226
+
// Execute signup transactionally with rollback capability
227
+
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
241
228
if err != nil {
242
-
s.l.Error("failed to create DNS record", "error", err)
243
-
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
// Error already logged and notice already sent
244
230
return
245
231
}
232
+
}
233
+
}
246
234
247
-
err = db.AddEmail(s.db, models.Email{
248
-
Did: did,
249
-
Address: email,
250
-
Verified: true,
251
-
Primary: true,
252
-
})
253
-
if err != nil {
254
-
s.l.Error("failed to add email", "error", err)
255
-
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
256
-
return
235
+
// executeSignupTransaction performs the signup process transactionally with rollback
236
+
func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
237
+
var recordID string
238
+
var did string
239
+
var emailAdded bool
240
+
241
+
success := false
242
+
defer func() {
243
+
if !success {
244
+
s.l.Info("rolling back signup transaction", "username", username, "did", did)
245
+
246
+
// Rollback DNS record
247
+
if recordID != "" {
248
+
if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
249
+
s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
250
+
} else {
251
+
s.l.Info("successfully rolled back DNS record", "recordID", recordID)
252
+
}
253
+
}
254
+
255
+
// Rollback PDS account
256
+
if did != "" {
257
+
if err := s.deleteAccountRequest(did); err != nil {
258
+
s.l.Error("failed to rollback PDS account", "error", err, "did", did)
259
+
} else {
260
+
s.l.Info("successfully rolled back PDS account", "did", did)
261
+
}
262
+
}
263
+
264
+
// Rollback email from database
265
+
if emailAdded {
266
+
if err := db.DeleteEmail(s.db, did, email); err != nil {
267
+
s.l.Error("failed to rollback email from database", "error", err, "email", email)
268
+
} else {
269
+
s.l.Info("successfully rolled back email from database", "email", email)
270
+
}
271
+
}
257
272
}
273
+
}()
258
274
259
-
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
260
-
<a class="underline text-black dark:text-white" href="/login">login</a>
261
-
with <code>%s.tngl.sh</code>.`, username))
275
+
// step 1: create account in PDS
276
+
did, err := s.createAccountRequest(username, password, email, code)
277
+
if err != nil {
278
+
s.l.Error("failed to create account", "error", err)
279
+
s.pages.Notice(w, "signup-error", err.Error())
280
+
return err
281
+
}
262
282
263
-
go func() {
264
-
err := db.DeleteInflightSignup(s.db, email)
265
-
if err != nil {
266
-
s.l.Error("failed to delete inflight signup", "error", err)
267
-
}
268
-
}()
269
-
return
283
+
// step 2: create DNS record with actual DID
284
+
recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{
285
+
Type: "TXT",
286
+
Name: "_atproto." + username,
287
+
Content: fmt.Sprintf(`"did=%s"`, did),
288
+
TTL: 6400,
289
+
Proxied: false,
290
+
})
291
+
if err != nil {
292
+
s.l.Error("failed to create DNS record", "error", err)
293
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
294
+
return err
295
+
}
296
+
297
+
// step 3: add email to database
298
+
err = db.AddEmail(s.db, models.Email{
299
+
Did: did,
300
+
Address: email,
301
+
Verified: true,
302
+
Primary: true,
303
+
})
304
+
if err != nil {
305
+
s.l.Error("failed to add email", "error", err)
306
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
307
+
return err
270
308
}
309
+
emailAdded = true
310
+
311
+
// if we get here, we've successfully created the account and added the email
312
+
success = true
313
+
314
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
315
+
<a class="underline text-black dark:text-white" href="/login">login</a>
316
+
with <code>%s.tngl.sh</code>.`, username))
317
+
318
+
// clean up inflight signup asynchronously
319
+
go func() {
320
+
if err := db.DeleteInflightSignup(s.db, email); err != nil {
321
+
s.l.Error("failed to delete inflight signup", "error", err)
322
+
}
323
+
}()
324
+
325
+
return nil
271
326
}
272
327
273
328
type turnstileResponse struct {
+5
-5
appview/spindles/spindles.go
+5
-5
appview/spindles/spindles.go
···
189
189
return
190
190
}
191
191
192
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
192
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
193
193
var exCid *string
194
194
if ex != nil {
195
195
exCid = ex.Cid
196
196
}
197
197
198
198
// re-announce by registering under same rkey
199
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
199
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
200
Collection: tangled.SpindleNSID,
201
201
Repo: user.Did,
202
202
Rkey: instance,
···
332
332
return
333
333
}
334
334
335
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
335
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
336
336
Collection: tangled.SpindleNSID,
337
337
Repo: user.Did,
338
338
Rkey: instance,
···
542
542
return
543
543
}
544
544
545
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
545
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
546
546
Collection: tangled.SpindleMemberNSID,
547
547
Repo: user.Did,
548
548
Rkey: rkey,
···
683
683
}
684
684
685
685
// remove from pds
686
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
686
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
687
687
Collection: tangled.SpindleMemberNSID,
688
688
Repo: user.Did,
689
689
Rkey: members[0].Rkey,
+3
-2
appview/state/follow.go
+3
-2
appview/state/follow.go
···
26
26
subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject)
27
27
if err != nil {
28
28
log.Println("failed to follow, invalid did")
29
+
return
29
30
}
30
31
31
32
if currentUser.Did == subjectIdent.DID.String() {
···
43
44
case http.MethodPost:
44
45
createdAt := time.Now().Format(time.RFC3339)
45
46
rkey := tid.TID()
46
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
47
48
Collection: tangled.GraphFollowNSID,
48
49
Repo: currentUser.Did,
49
50
Rkey: rkey,
···
88
89
return
89
90
}
90
91
91
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
92
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
92
93
Collection: tangled.GraphFollowNSID,
93
94
Repo: currentUser.Did,
94
95
Rkey: follow.Rkey,
+148
appview/state/gfi.go
+148
appview/state/gfi.go
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"sort"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
"tangled.org/core/consts"
16
+
)
17
+
18
+
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
+
user := s.oauth.GetUser(r)
20
+
21
+
page := pagination.FromContext(r.Context())
22
+
23
+
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
24
+
25
+
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
26
+
if err != nil {
27
+
log.Println("failed to get repo labels", err)
28
+
s.pages.Error503(w)
29
+
return
30
+
}
31
+
32
+
if len(repoLabels) == 0 {
33
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
34
+
LoggedInUser: user,
35
+
RepoGroups: []*models.RepoGroup{},
36
+
LabelDefs: make(map[string]*models.LabelDefinition),
37
+
Page: page,
38
+
})
39
+
return
40
+
}
41
+
42
+
repoUris := make([]string, 0, len(repoLabels))
43
+
for _, rl := range repoLabels {
44
+
repoUris = append(repoUris, rl.RepoAt.String())
45
+
}
46
+
47
+
allIssues, err := db.GetIssuesPaginated(
48
+
s.db,
49
+
pagination.Page{
50
+
Limit: 500,
51
+
},
52
+
db.FilterIn("repo_at", repoUris),
53
+
db.FilterEq("open", 1),
54
+
)
55
+
if err != nil {
56
+
log.Println("failed to get issues", err)
57
+
s.pages.Error503(w)
58
+
return
59
+
}
60
+
61
+
var goodFirstIssues []models.Issue
62
+
for _, issue := range allIssues {
63
+
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
64
+
goodFirstIssues = append(goodFirstIssues, issue)
65
+
}
66
+
}
67
+
68
+
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
69
+
for _, issue := range goodFirstIssues {
70
+
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
71
+
group.Issues = append(group.Issues, issue)
72
+
} else {
73
+
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
74
+
Repo: issue.Repo,
75
+
Issues: []models.Issue{issue},
76
+
}
77
+
}
78
+
}
79
+
80
+
var sortedGroups []*models.RepoGroup
81
+
for _, group := range repoGroups {
82
+
sortedGroups = append(sortedGroups, group)
83
+
}
84
+
85
+
sort.Slice(sortedGroups, func(i, j int) bool {
86
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
87
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
88
+
89
+
// If one is tangled and the other isn't, non-tangled comes first
90
+
if iIsTangled != jIsTangled {
91
+
return jIsTangled // true if j is tangled (i should come first)
92
+
}
93
+
94
+
// Both tangled or both not tangled: sort by name
95
+
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
96
+
})
97
+
98
+
groupStart := page.Offset
99
+
groupEnd := page.Offset + page.Limit
100
+
if groupStart > len(sortedGroups) {
101
+
groupStart = len(sortedGroups)
102
+
}
103
+
if groupEnd > len(sortedGroups) {
104
+
groupEnd = len(sortedGroups)
105
+
}
106
+
107
+
paginatedGroups := sortedGroups[groupStart:groupEnd]
108
+
109
+
var allIssuesFromGroups []models.Issue
110
+
for _, group := range paginatedGroups {
111
+
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
112
+
}
113
+
114
+
var allLabelDefs []models.LabelDefinition
115
+
if len(allIssuesFromGroups) > 0 {
116
+
labelDefUris := make(map[string]bool)
117
+
for _, issue := range allIssuesFromGroups {
118
+
for labelDefUri := range issue.Labels.Inner() {
119
+
labelDefUris[labelDefUri] = true
120
+
}
121
+
}
122
+
123
+
uriList := make([]string, 0, len(labelDefUris))
124
+
for uri := range labelDefUris {
125
+
uriList = append(uriList, uri)
126
+
}
127
+
128
+
if len(uriList) > 0 {
129
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
130
+
if err != nil {
131
+
log.Println("failed to fetch labels", err)
132
+
}
133
+
}
134
+
}
135
+
136
+
labelDefsMap := make(map[string]*models.LabelDefinition)
137
+
for i := range allLabelDefs {
138
+
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
139
+
}
140
+
141
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
142
+
LoggedInUser: user,
143
+
RepoGroups: paginatedGroups,
144
+
LabelDefs: labelDefsMap,
145
+
Page: page,
146
+
GfiLabel: labelDefsMap[goodFirstIssueLabel],
147
+
})
148
+
}
+17
-2
appview/state/knotstream.go
+17
-2
appview/state/knotstream.go
···
25
25
)
26
26
27
27
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
28
+
logger := log.FromContext(ctx)
29
+
logger = log.SubLogger(logger, "knotstream")
30
+
28
31
knots, err := db.GetRegistrations(
29
32
d,
30
33
db.FilterIsNot("registered", "null"),
···
39
42
srcs[s] = struct{}{}
40
43
}
41
44
42
-
logger := log.New("knotstream")
43
45
cache := cache.New(c.Redis.Addr)
44
46
cursorStore := cursor.NewRedisCursorStore(cache)
45
47
···
172
174
})
173
175
}
174
176
175
-
return db.InsertRepoLanguages(d, langs)
177
+
tx, err := d.Begin()
178
+
if err != nil {
179
+
return err
180
+
}
181
+
defer tx.Rollback()
182
+
183
+
// update appview's cache
184
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
185
+
if err != nil {
186
+
fmt.Printf("failed; %s\n", err)
187
+
// non-fatal
188
+
}
189
+
190
+
return tx.Commit()
176
191
}
177
192
178
193
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+68
appview/state/login.go
+68
appview/state/login.go
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"strings"
7
+
8
+
"tangled.org/core/appview/pages"
9
+
)
10
+
11
+
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
12
+
l := s.logger.With("handler", "Login")
13
+
14
+
switch r.Method {
15
+
case http.MethodGet:
16
+
returnURL := r.URL.Query().Get("return_url")
17
+
errorCode := r.URL.Query().Get("error")
18
+
s.pages.Login(w, pages.LoginParams{
19
+
ReturnUrl: returnURL,
20
+
ErrorCode: errorCode,
21
+
})
22
+
case http.MethodPost:
23
+
handle := r.FormValue("handle")
24
+
25
+
// when users copy their handle from bsky.app, it tends to have these characters around it:
26
+
//
27
+
// @nelind.dk:
28
+
// \u202a ensures that the handle is always rendered left to right and
29
+
// \u202c reverts that so the rest of the page renders however it should
30
+
handle = strings.TrimPrefix(handle, "\u202a")
31
+
handle = strings.TrimSuffix(handle, "\u202c")
32
+
33
+
// `@` is harmless
34
+
handle = strings.TrimPrefix(handle, "@")
35
+
36
+
// basic handle validation
37
+
if !strings.Contains(handle, ".") {
38
+
l.Error("invalid handle format", "raw", handle)
39
+
s.pages.Notice(
40
+
w,
41
+
"login-msg",
42
+
fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle),
43
+
)
44
+
return
45
+
}
46
+
47
+
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
48
+
if err != nil {
49
+
http.Error(w, err.Error(), http.StatusInternalServerError)
50
+
return
51
+
}
52
+
53
+
s.pages.HxRedirect(w, redirectURL)
54
+
}
55
+
}
56
+
57
+
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
58
+
l := s.logger.With("handler", "Logout")
59
+
60
+
err := s.oauth.DeleteSession(w, r)
61
+
if err != nil {
62
+
l.Error("failed to logout", "err", err)
63
+
} else {
64
+
l.Info("logged out successfully")
65
+
}
66
+
67
+
s.pages.HxRedirect(w, "/login")
68
+
}
+2
-2
appview/state/profile.go
+2
-2
appview/state/profile.go
···
634
634
vanityStats = append(vanityStats, string(v.Kind))
635
635
}
636
636
637
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
637
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
638
638
var cid *string
639
639
if ex != nil {
640
640
cid = ex.Cid
641
641
}
642
642
643
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
643
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
644
644
Collection: tangled.ActorProfileNSID,
645
645
Repo: user.Did,
646
646
Rkey: "self",
+11
-9
appview/state/reaction.go
+11
-9
appview/state/reaction.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
11
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
12
12
"tangled.org/core/api/tangled"
13
13
"tangled.org/core/appview/db"
14
14
"tangled.org/core/appview/models"
···
47
47
case http.MethodPost:
48
48
createdAt := time.Now().Format(time.RFC3339)
49
49
rkey := tid.TID()
50
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
51
51
Collection: tangled.FeedReactionNSID,
52
52
Repo: currentUser.Did,
53
53
Rkey: rkey,
···
70
70
return
71
71
}
72
72
73
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
73
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
74
74
if err != nil {
75
-
log.Println("failed to get reaction count for ", subjectUri)
75
+
log.Println("failed to get reactions for ", subjectUri)
76
76
}
77
77
78
78
log.Println("created atproto record: ", resp.Uri)
···
80
80
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
81
81
ThreadAt: subjectUri,
82
82
Kind: reactionKind,
83
-
Count: count,
83
+
Count: reactionMap[reactionKind].Count,
84
+
Users: reactionMap[reactionKind].Users,
84
85
IsReacted: true,
85
86
})
86
87
···
92
93
return
93
94
}
94
95
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
96
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
97
Collection: tangled.FeedReactionNSID,
97
98
Repo: currentUser.Did,
98
99
Rkey: reaction.Rkey,
···
109
110
// this is not an issue, the firehose event might have already done this
110
111
}
111
112
112
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
113
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
113
114
if err != nil {
114
-
log.Println("failed to get reaction count for ", subjectUri)
115
+
log.Println("failed to get reactions for ", subjectUri)
115
116
return
116
117
}
117
118
118
119
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
119
120
ThreadAt: subjectUri,
120
121
Kind: reactionKind,
121
-
Count: count,
122
+
Count: reactionMap[reactionKind].Count,
123
+
Users: reactionMap[reactionKind].Users,
122
124
IsReacted: false,
123
125
})
124
126
+69
-23
appview/state/router.go
+69
-23
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
-
"github.com/gorilla/sessions"
9
8
"tangled.org/core/appview/issues"
10
9
"tangled.org/core/appview/knots"
11
10
"tangled.org/core/appview/labels"
12
11
"tangled.org/core/appview/middleware"
13
12
"tangled.org/core/appview/notifications"
14
-
oauthhandler "tangled.org/core/appview/oauth/handler"
15
13
"tangled.org/core/appview/pipelines"
16
14
"tangled.org/core/appview/pulls"
17
15
"tangled.org/core/appview/repo"
···
34
32
s.pages,
35
33
)
36
34
37
-
router.Use(middleware.TryRefreshSession())
38
35
router.Get("/favicon.svg", s.Favicon)
39
36
router.Get("/favicon.ico", s.Favicon)
37
+
router.Get("/pwa-manifest.json", s.PWAManifest)
38
+
router.Get("/robots.txt", s.RobotsTxt)
40
39
41
40
userRouter := s.UserRouter(&middleware)
42
41
standardRouter := s.StandardRouter(&middleware)
···
122
121
// special-case handler for serving tangled.org/core
123
122
r.Get("/core", s.Core())
124
123
124
+
r.Get("/login", s.Login)
125
+
r.Post("/login", s.Login)
126
+
r.Post("/logout", s.Logout)
127
+
125
128
r.Route("/repo", func(r chi.Router) {
126
129
r.Route("/new", func(r chi.Router) {
127
130
r.Use(middleware.AuthMiddleware(s.oauth))
···
130
133
})
131
134
// r.Post("/import", s.ImportRepo)
132
135
})
136
+
137
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
133
138
134
139
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
135
140
r.Post("/", s.Follow)
···
161
166
r.Mount("/notifications", s.NotificationsRouter(mw))
162
167
163
168
r.Mount("/signup", s.SignupRouter())
164
-
r.Mount("/", s.OAuthRouter())
169
+
r.Mount("/", s.oauth.Router())
165
170
166
171
r.Get("/keys/{user}", s.Keys)
167
172
r.Get("/terms", s.TermsOfService)
···
188
193
}
189
194
}
190
195
191
-
func (s *State) OAuthRouter() http.Handler {
192
-
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
193
-
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
194
-
return oauth.Router()
195
-
}
196
-
197
196
func (s *State) SettingsRouter() http.Handler {
198
197
settings := &settings.Settings{
199
198
Db: s.db,
···
206
205
}
207
206
208
207
func (s *State) SpindlesRouter() http.Handler {
209
-
logger := log.New("spindles")
208
+
logger := log.SubLogger(s.logger, "spindles")
210
209
211
210
spindles := &spindles.Spindles{
212
211
Db: s.db,
···
222
221
}
223
222
224
223
func (s *State) KnotsRouter() http.Handler {
225
-
logger := log.New("knots")
224
+
logger := log.SubLogger(s.logger, "knots")
226
225
227
226
knots := &knots.Knots{
228
227
Db: s.db,
···
239
238
}
240
239
241
240
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
242
-
logger := log.New("strings")
241
+
logger := log.SubLogger(s.logger, "strings")
243
242
244
243
strs := &avstrings.Strings{
245
244
Db: s.db,
···
254
253
}
255
254
256
255
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
257
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
256
+
issues := issues.New(
257
+
s.oauth,
258
+
s.repoResolver,
259
+
s.pages,
260
+
s.idResolver,
261
+
s.db,
262
+
s.config,
263
+
s.notifier,
264
+
s.validator,
265
+
log.SubLogger(s.logger, "issues"),
266
+
)
258
267
return issues.Router(mw)
259
268
}
260
269
261
270
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
262
-
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
271
+
pulls := pulls.New(
272
+
s.oauth,
273
+
s.repoResolver,
274
+
s.pages,
275
+
s.idResolver,
276
+
s.db,
277
+
s.config,
278
+
s.notifier,
279
+
s.enforcer,
280
+
s.validator,
281
+
log.SubLogger(s.logger, "pulls"),
282
+
)
263
283
return pulls.Router(mw)
264
284
}
265
285
266
286
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
267
-
logger := log.New("repo")
268
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator)
287
+
repo := repo.New(
288
+
s.oauth,
289
+
s.repoResolver,
290
+
s.pages,
291
+
s.spindlestream,
292
+
s.idResolver,
293
+
s.db,
294
+
s.config,
295
+
s.notifier,
296
+
s.enforcer,
297
+
log.SubLogger(s.logger, "repo"),
298
+
s.validator,
299
+
)
269
300
return repo.Router(mw)
270
301
}
271
302
272
303
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
273
-
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
304
+
pipes := pipelines.New(
305
+
s.oauth,
306
+
s.repoResolver,
307
+
s.pages,
308
+
s.spindlestream,
309
+
s.idResolver,
310
+
s.db,
311
+
s.config,
312
+
s.enforcer,
313
+
log.SubLogger(s.logger, "pipelines"),
314
+
)
274
315
return pipes.Router(mw)
275
316
}
276
317
277
318
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
278
-
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
319
+
ls := labels.New(
320
+
s.oauth,
321
+
s.pages,
322
+
s.db,
323
+
s.validator,
324
+
s.enforcer,
325
+
log.SubLogger(s.logger, "labels"),
326
+
)
279
327
return ls.Router(mw)
280
328
}
281
329
282
330
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
283
-
notifs := notifications.New(s.db, s.oauth, s.pages)
331
+
notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications"))
284
332
return notifs.Router(mw)
285
333
}
286
334
287
335
func (s *State) SignupRouter() http.Handler {
288
-
logger := log.New("signup")
289
-
290
-
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
336
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup"))
291
337
return sig.Router()
292
338
}
+3
-1
appview/state/spindlestream.go
+3
-1
appview/state/spindlestream.go
···
22
22
)
23
23
24
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
25
+
logger := log.FromContext(ctx)
26
+
logger = log.SubLogger(logger, "spindlestream")
27
+
25
28
spindles, err := db.GetSpindles(
26
29
d,
27
30
db.FilterIsNot("verified", "null"),
···
36
39
srcs[src] = struct{}{}
37
40
}
38
41
39
-
logger := log.New("spindlestream")
40
42
cache := cache.New(c.Redis.Addr)
41
43
cursorStore := cursor.NewRedisCursorStore(cache)
42
44
+2
-2
appview/state/star.go
+2
-2
appview/state/star.go
···
40
40
case http.MethodPost:
41
41
createdAt := time.Now().Format(time.RFC3339)
42
42
rkey := tid.TID()
43
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
44
44
Collection: tangled.FeedStarNSID,
45
45
Repo: currentUser.Did,
46
46
Rkey: rkey,
···
92
92
return
93
93
}
94
94
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
95
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
96
Collection: tangled.FeedStarNSID,
97
97
Repo: currentUser.Did,
98
98
Rkey: star.Rkey,
+81
-37
appview/state/state.go
+81
-37
appview/state/state.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"strings"
12
11
"time"
13
12
14
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
-
"github.com/bluesky-social/indigo/atproto/syntax"
16
-
lexutil "github.com/bluesky-social/indigo/lex/util"
17
-
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview"
22
-
"tangled.org/core/appview/cache"
23
-
"tangled.org/core/appview/cache/session"
24
15
"tangled.org/core/appview/config"
25
16
"tangled.org/core/appview/db"
26
17
"tangled.org/core/appview/models"
···
35
26
"tangled.org/core/eventconsumer"
36
27
"tangled.org/core/idresolver"
37
28
"tangled.org/core/jetstream"
29
+
"tangled.org/core/log"
38
30
tlog "tangled.org/core/log"
39
31
"tangled.org/core/rbac"
40
32
"tangled.org/core/tid"
33
+
34
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
35
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
36
+
"github.com/bluesky-social/indigo/atproto/syntax"
37
+
lexutil "github.com/bluesky-social/indigo/lex/util"
38
+
securejoin "github.com/cyphar/filepath-securejoin"
39
+
"github.com/go-chi/chi/v5"
40
+
"github.com/posthog/posthog-go"
41
41
)
42
42
43
43
type State struct {
···
46
46
oauth *oauth.OAuth
47
47
enforcer *rbac.Enforcer
48
48
pages *pages.Pages
49
-
sess *session.SessionStore
50
49
idResolver *idresolver.Resolver
51
50
posthog posthog.Client
52
51
jc *jetstream.JetstreamClient
···
59
58
}
60
59
61
60
func Make(ctx context.Context, config *config.Config) (*State, error) {
62
-
d, err := db.Make(config.Core.DbPath)
61
+
logger := tlog.FromContext(ctx)
62
+
63
+
d, err := db.Make(ctx, config.Core.DbPath)
63
64
if err != nil {
64
65
return nil, fmt.Errorf("failed to create db: %w", err)
65
66
}
···
71
72
72
73
res, err := idresolver.RedisResolver(config.Redis.ToURL())
73
74
if err != nil {
74
-
log.Printf("failed to create redis resolver: %v", err)
75
+
logger.Error("failed to create redis resolver", "err", err)
75
76
res = idresolver.DefaultResolver()
76
77
}
77
78
78
-
pgs := pages.NewPages(config, res)
79
-
cache := cache.New(config.Redis.Addr)
80
-
sess := session.New(cache)
81
-
oauth := oauth.NewOAuth(config, sess)
82
-
validator := validator.New(d, res, enforcer)
83
-
84
79
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
85
80
if err != nil {
86
81
return nil, fmt.Errorf("failed to create posthog client: %w", err)
87
82
}
83
+
84
+
pages := pages.NewPages(config, res, log.SubLogger(logger, "pages"))
85
+
oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth"))
86
+
if err != nil {
87
+
return nil, fmt.Errorf("failed to start oauth handler: %w", err)
88
+
}
89
+
validator := validator.New(d, res, enforcer)
88
90
89
91
repoResolver := reporesolver.New(config, enforcer, res, d)
90
92
···
107
109
tangled.LabelOpNSID,
108
110
},
109
111
nil,
110
-
slog.Default(),
112
+
tlog.SubLogger(logger, "jetstream"),
111
113
wrapper,
112
114
false,
113
115
···
128
130
Enforcer: enforcer,
129
131
IdResolver: res,
130
132
Config: config,
131
-
Logger: tlog.New("ingester"),
133
+
Logger: log.SubLogger(logger, "ingester"),
132
134
Validator: validator,
133
135
}
134
136
err = jc.StartJetstream(ctx, ingester.Ingest())
···
164
166
notifier,
165
167
oauth,
166
168
enforcer,
167
-
pgs,
168
-
sess,
169
+
pages,
169
170
res,
170
171
posthog,
171
172
jc,
···
173
174
repoResolver,
174
175
knotstream,
175
176
spindlestream,
176
-
slog.Default(),
177
+
logger,
177
178
validator,
178
179
}
179
180
···
198
199
s.pages.Favicon(w)
199
200
}
200
201
202
+
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
203
+
w.Header().Set("Content-Type", "text/plain")
204
+
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
205
+
206
+
robotsTxt := `User-agent: *
207
+
Allow: /
208
+
`
209
+
w.Write([]byte(robotsTxt))
210
+
}
211
+
212
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
213
+
const manifestJson = `{
214
+
"name": "tangled",
215
+
"description": "tightly-knit social coding.",
216
+
"icons": [
217
+
{
218
+
"src": "/favicon.svg",
219
+
"sizes": "144x144"
220
+
}
221
+
],
222
+
"start_url": "/",
223
+
"id": "org.tangled",
224
+
225
+
"display": "standalone",
226
+
"background_color": "#111827",
227
+
"theme_color": "#111827"
228
+
}`
229
+
230
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
231
+
w.Header().Set("Content-Type", "application/json")
232
+
w.Write([]byte(manifestJson))
233
+
}
234
+
201
235
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
202
236
user := s.oauth.GetUser(r)
203
237
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
230
264
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
231
265
user := s.oauth.GetUser(r)
232
266
267
+
// TODO: set this flag based on the UI
268
+
filtered := false
269
+
233
270
var userDid string
234
271
if user != nil {
235
272
userDid = user.Did
236
273
}
237
-
timeline, err := db.MakeTimeline(s.db, 50, userDid)
274
+
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
238
275
if err != nil {
239
-
log.Println(err)
276
+
s.logger.Error("failed to make timeline", "err", err)
240
277
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
241
278
}
242
279
243
280
repos, err := db.GetTopStarredReposLastWeek(s.db)
244
281
if err != nil {
245
-
log.Println(err)
282
+
s.logger.Error("failed to get top starred repos", "err", err)
246
283
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
247
284
return
248
285
}
249
286
287
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
288
+
if err != nil {
289
+
// non-fatal
290
+
}
291
+
250
292
s.pages.Timeline(w, pages.TimelineParams{
251
293
LoggedInUser: user,
252
294
Timeline: timeline,
253
295
Repos: repos,
296
+
GfiLabel: gfiLabel,
254
297
})
255
298
}
256
299
···
262
305
263
306
l := s.logger.With("handler", "UpgradeBanner")
264
307
l = l.With("did", user.Did)
265
-
l = l.With("handle", user.Handle)
266
308
267
309
regs, err := db.GetRegistrations(
268
310
s.db,
···
293
335
}
294
336
295
337
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
296
-
timeline, err := db.MakeTimeline(s.db, 5, "")
338
+
// TODO: set this flag based on the UI
339
+
filtered := false
340
+
341
+
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
297
342
if err != nil {
298
-
log.Println(err)
343
+
s.logger.Error("failed to make timeline", "err", err)
299
344
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
300
345
return
301
346
}
302
347
303
348
repos, err := db.GetTopStarredReposLastWeek(s.db)
304
349
if err != nil {
305
-
log.Println(err)
350
+
s.logger.Error("failed to get top starred repos", "err", err)
306
351
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
307
352
return
308
353
}
···
402
447
403
448
user := s.oauth.GetUser(r)
404
449
l = l.With("did", user.Did)
405
-
l = l.With("handle", user.Handle)
406
450
407
451
// form validation
408
452
domain := r.FormValue("domain")
···
466
510
}
467
511
record := repo.AsRecord()
468
512
469
-
xrpcClient, err := s.oauth.AuthorizedClient(r)
513
+
atpClient, err := s.oauth.AuthorizedClient(r)
470
514
if err != nil {
471
515
l.Info("PDS write failed", "err", err)
472
516
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
473
517
return
474
518
}
475
519
476
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
520
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
477
521
Collection: tangled.RepoNSID,
478
522
Repo: user.Did,
479
523
Rkey: rkey,
···
505
549
rollback := func() {
506
550
err1 := tx.Rollback()
507
551
err2 := s.enforcer.E.LoadPolicy()
508
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
552
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
509
553
510
554
// ignore txn complete errors, this is okay
511
555
if errors.Is(err1, sql.ErrTxDone) {
···
578
622
aturi = ""
579
623
580
624
s.notifier.NewRepo(r.Context(), repo)
581
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
625
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
582
626
}
583
627
}
584
628
585
629
// this is used to rollback changes made to the PDS
586
630
//
587
631
// it is a no-op if the provided ATURI is empty
588
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
632
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
589
633
if aturi == "" {
590
634
return nil
591
635
}
···
596
640
repo := parsed.Authority().String()
597
641
rkey := parsed.RecordKey().String()
598
642
599
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
643
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
600
644
Collection: collection,
601
645
Repo: repo,
602
646
Rkey: rkey,
+9
-7
appview/strings/strings.go
+9
-7
appview/strings/strings.go
···
22
22
"github.com/bluesky-social/indigo/api/atproto"
23
23
"github.com/bluesky-social/indigo/atproto/identity"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
25
-
lexutil "github.com/bluesky-social/indigo/lex/util"
26
25
"github.com/go-chi/chi/v5"
26
+
27
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
27
29
)
28
30
29
31
type Strings struct {
···
254
256
}
255
257
256
258
// first replace the existing record in the PDS
257
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
259
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
258
260
if err != nil {
259
261
fail("Failed to updated existing record.", err)
260
262
return
261
263
}
262
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
264
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
263
265
Collection: tangled.StringNSID,
264
266
Repo: entry.Did.String(),
265
267
Rkey: entry.Rkey,
···
284
286
s.Notifier.EditString(r.Context(), &entry)
285
287
286
288
// if that went okay, redir to the string
287
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
289
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
288
290
}
289
291
290
292
}
···
336
338
return
337
339
}
338
340
339
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
341
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
340
342
Collection: tangled.StringNSID,
341
343
Repo: user.Did,
342
344
Rkey: string.Rkey,
···
360
362
s.Notifier.NewString(r.Context(), &string)
361
363
362
364
// successful
363
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
365
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
364
366
}
365
367
}
366
368
···
403
405
404
406
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
405
407
406
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
408
+
s.Pages.HxRedirect(w, "/strings/"+user.Did)
407
409
}
408
410
409
411
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+25
appview/validator/patch.go
+25
appview/validator/patch.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.org/core/patchutil"
8
+
)
9
+
10
+
func (v *Validator) ValidatePatch(patch *string) error {
11
+
if patch == nil || *patch == "" {
12
+
return fmt.Errorf("patch is empty")
13
+
}
14
+
15
+
// add newline if not present to diff style patches
16
+
if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") {
17
+
*patch = *patch + "\n"
18
+
}
19
+
20
+
if err := patchutil.IsPatchValid(*patch); err != nil {
21
+
return err
22
+
}
23
+
24
+
return nil
25
+
}
-99
appview/xrpcclient/xrpc.go
-99
appview/xrpcclient/xrpc.go
···
1
1
package xrpcclient
2
2
3
3
import (
4
-
"bytes"
5
-
"context"
6
4
"errors"
7
-
"io"
8
5
"net/http"
9
6
10
-
"github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
7
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
14
8
)
15
9
16
10
var (
···
19
13
ErrXrpcFailed = errors.New("xrpc request failed")
20
14
ErrXrpcInvalid = errors.New("invalid xrpc request")
21
15
)
22
-
23
-
type Client struct {
24
-
*oauth.XrpcClient
25
-
authArgs *oauth.XrpcAuthedRequestArgs
26
-
}
27
-
28
-
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
29
-
return &Client{
30
-
XrpcClient: client,
31
-
authArgs: authArgs,
32
-
}
33
-
}
34
-
35
-
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
36
-
var out atproto.RepoPutRecord_Output
37
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
38
-
return nil, err
39
-
}
40
-
41
-
return &out, nil
42
-
}
43
-
44
-
func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) {
45
-
var out atproto.RepoApplyWrites_Output
46
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
47
-
return nil, err
48
-
}
49
-
50
-
return &out, nil
51
-
}
52
-
53
-
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
54
-
var out atproto.RepoGetRecord_Output
55
-
56
-
params := map[string]interface{}{
57
-
"cid": cid,
58
-
"collection": collection,
59
-
"repo": repo,
60
-
"rkey": rkey,
61
-
}
62
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
63
-
return nil, err
64
-
}
65
-
66
-
return &out, nil
67
-
}
68
-
69
-
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
70
-
var out atproto.RepoUploadBlob_Output
71
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return &out, nil
76
-
}
77
-
78
-
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
79
-
buf := new(bytes.Buffer)
80
-
81
-
params := map[string]interface{}{
82
-
"cid": cid,
83
-
"did": did,
84
-
}
85
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
86
-
return nil, err
87
-
}
88
-
89
-
return buf.Bytes(), nil
90
-
}
91
-
92
-
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
93
-
var out atproto.RepoDeleteRecord_Output
94
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
95
-
return nil, err
96
-
}
97
-
98
-
return &out, nil
99
-
}
100
-
101
-
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
102
-
var out atproto.ServerGetServiceAuth_Output
103
-
104
-
params := map[string]interface{}{
105
-
"aud": aud,
106
-
"exp": exp,
107
-
"lxm": lxm,
108
-
}
109
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
110
-
return nil, err
111
-
}
112
-
113
-
return &out, nil
114
-
}
115
16
116
17
// produces a more manageable error
117
18
func HandleXrpcErr(err error) error {
+14
-9
cmd/appview/main.go
+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
+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
-62
cmd/gen.go
···
1
-
package main
2
-
3
-
import (
4
-
cbg "github.com/whyrusleeping/cbor-gen"
5
-
"tangled.org/core/api/tangled"
6
-
)
7
-
8
-
func main() {
9
-
10
-
genCfg := cbg.Gen{
11
-
MaxStringLength: 1_000_000,
12
-
}
13
-
14
-
if err := genCfg.WriteMapEncodersToFile(
15
-
"api/tangled/cbor_gen.go",
16
-
"tangled",
17
-
tangled.ActorProfile{},
18
-
tangled.FeedReaction{},
19
-
tangled.FeedStar{},
20
-
tangled.GitRefUpdate{},
21
-
tangled.GitRefUpdate_CommitCountBreakdown{},
22
-
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
-
tangled.GitRefUpdate_IndividualLanguageSize{},
24
-
tangled.GitRefUpdate_LangBreakdown{},
25
-
tangled.GitRefUpdate_Meta{},
26
-
tangled.GraphFollow{},
27
-
tangled.Knot{},
28
-
tangled.KnotMember{},
29
-
tangled.LabelDefinition{},
30
-
tangled.LabelDefinition_ValueType{},
31
-
tangled.LabelOp{},
32
-
tangled.LabelOp_Operand{},
33
-
tangled.Pipeline{},
34
-
tangled.Pipeline_CloneOpts{},
35
-
tangled.Pipeline_ManualTriggerData{},
36
-
tangled.Pipeline_Pair{},
37
-
tangled.Pipeline_PullRequestTriggerData{},
38
-
tangled.Pipeline_PushTriggerData{},
39
-
tangled.PipelineStatus{},
40
-
tangled.Pipeline_TriggerMetadata{},
41
-
tangled.Pipeline_TriggerRepo{},
42
-
tangled.Pipeline_Workflow{},
43
-
tangled.PublicKey{},
44
-
tangled.Repo{},
45
-
tangled.RepoArtifact{},
46
-
tangled.RepoCollaborator{},
47
-
tangled.RepoIssue{},
48
-
tangled.RepoIssueComment{},
49
-
tangled.RepoIssueState{},
50
-
tangled.RepoPull{},
51
-
tangled.RepoPullComment{},
52
-
tangled.RepoPull_Source{},
53
-
tangled.RepoPullStatus{},
54
-
tangled.RepoPull_Target{},
55
-
tangled.Spindle{},
56
-
tangled.SpindleMember{},
57
-
tangled.String{},
58
-
); err != nil {
59
-
panic(err)
60
-
}
61
-
62
-
}
+1
-1
cmd/genjwks/main.go
+1
-1
cmd/genjwks/main.go
+6
-3
cmd/knot/main.go
+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
-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
+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
+2
-1
docs/knot-hosting.md
···
39
39
```
40
40
41
41
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/knot` is a good choice:
42
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
43
44
44
```
45
45
sudo mv knot /usr/local/bin/knot
46
+
sudo chown root:root /usr/local/bin/knot
46
47
```
47
48
48
49
This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1
-1
docs/spindle/pipeline.md
+1
-1
docs/spindle/pipeline.md
···
21
21
- `manual`: The workflow can be triggered manually.
22
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
25
26
26
```yaml
27
27
when:
+1
-1
flake.nix
+1
-1
flake.nix
···
262
262
lexgen --build-file lexicon-build-config.json lexicons
263
263
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
264
264
${pkgs.gotools}/bin/goimports -w api/tangled/*
265
-
go run cmd/gen.go
265
+
go run ./cmd/cborgen/
266
266
lexgen --build-file lexicon-build-config.json lexicons
267
267
rm api/tangled/*.bak
268
268
'';
+23
-6
go.mod
+23
-6
go.mod
···
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
11
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
···
21
21
github.com/go-chi/chi/v5 v5.2.0
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
+
github.com/goki/freetype v1.0.5
24
25
github.com/google/uuid v1.6.0
25
26
github.com/gorilla/feeds v1.2.0
26
27
github.com/gorilla/sessions v1.4.0
···
36
37
github.com/redis/go-redis/v9 v9.7.3
37
38
github.com/resend/resend-go/v2 v2.15.0
38
39
github.com/sethvargo/go-envconfig v1.1.0
40
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
41
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
39
42
github.com/stretchr/testify v1.10.0
40
43
github.com/urfave/cli/v3 v3.3.3
41
44
github.com/whyrusleeping/cbor-gen v0.3.1
42
45
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
-
github.com/yuin/goldmark v1.7.12
46
+
github.com/yuin/goldmark v1.7.13
44
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
48
golang.org/x/crypto v0.40.0
49
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
+
golang.org/x/image v0.31.0
46
51
golang.org/x/net v0.42.0
47
-
golang.org/x/sync v0.16.0
52
+
golang.org/x/sync v0.17.0
48
53
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
49
54
gopkg.in/yaml.v3 v3.0.1
50
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
51
55
)
52
56
53
57
require (
···
56
60
github.com/ProtonMail/go-crypto v1.3.0 // indirect
57
61
github.com/alecthomas/repr v0.4.0 // indirect
58
62
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
63
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
59
64
github.com/aymerick/douceur v0.2.0 // indirect
60
65
github.com/beorn7/perks v1.0.1 // indirect
61
66
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
62
67
github.com/casbin/govaluate v1.3.0 // indirect
63
68
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
64
69
github.com/cespare/xxhash/v2 v2.3.0 // indirect
70
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
71
+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
72
+
github.com/charmbracelet/log v0.4.2 // indirect
73
+
github.com/charmbracelet/x/ansi v0.8.0 // indirect
74
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
75
+
github.com/charmbracelet/x/term v0.2.1 // indirect
65
76
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
66
77
github.com/containerd/errdefs v1.0.0 // indirect
67
78
github.com/containerd/errdefs/pkg v0.3.0 // indirect
···
80
91
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
81
92
github.com/go-git/go-billy/v5 v5.6.2 // indirect
82
93
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
94
+
github.com/go-logfmt/logfmt v0.6.0 // indirect
83
95
github.com/go-logr/logr v1.4.3 // indirect
84
96
github.com/go-logr/stdr v1.2.2 // indirect
85
97
github.com/go-redis/cache/v9 v9.0.0 // indirect
···
122
134
github.com/lestrrat-go/httprc v1.0.6 // indirect
123
135
github.com/lestrrat-go/iter v1.0.2 // indirect
124
136
github.com/lestrrat-go/option v1.0.1 // indirect
137
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
125
138
github.com/mattn/go-isatty v0.0.20 // indirect
139
+
github.com/mattn/go-runewidth v0.0.16 // indirect
126
140
github.com/minio/sha256-simd v1.0.1 // indirect
127
141
github.com/mitchellh/mapstructure v1.5.0 // indirect
128
142
github.com/moby/docker-image-spec v1.3.1 // indirect
···
130
144
github.com/moby/term v0.5.2 // indirect
131
145
github.com/morikuni/aec v1.0.0 // indirect
132
146
github.com/mr-tron/base58 v1.2.0 // indirect
147
+
github.com/muesli/termenv v0.16.0 // indirect
133
148
github.com/multiformats/go-base32 v0.1.0 // indirect
134
149
github.com/multiformats/go-base36 v0.2.0 // indirect
135
150
github.com/multiformats/go-multibase v0.2.0 // indirect
···
148
163
github.com/prometheus/client_model v0.6.2 // indirect
149
164
github.com/prometheus/common v0.64.0 // indirect
150
165
github.com/prometheus/procfs v0.16.1 // indirect
166
+
github.com/rivo/uniseg v0.4.7 // indirect
151
167
github.com/ryanuber/go-glob v1.0.0 // indirect
152
168
github.com/segmentio/asm v1.2.0 // indirect
153
169
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
···
156
172
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
157
173
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
158
174
github.com/wyatt915/treeblood v0.1.15 // indirect
175
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
176
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
159
177
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
160
178
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
161
179
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
···
168
186
go.uber.org/atomic v1.11.0 // indirect
169
187
go.uber.org/multierr v1.11.0 // indirect
170
188
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
189
golang.org/x/sys v0.34.0 // indirect
173
-
golang.org/x/text v0.27.0 // indirect
190
+
golang.org/x/text v0.29.0 // indirect
174
191
golang.org/x/time v0.12.0 // indirect
175
192
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
176
193
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+45
-12
go.sum
+45
-12
go.sum
···
19
19
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
20
20
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
21
21
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
22
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
23
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
22
24
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
23
25
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
26
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
27
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
28
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
29
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
28
30
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
31
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
32
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
48
50
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
49
51
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
50
52
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
53
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
54
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
55
+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
56
+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
57
+
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
58
+
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
59
+
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
60
+
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
61
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
62
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
63
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
64
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
51
65
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
66
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
67
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
···
120
134
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
121
135
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
122
136
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
137
+
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
138
+
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
123
139
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
124
140
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
125
141
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
···
136
152
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
137
153
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
138
154
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
155
+
github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
156
+
github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
139
157
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
140
158
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
141
159
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
243
261
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
244
262
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
245
263
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
246
-
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
247
-
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
248
264
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
249
265
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
250
266
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
276
292
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
277
293
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
278
294
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
295
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
296
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
279
297
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
280
298
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
281
299
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
282
300
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
301
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
302
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
283
303
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
284
304
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
285
305
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
···
300
320
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
301
321
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
302
322
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
323
+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
324
+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
303
325
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
304
326
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
305
327
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
···
377
399
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
378
400
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
379
401
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
402
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
403
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
404
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
380
405
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
381
406
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
382
407
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
···
399
424
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
400
425
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
401
426
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
427
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
428
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
429
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
430
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
402
431
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
403
432
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
404
433
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
430
459
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
431
460
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
432
461
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
462
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
463
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
433
464
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
434
465
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
435
466
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
436
467
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
437
468
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
438
469
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
439
-
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
440
-
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
470
+
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
471
+
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
441
472
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
442
473
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
474
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
475
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c=
443
476
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
444
477
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
445
478
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
···
489
522
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
490
523
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
491
524
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
525
+
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
526
+
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
492
527
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
493
528
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
494
529
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
528
563
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
529
564
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
530
565
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
531
-
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
532
-
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
566
+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
567
+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
533
568
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
534
569
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
535
570
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
583
618
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
584
619
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
585
620
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
586
-
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
587
-
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
621
+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
622
+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
588
623
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
589
624
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
590
625
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
652
687
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
653
688
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
654
689
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
655
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
656
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
657
690
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
658
691
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71
-12
input.css
+71
-12
input.css
···
134
134
}
135
135
136
136
.prose hr {
137
-
@apply my-2;
137
+
@apply my-2;
138
138
}
139
139
140
140
.prose li:has(input) {
141
-
@apply list-none;
141
+
@apply list-none;
142
142
}
143
143
144
144
.prose ul:has(input) {
145
-
@apply pl-2;
145
+
@apply pl-2;
146
146
}
147
147
148
148
.prose .heading .anchor {
149
-
@apply no-underline mx-2 opacity-0;
149
+
@apply no-underline mx-2 opacity-0;
150
150
}
151
151
152
152
.prose .heading:hover .anchor {
153
-
@apply opacity-70;
153
+
@apply opacity-70;
154
154
}
155
155
156
156
.prose .heading .anchor:hover {
157
-
@apply opacity-70;
157
+
@apply opacity-70;
158
158
}
159
159
160
160
.prose a.footnote-backref {
161
-
@apply no-underline;
161
+
@apply no-underline;
162
162
}
163
163
164
164
.prose li {
165
-
@apply my-0 py-0;
165
+
@apply my-0 py-0;
166
166
}
167
167
168
-
.prose ul, .prose ol {
169
-
@apply my-1 py-0;
168
+
.prose ul,
169
+
.prose ol {
170
+
@apply my-1 py-0;
170
171
}
171
172
172
173
.prose img {
···
176
177
}
177
178
178
179
.prose input {
179
-
@apply inline-block my-0 mb-1 mx-1;
180
+
@apply inline-block my-0 mb-1 mx-1;
180
181
}
181
182
182
183
.prose input[type="checkbox"] {
183
184
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
184
185
}
186
+
187
+
/* Base callout */
188
+
details[data-callout] {
189
+
@apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;
190
+
}
191
+
192
+
details[data-callout] > summary {
193
+
@apply font-bold cursor-pointer mb-1;
194
+
}
195
+
196
+
details[data-callout] > .callout-content {
197
+
@apply text-sm leading-snug;
198
+
}
199
+
200
+
/* Note (blue) */
201
+
details[data-callout="note" i] {
202
+
@apply border-blue-400 dark:border-blue-500;
203
+
}
204
+
details[data-callout="note" i] > summary {
205
+
@apply text-blue-700 dark:text-blue-400;
206
+
}
207
+
208
+
/* Important (purple) */
209
+
details[data-callout="important" i] {
210
+
@apply border-purple-400 dark:border-purple-500;
211
+
}
212
+
details[data-callout="important" i] > summary {
213
+
@apply text-purple-700 dark:text-purple-400;
214
+
}
215
+
216
+
/* Warning (yellow) */
217
+
details[data-callout="warning" i] {
218
+
@apply border-yellow-400 dark:border-yellow-500;
219
+
}
220
+
details[data-callout="warning" i] > summary {
221
+
@apply text-yellow-700 dark:text-yellow-400;
222
+
}
223
+
224
+
/* Caution (red) */
225
+
details[data-callout="caution" i] {
226
+
@apply border-red-400 dark:border-red-500;
227
+
}
228
+
details[data-callout="caution" i] > summary {
229
+
@apply text-red-700 dark:text-red-400;
230
+
}
231
+
232
+
/* Tip (green) */
233
+
details[data-callout="tip" i] {
234
+
@apply border-green-400 dark:border-green-500;
235
+
}
236
+
details[data-callout="tip" i] > summary {
237
+
@apply text-green-700 dark:text-green-400;
238
+
}
239
+
240
+
/* Optional: hide the disclosure arrow like GitHub */
241
+
details[data-callout] > summary::-webkit-details-marker {
242
+
display: none;
243
+
}
185
244
}
186
245
@layer utilities {
187
246
.error {
···
228
287
}
229
288
/* LineHighlight */
230
289
.chroma .hl {
231
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
290
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
232
291
}
233
292
234
293
/* LineNumbersTable */
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
···
114
114
115
115
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
116
116
117
-
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
117
+
client, err := client.NewClient(j.cfg, logger, sched)
118
118
if err != nil {
119
119
return fmt.Errorf("failed to create jetstream client: %w", err)
120
120
}
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
···
41
41
Repo Repo `env:",prefix=KNOT_REPO_"`
42
42
Server Server `env:",prefix=KNOT_SERVER_"`
43
43
Git Git `env:",prefix=KNOT_GIT_"`
44
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
44
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
45
45
}
46
46
47
47
func Load(ctx context.Context) (*Config, error) {
+2
-3
knotserver/events.go
+2
-3
knotserver/events.go
···
8
8
"time"
9
9
10
10
"github.com/gorilla/websocket"
11
+
"tangled.org/core/log"
11
12
)
12
13
13
14
var upgrader = websocket.Upgrader{
···
16
17
}
17
18
18
19
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
19
-
l := h.l.With("handler", "OpLog")
20
+
l := log.SubLogger(h.l, "eventstream")
20
21
l.Debug("received new connection")
21
22
22
23
conn, err := upgrader.Upgrade(w, r, nil)
···
75
76
}
76
77
case <-time.After(30 * time.Second):
77
78
// send a keep-alive
78
-
l.Debug("sent keepalive")
79
79
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
80
80
l.Error("failed to write control", "err", err)
81
81
}
···
89
89
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
90
90
return err
91
91
}
92
-
h.l.Debug("ops", "ops", events)
93
92
94
93
for _, event := range events {
95
94
// first extract the inner json into a map
+5
knotserver/git/branch.go
+5
knotserver/git/branch.go
+11
knotserver/git/git.go
+11
knotserver/git/git.go
···
71
71
return &g, nil
72
72
}
73
73
74
+
// re-open a repository and update references
75
+
func (g *GitRepo) Refresh() error {
76
+
refreshed, err := PlainOpen(g.path)
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
*g = *refreshed
82
+
return nil
83
+
}
84
+
74
85
func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
75
86
commits := []*object.Commit{}
76
87
+21
-2
knotserver/git/last_commit.go
+21
-2
knotserver/git/last_commit.go
···
30
30
commitCache = cache
31
31
}
32
32
33
-
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) {
33
+
// processReader wraps a reader and ensures the associated process is cleaned up
34
+
type processReader struct {
35
+
io.Reader
36
+
cmd *exec.Cmd
37
+
stdout io.ReadCloser
38
+
}
39
+
40
+
func (pr *processReader) Close() error {
41
+
if err := pr.stdout.Close(); err != nil {
42
+
return err
43
+
}
44
+
return pr.cmd.Wait()
45
+
}
46
+
47
+
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) {
34
48
args := []string{}
35
49
args = append(args, "log")
36
50
args = append(args, g.h.String())
···
48
62
return nil, err
49
63
}
50
64
51
-
return stdout, nil
65
+
return &processReader{
66
+
Reader: stdout,
67
+
cmd: cmd,
68
+
stdout: stdout,
69
+
}, nil
52
70
}
53
71
54
72
type commit struct {
···
104
122
if err != nil {
105
123
return nil, err
106
124
}
125
+
defer output.Close() // Ensure the git process is properly cleaned up
107
126
108
127
reader := bufio.NewReader(output)
109
128
var current commit
+150
-37
knotserver/git/merge.go
+150
-37
knotserver/git/merge.go
···
4
4
"bytes"
5
5
"crypto/sha256"
6
6
"fmt"
7
+
"log"
7
8
"os"
8
9
"os/exec"
9
10
"regexp"
···
12
13
"github.com/dgraph-io/ristretto"
13
14
"github.com/go-git/go-git/v5"
14
15
"github.com/go-git/go-git/v5/plumbing"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
15
18
)
16
19
17
20
type MergeCheckCache struct {
···
32
35
mergeCheckCache = MergeCheckCache{cache}
33
36
}
34
37
35
-
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
38
+
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
36
39
sep := byte(':')
37
40
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
38
41
return fmt.Sprintf("%x", hash)
···
49
52
}
50
53
}
51
54
52
-
func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
55
+
func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
53
56
key := m.cacheKey(g, patch, targetBranch)
54
57
val := m.cacheVal(mergeCheck)
55
58
m.cache.Set(key, val, 0)
56
59
}
57
60
58
-
func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
61
+
func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
59
62
key := m.cacheKey(g, patch, targetBranch)
60
63
if val, ok := m.cache.Get(key); ok {
61
64
if val == struct{}{} {
···
104
107
return fmt.Sprintf("merge failed: %s", e.Message)
105
108
}
106
109
107
-
func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
110
+
func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) {
108
111
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
109
112
if err != nil {
110
113
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
111
114
}
112
115
113
-
if _, err := tmpFile.Write(patchData); err != nil {
116
+
if _, err := tmpFile.Write([]byte(patchData)); err != nil {
114
117
tmpFile.Close()
115
118
os.Remove(tmpFile.Name())
116
119
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
···
162
165
return nil
163
166
}
164
167
165
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
168
+
func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
166
169
var stderr bytes.Buffer
167
170
var cmd *exec.Cmd
168
171
169
172
// configure default git user before merge
170
-
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171
-
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
173
+
exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
174
+
exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
175
+
exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
173
176
174
177
// if patch is a format-patch, apply using 'git am'
175
178
if opts.FormatPatch {
176
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177
-
} else {
178
-
// else, apply using 'git apply' and commit it manually
179
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180
-
applyCmd.Stderr = &stderr
181
-
if err := applyCmd.Run(); err != nil {
182
-
return fmt.Errorf("patch application failed: %s", stderr.String())
183
-
}
179
+
return g.applyMailbox(patchData)
180
+
}
184
181
185
-
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
186
-
if err := stageCmd.Run(); err != nil {
187
-
return fmt.Errorf("failed to stage changes: %w", err)
188
-
}
182
+
// else, apply using 'git apply' and commit it manually
183
+
applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
184
+
applyCmd.Stderr = &stderr
185
+
if err := applyCmd.Run(); err != nil {
186
+
return fmt.Errorf("patch application failed: %s", stderr.String())
187
+
}
189
188
190
-
commitArgs := []string{"-C", tmpDir, "commit"}
189
+
stageCmd := exec.Command("git", "-C", g.path, "add", ".")
190
+
if err := stageCmd.Run(); err != nil {
191
+
return fmt.Errorf("failed to stage changes: %w", err)
192
+
}
191
193
192
-
// Set author if provided
193
-
authorName := opts.AuthorName
194
-
authorEmail := opts.AuthorEmail
194
+
commitArgs := []string{"-C", g.path, "commit"}
195
195
196
-
if authorName != "" && authorEmail != "" {
197
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198
-
}
199
-
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
196
+
// Set author if provided
197
+
authorName := opts.AuthorName
198
+
authorEmail := opts.AuthorEmail
200
199
201
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
200
+
if authorName != "" && authorEmail != "" {
201
+
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
202
+
}
203
+
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
202
204
203
-
if opts.CommitBody != "" {
204
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
-
}
205
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
206
206
207
-
cmd = exec.Command("git", commitArgs...)
207
+
if opts.CommitBody != "" {
208
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
208
209
}
210
+
211
+
cmd = exec.Command("git", commitArgs...)
209
212
210
213
cmd.Stderr = &stderr
211
214
···
216
219
return nil
217
220
}
218
221
219
-
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
222
+
func (g *GitRepo) applyMailbox(patchData string) error {
223
+
fps, err := patchutil.ExtractPatches(patchData)
224
+
if err != nil {
225
+
return fmt.Errorf("failed to extract patches: %w", err)
226
+
}
227
+
228
+
// apply each patch one by one
229
+
// update the newly created commit object to add the change-id header
230
+
total := len(fps)
231
+
for i, p := range fps {
232
+
newCommit, err := g.applySingleMailbox(p)
233
+
if err != nil {
234
+
return err
235
+
}
236
+
237
+
log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
238
+
}
239
+
240
+
return nil
241
+
}
242
+
243
+
func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
244
+
tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw)
245
+
if err != nil {
246
+
return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err)
247
+
}
248
+
249
+
var stderr bytes.Buffer
250
+
cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
251
+
cmd.Stderr = &stderr
252
+
253
+
head, err := g.r.Head()
254
+
if err != nil {
255
+
return plumbing.ZeroHash, err
256
+
}
257
+
log.Println("head before apply", head.Hash().String())
258
+
259
+
if err := cmd.Run(); err != nil {
260
+
return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String())
261
+
}
262
+
263
+
if err := g.Refresh(); err != nil {
264
+
return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
265
+
}
266
+
267
+
head, err = g.r.Head()
268
+
if err != nil {
269
+
return plumbing.ZeroHash, err
270
+
}
271
+
log.Println("head after apply", head.Hash().String())
272
+
273
+
newHash := head.Hash()
274
+
if changeId, err := singlePatch.ChangeId(); err != nil {
275
+
// no change ID
276
+
} else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
277
+
return plumbing.ZeroHash, err
278
+
} else {
279
+
newHash = updatedHash
280
+
}
281
+
282
+
return newHash, nil
283
+
}
284
+
285
+
func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
286
+
log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
287
+
obj, err := g.r.CommitObject(hash)
288
+
if err != nil {
289
+
return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
290
+
}
291
+
292
+
// write the change-id header
293
+
obj.ExtraHeaders["change-id"] = []byte(changeId)
294
+
295
+
// create a new object
296
+
dest := g.r.Storer.NewEncodedObject()
297
+
if err := obj.Encode(dest); err != nil {
298
+
return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
299
+
}
300
+
301
+
// store the new object
302
+
newHash, err := g.r.Storer.SetEncodedObject(dest)
303
+
if err != nil {
304
+
return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
305
+
}
306
+
307
+
log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
308
+
309
+
// find the branch that HEAD is pointing to
310
+
ref, err := g.r.Head()
311
+
if err != nil {
312
+
return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
313
+
}
314
+
315
+
// and update that branch to point to new commit
316
+
if ref.Name().IsBranch() {
317
+
err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
318
+
if err != nil {
319
+
return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
320
+
}
321
+
}
322
+
323
+
// new hash of commit
324
+
return newHash, nil
325
+
}
326
+
327
+
func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error {
220
328
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
221
329
return val
222
330
}
···
244
352
return result
245
353
}
246
354
247
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
355
+
func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
248
356
patchFile, err := g.createTempFileWithPatch(patchData)
249
357
if err != nil {
250
358
return &ErrMerge{
···
263
371
}
264
372
defer os.RemoveAll(tmpDir)
265
373
266
-
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
374
+
tmpRepo, err := PlainOpen(tmpDir)
375
+
if err != nil {
376
+
return err
377
+
}
378
+
379
+
if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
267
380
return err
268
381
}
269
382
+18
-18
knotserver/git.go
+18
-18
knotserver/git.go
···
13
13
"tangled.org/core/knotserver/git/service"
14
14
)
15
15
16
-
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
16
+
func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
17
17
did := chi.URLParam(r, "did")
18
18
name := chi.URLParam(r, "name")
19
19
repoName, err := securejoin.SecureJoin(did, name)
20
20
if err != nil {
21
21
gitError(w, "repository not found", http.StatusNotFound)
22
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
22
+
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
23
23
return
24
24
}
25
25
26
-
repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName)
26
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName)
27
27
if err != nil {
28
28
gitError(w, "repository not found", http.StatusNotFound)
29
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
29
+
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
30
30
return
31
31
}
32
32
···
46
46
47
47
if err := cmd.InfoRefs(); err != nil {
48
48
gitError(w, err.Error(), http.StatusInternalServerError)
49
-
d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
49
+
h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
50
50
return
51
51
}
52
52
case "git-receive-pack":
53
-
d.RejectPush(w, r, name)
53
+
h.RejectPush(w, r, name)
54
54
default:
55
55
gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
56
56
}
57
57
}
58
58
59
-
func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
59
+
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
60
did := chi.URLParam(r, "did")
61
61
name := chi.URLParam(r, "name")
62
-
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
63
if err != nil {
64
64
gitError(w, err.Error(), http.StatusInternalServerError)
65
-
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
66
return
67
67
}
68
68
···
77
77
gzipReader, err := gzip.NewReader(r.Body)
78
78
if err != nil {
79
79
gitError(w, err.Error(), http.StatusInternalServerError)
80
-
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
81
81
return
82
82
}
83
83
defer gzipReader.Close()
···
88
88
w.Header().Set("Connection", "Keep-Alive")
89
89
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
90
90
91
-
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
91
+
h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
92
92
93
93
cmd := service.ServiceCommand{
94
94
GitProtocol: r.Header.Get("Git-Protocol"),
···
100
100
w.WriteHeader(http.StatusOK)
101
101
102
102
if err := cmd.UploadPack(); err != nil {
103
-
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
103
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
104
104
return
105
105
}
106
106
}
107
107
108
-
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
108
+
func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
109
109
did := chi.URLParam(r, "did")
110
110
name := chi.URLParam(r, "name")
111
-
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
111
+
_, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
112
112
if err != nil {
113
113
gitError(w, err.Error(), http.StatusForbidden)
114
-
d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
114
+
h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
115
115
return
116
116
}
117
117
118
-
d.RejectPush(w, r, name)
118
+
h.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
121
+
func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
122
122
// A text/plain response will cause git to print each line of the body
123
123
// prefixed with "remote: ".
124
124
w.Header().Set("content-type", "text/plain; charset=UTF-8")
···
131
131
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
132
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
133
133
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
134
-
hostname := d.c.Server.Hostname
134
+
hostname := h.c.Server.Hostname
135
135
if strings.Contains(hostname, ":") {
136
136
hostname = strings.Split(hostname, ":")[0]
137
137
}
+50
-1
knotserver/internal.go
+50
-1
knotserver/internal.go
···
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
14
"github.com/go-chi/chi/v5"
15
15
"github.com/go-chi/chi/v5/middleware"
16
+
"github.com/go-git/go-git/v5/plumbing"
16
17
"tangled.org/core/api/tangled"
17
18
"tangled.org/core/hook"
19
+
"tangled.org/core/idresolver"
18
20
"tangled.org/core/knotserver/config"
19
21
"tangled.org/core/knotserver/db"
20
22
"tangled.org/core/knotserver/git"
23
+
"tangled.org/core/log"
21
24
"tangled.org/core/notifier"
22
25
"tangled.org/core/rbac"
23
26
"tangled.org/core/workflow"
···
118
121
// non-fatal
119
122
}
120
123
124
+
if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
125
+
msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context())
126
+
if err != nil {
127
+
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
128
+
// non-fatal
129
+
} else {
130
+
for msgLine := range msg {
131
+
resp.Messages = append(resp.Messages, msg[msgLine])
132
+
}
133
+
}
134
+
}
135
+
121
136
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
122
137
if err != nil {
123
138
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
···
128
143
writeJSON(w, resp)
129
144
}
130
145
146
+
func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
147
+
l := h.l.With("handler", "replyCompare")
148
+
userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner)
149
+
user := repoOwner
150
+
if err != nil {
151
+
l.Error("Failed to fetch user identity", "err", err)
152
+
// non-fatal
153
+
} else {
154
+
user = userIdent.Handle.String()
155
+
}
156
+
gr, err := git.PlainOpen(gitRelativeDir)
157
+
if err != nil {
158
+
l.Error("Failed to open git repository", "err", err)
159
+
return []string{}, err
160
+
}
161
+
defaultBranch, err := gr.FindMainBranch()
162
+
if err != nil {
163
+
l.Error("Failed to fetch default branch", "err", err)
164
+
return []string{}, err
165
+
}
166
+
if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
167
+
return []string{}, nil
168
+
}
169
+
ZWS := "\u200B"
170
+
var msg []string
171
+
msg = append(msg, ZWS)
172
+
msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
173
+
msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
174
+
msg = append(msg, ZWS)
175
+
return msg, nil
176
+
}
177
+
131
178
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
132
179
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
133
180
if err != nil {
···
268
315
return h.db.InsertEvent(event, h.n)
269
316
}
270
317
271
-
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
318
+
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
272
319
r := chi.NewRouter()
320
+
l := log.FromContext(ctx)
321
+
l = log.SubLogger(l, "internal")
273
322
274
323
h := InternalHandle{
275
324
db,
+35
knotserver/middleware.go
+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
+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
+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, ¬ifier)
91
+
mux, err := Setup(ctx, c, db, e, jc, ¬ifier)
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, ¬ifier)
96
+
imux := Internal(ctx, c, db, e, ¬ifier)
96
97
97
98
logger.Info("starting internal server", "address", c.Server.InternalListenAddr)
98
99
go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+87
knotserver/xrpc/delete_branch.go
+87
knotserver/xrpc/delete_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
"tangled.org/core/rbac"
15
+
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
+
)
18
+
19
+
func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) {
20
+
l := x.Logger
21
+
fail := func(e xrpcerr.XrpcError) {
22
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
+
writeError(w, e, http.StatusBadRequest)
24
+
}
25
+
26
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
+
if !ok {
28
+
fail(xrpcerr.MissingActorDidError)
29
+
return
30
+
}
31
+
32
+
var data tangled.RepoDeleteBranch_Input
33
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
+
fail(xrpcerr.GenericError(err))
35
+
return
36
+
}
37
+
38
+
// unfortunately we have to resolve repo-at here
39
+
repoAt, err := syntax.ParseATURI(data.Repo)
40
+
if err != nil {
41
+
fail(xrpcerr.InvalidRepoError(data.Repo))
42
+
return
43
+
}
44
+
45
+
// resolve this aturi to extract the repo record
46
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
+
if err != nil || ident.Handle.IsInvalidHandle() {
48
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
+
return
50
+
}
51
+
52
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
+
if err != nil {
55
+
fail(xrpcerr.GenericError(err))
56
+
return
57
+
}
58
+
59
+
repo := resp.Value.Val.(*tangled.Repo)
60
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
61
+
if err != nil {
62
+
fail(xrpcerr.GenericError(err))
63
+
return
64
+
}
65
+
66
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
+
l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath)
68
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
+
return
70
+
}
71
+
72
+
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
+
gr, err := git.PlainOpen(path)
74
+
if err != nil {
75
+
fail(xrpcerr.GenericError(err))
76
+
return
77
+
}
78
+
79
+
err = gr.DeleteBranch(data.Branch)
80
+
if err != nil {
81
+
l.Error("deleting branch", "error", err.Error(), "branch", data.Branch)
82
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
83
+
return
84
+
}
85
+
86
+
w.WriteHeader(http.StatusOK)
87
+
}
+1
-1
knotserver/xrpc/merge.go
+1
-1
knotserver/xrpc/merge.go
···
85
85
mo.CommitterEmail = x.Config.Git.UserEmail
86
86
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
87
87
88
-
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
88
+
err = gr.MergeWithOptions(data.Patch, data.Branch, mo)
89
89
if err != nil {
90
90
var mergeErr *git.ErrMerge
91
91
if errors.As(err, &mergeErr) {
+3
-1
knotserver/xrpc/merge_check.go
+3
-1
knotserver/xrpc/merge_check.go
···
51
51
return
52
52
}
53
53
54
-
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
54
+
err = gr.MergeCheck(data.Patch, data.Branch)
55
55
56
56
response := tangled.RepoMergeCheck_Output{
57
57
Is_conflicted: false,
···
80
80
response.Error = &errMsg
81
81
}
82
82
}
83
+
84
+
l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts)
83
85
84
86
w.Header().Set("Content-Type", "application/json")
85
87
w.WriteHeader(http.StatusOK)
+20
-4
knotserver/xrpc/repo_compare.go
+20
-4
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
7
8
"tangled.org/core/knotserver/git"
8
9
"tangled.org/core/types"
9
10
xrpcerr "tangled.org/core/xrpc/errors"
···
71
72
return
72
73
}
73
74
75
+
var combinedPatch []*gitdiff.File
76
+
var combinedPatchRaw string
77
+
// we need the combined patch
78
+
if len(formatPatch) >= 2 {
79
+
diffTree, err := gr.DiffTree(commit1, commit2)
80
+
if err != nil {
81
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
82
+
} else {
83
+
combinedPatch = diffTree.Diff
84
+
combinedPatchRaw = diffTree.Patch
85
+
}
86
+
}
87
+
74
88
response := types.RepoFormatPatchResponse{
75
-
Rev1: commit1.Hash.String(),
76
-
Rev2: commit2.Hash.String(),
77
-
FormatPatch: formatPatch,
78
-
Patch: rawPatch,
89
+
Rev1: commit1.Hash.String(),
90
+
Rev2: commit2.Hash.String(),
91
+
FormatPatch: formatPatch,
92
+
FormatPatchRaw: rawPatch,
93
+
CombinedPatch: combinedPatch,
94
+
CombinedPatchRaw: combinedPatchRaw,
79
95
}
80
96
81
97
writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
+1
knotserver/xrpc/xrpc.go
···
38
38
r.Use(x.ServiceAuth.VerifyServiceAuth)
39
39
40
40
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
41
+
r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch)
41
42
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
42
43
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
43
44
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+30
lexicons/repo/deleteBranch.json
+30
lexicons/repo/deleteBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.deleteBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Delete a branch on this repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"branch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"branch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
+23
-9
log/log.go
+23
-9
log/log.go
···
4
4
"context"
5
5
"log/slog"
6
6
"os"
7
+
8
+
"github.com/charmbracelet/log"
7
9
)
8
10
9
-
// NewHandler sets up a new slog.Handler with the service name
10
-
// as an attribute
11
11
func NewHandler(name string) slog.Handler {
12
-
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
13
-
Level: slog.LevelDebug,
12
+
return log.NewWithOptions(os.Stderr, log.Options{
13
+
ReportTimestamp: true,
14
+
Prefix: name,
15
+
Level: log.DebugLevel,
14
16
})
15
-
16
-
var attrs []slog.Attr
17
-
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
18
-
handler.WithAttrs(attrs)
19
-
return handler
20
17
}
21
18
22
19
func New(name string) *slog.Logger {
···
49
46
50
47
return slog.Default()
51
48
}
49
+
50
+
// sublogger derives a new logger from an existing one by appending a suffix to its prefix.
51
+
func SubLogger(base *slog.Logger, suffix string) *slog.Logger {
52
+
// try to get the underlying charmbracelet logger
53
+
if cl, ok := base.Handler().(*log.Logger); ok {
54
+
prefix := cl.GetPrefix()
55
+
if prefix != "" {
56
+
prefix = prefix + "/" + suffix
57
+
} else {
58
+
prefix = suffix
59
+
}
60
+
return slog.New(NewHandler(prefix))
61
+
}
62
+
63
+
// Fallback: no known handler type
64
+
return slog.New(NewHandler(suffix))
65
+
}
+62
-11
nix/gomod2nix.toml
+62
-11
nix/gomod2nix.toml
···
29
29
[mod."github.com/avast/retry-go/v4"]
30
30
version = "v4.6.1"
31
31
hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k="
32
+
[mod."github.com/aymanbagabas/go-osc52/v2"]
33
+
version = "v2.0.1"
34
+
hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg="
32
35
[mod."github.com/aymerick/douceur"]
33
36
version = "v0.2.0"
34
37
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
···
40
43
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
44
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
45
[mod."github.com/bluesky-social/indigo"]
43
-
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
-
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
46
+
version = "v0.0.0-20251003000214-3259b215110e"
47
+
hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo="
45
48
[mod."github.com/bluesky-social/jetstream"]
46
49
version = "v0.0.0-20241210005130-ea96859b93d1"
47
50
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
63
66
[mod."github.com/cespare/xxhash/v2"]
64
67
version = "v2.3.0"
65
68
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
69
+
[mod."github.com/charmbracelet/colorprofile"]
70
+
version = "v0.2.3-0.20250311203215-f60798e515dc"
71
+
hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q="
72
+
[mod."github.com/charmbracelet/lipgloss"]
73
+
version = "v1.1.0"
74
+
hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4="
75
+
[mod."github.com/charmbracelet/log"]
76
+
version = "v0.4.2"
77
+
hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I="
78
+
[mod."github.com/charmbracelet/x/ansi"]
79
+
version = "v0.8.0"
80
+
hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas="
81
+
[mod."github.com/charmbracelet/x/cellbuf"]
82
+
version = "v0.0.13-0.20250311204145-2c3ea96c31dd"
83
+
hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU="
84
+
[mod."github.com/charmbracelet/x/term"]
85
+
version = "v0.2.1"
86
+
hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM="
66
87
[mod."github.com/cloudflare/circl"]
67
88
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
89
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
···
145
166
[mod."github.com/go-jose/go-jose/v3"]
146
167
version = "v3.0.4"
147
168
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
169
+
[mod."github.com/go-logfmt/logfmt"]
170
+
version = "v0.6.0"
171
+
hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg="
148
172
[mod."github.com/go-logr/logr"]
149
173
version = "v1.4.3"
150
174
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
···
163
187
[mod."github.com/gogo/protobuf"]
164
188
version = "v1.3.2"
165
189
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
190
+
[mod."github.com/goki/freetype"]
191
+
version = "v1.0.5"
192
+
hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs="
166
193
[mod."github.com/golang-jwt/jwt/v5"]
167
194
version = "v5.2.3"
168
195
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
295
322
[mod."github.com/lestrrat-go/option"]
296
323
version = "v1.0.1"
297
324
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
325
+
[mod."github.com/lucasb-eyer/go-colorful"]
326
+
version = "v1.2.0"
327
+
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
298
328
[mod."github.com/mattn/go-isatty"]
299
329
version = "v0.0.20"
300
330
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
331
+
[mod."github.com/mattn/go-runewidth"]
332
+
version = "v0.0.16"
333
+
hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ="
301
334
[mod."github.com/mattn/go-sqlite3"]
302
335
version = "v1.14.24"
303
336
hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg="
···
325
358
[mod."github.com/mr-tron/base58"]
326
359
version = "v1.2.0"
327
360
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
361
+
[mod."github.com/muesli/termenv"]
362
+
version = "v0.16.0"
363
+
hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI="
328
364
[mod."github.com/multiformats/go-base32"]
329
365
version = "v0.1.0"
330
366
hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio="
···
391
427
[mod."github.com/resend/resend-go/v2"]
392
428
version = "v2.15.0"
393
429
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
430
+
[mod."github.com/rivo/uniseg"]
431
+
version = "v0.4.7"
432
+
hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo="
394
433
[mod."github.com/ryanuber/go-glob"]
395
434
version = "v1.0.0"
396
435
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
···
407
446
[mod."github.com/spaolacci/murmur3"]
408
447
version = "v1.1.0"
409
448
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
449
+
[mod."github.com/srwiley/oksvg"]
450
+
version = "v0.0.0-20221011165216-be6e8873101c"
451
+
hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk="
452
+
[mod."github.com/srwiley/rasterx"]
453
+
version = "v0.0.0-20220730225603-2ab79fcdd4ef"
454
+
hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68="
410
455
[mod."github.com/stretchr/testify"]
411
456
version = "v1.10.0"
412
457
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
431
476
[mod."github.com/wyatt915/treeblood"]
432
477
version = "v0.1.15"
433
478
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
479
+
[mod."github.com/xo/terminfo"]
480
+
version = "v0.0.0-20220910002029-abceb7e1c41e"
481
+
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
434
482
[mod."github.com/yuin/goldmark"]
435
-
version = "v1.7.12"
436
-
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
483
+
version = "v1.7.13"
484
+
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
437
485
[mod."github.com/yuin/goldmark-highlighting/v2"]
438
486
version = "v2.0.0-20230729083705-37449abec8cc"
439
487
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
488
+
[mod."gitlab.com/staticnoise/goldmark-callout"]
489
+
version = "v0.0.0-20240609120641-6366b799e4ab"
490
+
hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44="
440
491
[mod."gitlab.com/yawning/secp256k1-voi"]
441
492
version = "v0.0.0-20230925100816-f2616030848b"
442
493
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
···
479
530
[mod."golang.org/x/exp"]
480
531
version = "v0.0.0-20250620022241-b7579e27df2b"
481
532
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
533
+
[mod."golang.org/x/image"]
534
+
version = "v0.31.0"
535
+
hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg="
482
536
[mod."golang.org/x/net"]
483
537
version = "v0.42.0"
484
538
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
485
539
[mod."golang.org/x/sync"]
486
-
version = "v0.16.0"
487
-
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
540
+
version = "v0.17.0"
541
+
hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0="
488
542
[mod."golang.org/x/sys"]
489
543
version = "v0.34.0"
490
544
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
491
545
[mod."golang.org/x/text"]
492
-
version = "v0.27.0"
493
-
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
546
+
version = "v0.29.0"
547
+
hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI="
494
548
[mod."golang.org/x/time"]
495
549
version = "v0.12.0"
496
550
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
527
581
[mod."lukechampine.com/blake3"]
528
582
version = "v1.4.1"
529
583
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
530
-
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
531
-
version = "v0.0.0-20250724194903-28e660378cb1"
532
-
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+2
-2
nix/modules/knot.nix
+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
+2
-2
nix/modules/spindle.nix
···
33
33
34
34
hostname = mkOption {
35
35
type = types.str;
36
-
example = "spindle.tangled.sh";
36
+
example = "my.spindle.com";
37
37
description = "Hostname for the server (required)";
38
38
};
39
39
···
92
92
pipelines = {
93
93
nixery = mkOption {
94
94
type = types.str;
95
-
default = "nixery.tangled.sh";
95
+
default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
96
96
description = "Nixery instance to use";
97
97
};
98
98
+1
nix/pkgs/appview-static-files.nix
+1
nix/pkgs/appview-static-files.nix
···
22
22
cp -rf ${lucide-src}/*.svg icons/
23
23
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
+
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
25
26
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
26
27
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
28
# for whatever reason (produces broken css), so we are doing this instead
+18
-7
patchutil/patchutil.go
+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
+13
-12
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"reflect"
5
6
"testing"
6
7
)
···
9
10
tests := []struct {
10
11
name string
11
12
patch string
12
-
expected bool
13
+
expected error
13
14
}{
14
15
{
15
16
name: `empty patch`,
16
17
patch: ``,
17
-
expected: false,
18
+
expected: EmptyPatchError,
18
19
},
19
20
{
20
21
name: `single line patch`,
21
22
patch: `single line`,
22
-
expected: false,
23
+
expected: EmptyPatchError,
23
24
},
24
25
{
25
26
name: `valid diff patch`,
···
31
32
-old line
32
33
+new line
33
34
context`,
34
-
expected: true,
35
+
expected: nil,
35
36
},
36
37
{
37
38
name: `valid patch starting with ---`,
···
41
42
-old line
42
43
+new line
43
44
context`,
44
-
expected: true,
45
+
expected: nil,
45
46
},
46
47
{
47
48
name: `valid patch starting with Index`,
···
53
54
-old line
54
55
+new line
55
56
context`,
56
-
expected: true,
57
+
expected: nil,
57
58
},
58
59
{
59
60
name: `valid patch starting with +++`,
···
63
64
-old line
64
65
+new line
65
66
context`,
66
-
expected: true,
67
+
expected: nil,
67
68
},
68
69
{
69
70
name: `valid patch starting with @@`,
···
72
73
+new line
73
74
context
74
75
`,
75
-
expected: true,
76
+
expected: nil,
76
77
},
77
78
{
78
79
name: `valid format patch`,
···
90
91
+new content
91
92
--
92
93
2.48.1`,
93
-
expected: true,
94
+
expected: nil,
94
95
},
95
96
{
96
97
name: `invalid format patch`,
97
98
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
99
From: Author <author@example.com>
99
100
This is not a valid patch format`,
100
-
expected: false,
101
+
expected: FormatPatchError,
101
102
},
102
103
{
103
104
name: `not a patch at all`,
···
105
106
just some
106
107
random text
107
108
that isn't a patch`,
108
-
expected: false,
109
+
expected: GenericPatchError,
109
110
},
110
111
}
111
112
112
113
for _, tt := range tests {
113
114
t.Run(tt.name, func(t *testing.T) {
114
115
result := IsPatchValid(tt.patch)
115
-
if result != tt.expected {
116
+
if !errors.Is(result, tt.expected) {
116
117
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
118
}
118
119
})
+1
-1
spindle/engines/nixery/engine.go
+1
-1
spindle/engines/nixery/engine.go
···
222
222
},
223
223
ReadonlyRootfs: false,
224
224
CapDrop: []string{"ALL"},
225
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
226
226
SecurityOpt: []string{"no-new-privileges"},
227
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
228
}, nil, nil, "")
+35
spindle/middleware.go
+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
+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
+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
+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
+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),