+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
+
}
+140
appview/db/db.go
+140
appview/db/db.go
···
954
return err
955
})
956
957
+
// add generated at_uri column to pulls table
958
+
//
959
+
// this requires a full table recreation because stored columns
960
+
// cannot be added via alter
961
+
//
962
+
// disable foreign-keys for the next migration
963
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
+
_, err := tx.Exec(`
966
+
create table if not exists pulls_new (
967
+
-- identifiers
968
+
id integer primary key autoincrement,
969
+
pull_id integer not null,
970
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
971
+
972
+
-- at identifiers
973
+
repo_at text not null,
974
+
owner_did text not null,
975
+
rkey text not null,
976
+
977
+
-- content
978
+
title text not null,
979
+
body text not null,
980
+
target_branch text not null,
981
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
982
+
983
+
-- source info
984
+
source_branch text,
985
+
source_repo_at text,
986
+
987
+
-- stacking
988
+
stack_id text,
989
+
change_id text,
990
+
parent_change_id text,
991
+
992
+
-- meta
993
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
994
+
995
+
-- constraints
996
+
unique(repo_at, pull_id),
997
+
unique(at_uri),
998
+
foreign key (repo_at) references repos(at_uri) on delete cascade
999
+
);
1000
+
`)
1001
+
if err != nil {
1002
+
return err
1003
+
}
1004
+
1005
+
// transfer data
1006
+
_, err = tx.Exec(`
1007
+
insert into pulls_new (
1008
+
id, pull_id, repo_at, owner_did, rkey,
1009
+
title, body, target_branch, state,
1010
+
source_branch, source_repo_at,
1011
+
stack_id, change_id, parent_change_id,
1012
+
created
1013
+
)
1014
+
select
1015
+
id, pull_id, repo_at, owner_did, rkey,
1016
+
title, body, target_branch, state,
1017
+
source_branch, source_repo_at,
1018
+
stack_id, change_id, parent_change_id,
1019
+
created
1020
+
from pulls;
1021
+
`)
1022
+
if err != nil {
1023
+
return err
1024
+
}
1025
+
1026
+
// drop old table
1027
+
_, err = tx.Exec(`drop table pulls`)
1028
+
if err != nil {
1029
+
return err
1030
+
}
1031
+
1032
+
// rename new table
1033
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1034
+
return err
1035
+
})
1036
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1037
+
1038
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1039
+
//
1040
+
// this requires a full table recreation because stored columns
1041
+
// cannot be added via alter
1042
+
//
1043
+
// disable foreign-keys for the next migration
1044
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
+
_, err := tx.Exec(`
1047
+
create table if not exists pull_submissions_new (
1048
+
-- identifiers
1049
+
id integer primary key autoincrement,
1050
+
pull_at text not null,
1051
+
1052
+
-- content, these are immutable, and require a resubmission to update
1053
+
round_number integer not null default 0,
1054
+
patch text,
1055
+
source_rev text,
1056
+
1057
+
-- meta
1058
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1059
+
1060
+
-- constraints
1061
+
unique(pull_at, round_number),
1062
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1063
+
);
1064
+
`)
1065
+
if err != nil {
1066
+
return err
1067
+
}
1068
+
1069
+
// transfer data, constructing pull_at from pulls table
1070
+
_, err = tx.Exec(`
1071
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1072
+
select
1073
+
ps.id,
1074
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1075
+
ps.round_number,
1076
+
ps.patch,
1077
+
ps.created
1078
+
from pull_submissions ps
1079
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1080
+
`)
1081
+
if err != nil {
1082
+
return err
1083
+
}
1084
+
1085
+
// drop old table
1086
+
_, err = tx.Exec(`drop table pull_submissions`)
1087
+
if err != nil {
1088
+
return err
1089
+
}
1090
+
1091
+
// rename new table
1092
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1093
+
return err
1094
+
})
1095
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
+
1097
return &DB{db}, nil
1098
}
1099
+13
-9
appview/db/email.go
+13
-9
appview/db/email.go
···
71
return did, nil
72
}
73
74
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
75
-
if len(ems) == 0 {
76
return make(map[string]string), nil
77
}
78
···
80
if isVerifiedFilter {
81
verifiedFilter = 1
82
}
83
84
// Create placeholders for the IN clause
85
-
placeholders := make([]string, len(ems))
86
-
args := make([]any, len(ems)+1)
87
88
args[0] = verifiedFilter
89
-
for i, em := range ems {
90
-
placeholders[i] = "?"
91
-
args[i+1] = em
92
}
93
94
query := `
···
104
return nil, err
105
}
106
defer rows.Close()
107
-
108
-
assoc := make(map[string]string)
109
110
for rows.Next() {
111
var email, did string
···
71
return did, nil
72
}
73
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
76
return make(map[string]string), nil
77
}
78
···
80
if isVerifiedFilter {
81
verifiedFilter = 1
82
}
83
+
84
+
assoc := make(map[string]string)
85
86
// Create placeholders for the IN clause
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
89
90
args[0] = verifiedFilter
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
98
}
99
100
query := `
···
110
return nil, err
111
}
112
defer rows.Close()
113
114
for rows.Next() {
115
var email, did string
+34
appview/db/language.go
+34
appview/db/language.go
···
1
package db
2
3
import (
4
+
"database/sql"
5
"fmt"
6
"strings"
7
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
"tangled.org/core/appview/models"
10
)
11
···
84
85
return nil
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+18
-25
appview/db/notifications.go
+18
-25
appview/db/notifications.go
···
3
import (
4
"context"
5
"database/sql"
6
"fmt"
7
"time"
8
9
"tangled.org/core/appview/models"
···
248
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
249
}
250
251
-
// GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility)
252
-
func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) {
253
-
page := pagination.Page{Limit: limit, Offset: offset}
254
-
return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID))
255
-
}
256
257
-
// GetNotificationsWithEntities retrieves notifications with entities for a user with pagination
258
-
func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) {
259
-
page := pagination.Page{Limit: limit, Offset: offset}
260
-
return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID))
261
-
}
262
263
-
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
264
-
recipientFilter := FilterEq("recipient_did", userDID)
265
-
readFilter := FilterEq("read", 0)
266
-
267
-
query := fmt.Sprintf(`
268
-
SELECT COUNT(*)
269
-
FROM notifications
270
-
WHERE %s AND %s
271
-
`, recipientFilter.Condition(), readFilter.Condition())
272
-
273
-
args := append(recipientFilter.Arg(), readFilter.Arg()...)
274
275
-
var count int
276
-
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
277
-
if err != nil {
278
-
return 0, fmt.Errorf("failed to get unread count: %w", err)
279
}
280
281
return count, nil
···
3
import (
4
"context"
5
"database/sql"
6
+
"errors"
7
"fmt"
8
+
"strings"
9
"time"
10
11
"tangled.org/core/appview/models"
···
250
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
251
}
252
253
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
254
+
var conditions []string
255
+
var args []any
256
+
for _, filter := range filters {
257
+
conditions = append(conditions, filter.Condition())
258
+
args = append(args, filter.Arg()...)
259
+
}
260
261
+
whereClause := ""
262
+
if conditions != nil {
263
+
whereClause = " where " + strings.Join(conditions, " and ")
264
+
}
265
266
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
267
+
var count int64
268
+
err := e.QueryRow(query, args...).Scan(&count)
269
270
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
271
+
return 0, err
272
}
273
274
return count, nil
+145
-229
appview/db/pulls.go
+145
-229
appview/db/pulls.go
···
1
package db
2
3
import (
4
"database/sql"
5
"fmt"
6
-
"log"
7
"sort"
8
"strings"
9
"time"
···
87
pull.ID = int(id)
88
89
_, err = tx.Exec(`
90
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
91
-
values (?, ?, ?, ?, ?)
92
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
93
return err
94
}
95
···
108
}
109
110
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
111
-
pulls := make(map[int]*models.Pull)
112
113
var conditions []string
114
var args []any
···
211
pull.ParentChangeId = parentChangeId.String
212
}
213
214
-
pulls[pull.PullId] = &pull
215
}
216
217
-
// get latest round no. for each pull
218
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
219
-
submissionsQuery := fmt.Sprintf(`
220
-
select
221
-
id, pull_id, round_number, patch, created, source_rev
222
-
from
223
-
pull_submissions
224
-
where
225
-
repo_at in (%s) and pull_id in (%s)
226
-
`, inClause, inClause)
227
-
228
-
args = make([]any, len(pulls)*2)
229
-
idx := 0
230
for _, p := range pulls {
231
-
args[idx] = p.RepoAt
232
-
idx += 1
233
}
234
-
for _, p := range pulls {
235
-
args[idx] = p.PullId
236
-
idx += 1
237
-
}
238
-
submissionsRows, err := e.Query(submissionsQuery, args...)
239
if err != nil {
240
-
return nil, err
241
}
242
-
defer submissionsRows.Close()
243
244
-
for submissionsRows.Next() {
245
-
var s models.PullSubmission
246
-
var sourceRev sql.NullString
247
-
var createdAt string
248
-
err := submissionsRows.Scan(
249
-
&s.ID,
250
-
&s.PullId,
251
-
&s.RoundNumber,
252
-
&s.Patch,
253
-
&createdAt,
254
-
&sourceRev,
255
-
)
256
-
if err != nil {
257
-
return nil, err
258
}
259
260
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
261
-
if err != nil {
262
-
return nil, err
263
}
264
-
s.Created = createdTime
265
266
-
if sourceRev.Valid {
267
-
s.SourceRev = sourceRev.String
268
}
269
-
270
-
if p, ok := pulls[s.PullId]; ok {
271
-
p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1)
272
-
p.Submissions[s.RoundNumber] = &s
273
-
}
274
}
275
-
if err := rows.Err(); err != nil {
276
-
return nil, err
277
}
278
-
279
-
// get comment count on latest submission on each pull
280
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
281
-
commentsQuery := fmt.Sprintf(`
282
-
select
283
-
count(id), pull_id
284
-
from
285
-
pull_comments
286
-
where
287
-
submission_id in (%s)
288
-
group by
289
-
submission_id
290
-
`, inClause)
291
-
292
-
args = []any{}
293
for _, p := range pulls {
294
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
295
-
}
296
-
commentsRows, err := e.Query(commentsQuery, args...)
297
-
if err != nil {
298
-
return nil, err
299
-
}
300
-
defer commentsRows.Close()
301
-
302
-
for commentsRows.Next() {
303
-
var commentCount, pullId int
304
-
err := commentsRows.Scan(
305
-
&commentCount,
306
-
&pullId,
307
-
)
308
-
if err != nil {
309
-
return nil, err
310
}
311
-
if p, ok := pulls[pullId]; ok {
312
-
p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount)
313
-
}
314
-
}
315
-
if err := rows.Err(); err != nil {
316
-
return nil, err
317
}
318
319
orderedByPullId := []*models.Pull{}
···
332
}
333
334
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
335
-
query := `
336
-
select
337
-
id,
338
-
owner_did,
339
-
pull_id,
340
-
created,
341
-
title,
342
-
state,
343
-
target_branch,
344
-
repo_at,
345
-
body,
346
-
rkey,
347
-
source_branch,
348
-
source_repo_at,
349
-
stack_id,
350
-
change_id,
351
-
parent_change_id
352
-
from
353
-
pulls
354
-
where
355
-
repo_at = ? and pull_id = ?
356
-
`
357
-
row := e.QueryRow(query, repoAt, pullId)
358
-
359
-
var pull models.Pull
360
-
var createdAt string
361
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
362
-
err := row.Scan(
363
-
&pull.ID,
364
-
&pull.OwnerDid,
365
-
&pull.PullId,
366
-
&createdAt,
367
-
&pull.Title,
368
-
&pull.State,
369
-
&pull.TargetBranch,
370
-
&pull.RepoAt,
371
-
&pull.Body,
372
-
&pull.Rkey,
373
-
&sourceBranch,
374
-
&sourceRepoAt,
375
-
&stackId,
376
-
&changeId,
377
-
&parentChangeId,
378
-
)
379
if err != nil {
380
return nil, err
381
}
382
-
383
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
384
-
if err != nil {
385
-
return nil, err
386
}
387
-
pull.Created = createdTime
388
389
-
// populate source
390
-
if sourceBranch.Valid {
391
-
pull.PullSource = &models.PullSource{
392
-
Branch: sourceBranch.String,
393
-
}
394
-
if sourceRepoAt.Valid {
395
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
396
-
if err != nil {
397
-
return nil, err
398
-
}
399
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
400
-
}
401
}
402
403
-
if stackId.Valid {
404
-
pull.StackId = stackId.String
405
-
}
406
-
if changeId.Valid {
407
-
pull.ChangeId = changeId.String
408
-
}
409
-
if parentChangeId.Valid {
410
-
pull.ParentChangeId = parentChangeId.String
411
}
412
413
-
submissionsQuery := `
414
select
415
-
id, pull_id, repo_at, round_number, patch, created, source_rev
416
from
417
pull_submissions
418
-
where
419
-
repo_at = ? and pull_id = ?
420
-
`
421
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
422
if err != nil {
423
return nil, err
424
}
425
-
defer submissionsRows.Close()
426
427
-
submissionsMap := make(map[int]*models.PullSubmission)
428
429
-
for submissionsRows.Next() {
430
var submission models.PullSubmission
431
-
var submissionCreatedStr string
432
-
var submissionSourceRev sql.NullString
433
-
err := submissionsRows.Scan(
434
&submission.ID,
435
-
&submission.PullId,
436
-
&submission.RepoAt,
437
&submission.RoundNumber,
438
&submission.Patch,
439
-
&submissionCreatedStr,
440
-
&submissionSourceRev,
441
)
442
if err != nil {
443
return nil, err
444
}
445
446
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
447
if err != nil {
448
return nil, err
449
}
450
-
submission.Created = submissionCreatedTime
451
452
-
if submissionSourceRev.Valid {
453
-
submission.SourceRev = submissionSourceRev.String
454
}
455
456
-
submissionsMap[submission.ID] = &submission
457
}
458
-
if err = submissionsRows.Close(); err != nil {
459
return nil, err
460
}
461
-
if len(submissionsMap) == 0 {
462
-
return &pull, nil
463
}
464
465
var args []any
466
-
for k := range submissionsMap {
467
-
args = append(args, k)
468
}
469
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
470
-
commentsQuery := fmt.Sprintf(`
471
select
472
id,
473
pull_id,
···
479
created
480
from
481
pull_comments
482
-
where
483
-
submission_id IN (%s)
484
order by
485
created asc
486
-
`, inClause)
487
-
commentsRows, err := e.Query(commentsQuery, args...)
488
if err != nil {
489
return nil, err
490
}
491
-
defer commentsRows.Close()
492
493
-
for commentsRows.Next() {
494
var comment models.PullComment
495
-
var commentCreatedStr string
496
-
err := commentsRows.Scan(
497
&comment.ID,
498
&comment.PullId,
499
&comment.SubmissionId,
···
501
&comment.OwnerDid,
502
&comment.CommentAt,
503
&comment.Body,
504
-
&commentCreatedStr,
505
)
506
if err != nil {
507
return nil, err
508
}
509
510
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
511
-
if err != nil {
512
-
return nil, err
513
}
514
-
comment.Created = commentCreatedTime
515
516
-
// Add the comment to its submission
517
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
518
-
submission.Comments = append(submission.Comments, comment)
519
-
}
520
521
-
}
522
-
if err = commentsRows.Err(); err != nil {
523
return nil, err
524
}
525
526
-
var pullSourceRepo *models.Repo
527
-
if pull.PullSource != nil {
528
-
if pull.PullSource.RepoAt != nil {
529
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
530
-
if err != nil {
531
-
log.Printf("failed to get repo by at uri: %v", err)
532
-
} else {
533
-
pull.PullSource.Repo = pullSourceRepo
534
-
}
535
-
}
536
-
}
537
-
538
-
pull.Submissions = make([]*models.PullSubmission, len(submissionsMap))
539
-
for _, submission := range submissionsMap {
540
-
pull.Submissions[submission.RoundNumber] = submission
541
-
}
542
-
543
-
return &pull, nil
544
}
545
546
// timeframe here is directly passed into the sql query filter, and any
···
677
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
678
newRoundNumber := len(pull.Submissions)
679
_, err := e.Exec(`
680
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
681
-
values (?, ?, ?, ?, ?)
682
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
683
684
return err
685
}
···
1
package db
2
3
import (
4
+
"cmp"
5
"database/sql"
6
+
"errors"
7
"fmt"
8
+
"maps"
9
+
"slices"
10
"sort"
11
"strings"
12
"time"
···
90
pull.ID = int(id)
91
92
_, err = tx.Exec(`
93
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
+
values (?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
111
}
112
113
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
115
116
var conditions []string
117
var args []any
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
+
pulls[pull.PullAt()] = &pull
218
}
219
220
+
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
+
pullAts = append(pullAts, p.PullAt())
223
}
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
227
}
228
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
232
}
233
+
}
234
235
+
// collect allLabels for each issue
236
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
+
if err != nil {
238
+
return nil, fmt.Errorf("failed to query labels: %w", err)
239
+
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
243
}
244
+
}
245
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
+
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
}
252
+
}
253
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
256
}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
260
}
261
for _, p := range pulls {
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
266
}
267
}
268
269
orderedByPullId := []*models.Pull{}
···
282
}
283
284
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
286
if err != nil {
287
return nil, err
288
}
289
+
if pulls == nil {
290
+
return nil, sql.ErrNoRows
291
}
292
293
+
return pulls[0], nil
294
+
}
295
+
296
+
// mapping from pull -> pull submissions
297
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
298
+
var conditions []string
299
+
var args []any
300
+
for _, filter := range filters {
301
+
conditions = append(conditions, filter.Condition())
302
+
args = append(args, filter.Arg()...)
303
}
304
305
+
whereClause := ""
306
+
if conditions != nil {
307
+
whereClause = " where " + strings.Join(conditions, " and ")
308
}
309
310
+
query := fmt.Sprintf(`
311
select
312
+
id,
313
+
pull_at,
314
+
round_number,
315
+
patch,
316
+
created,
317
+
source_rev
318
from
319
pull_submissions
320
+
%s
321
+
order by
322
+
round_number asc
323
+
`, whereClause)
324
+
325
+
rows, err := e.Query(query, args...)
326
if err != nil {
327
return nil, err
328
}
329
+
defer rows.Close()
330
331
+
submissionMap := make(map[int]*models.PullSubmission)
332
333
+
for rows.Next() {
334
var submission models.PullSubmission
335
+
var createdAt string
336
+
var sourceRev sql.NullString
337
+
err := rows.Scan(
338
&submission.ID,
339
+
&submission.PullAt,
340
&submission.RoundNumber,
341
&submission.Patch,
342
+
&createdAt,
343
+
&sourceRev,
344
)
345
if err != nil {
346
return nil, err
347
}
348
349
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
if err != nil {
351
return nil, err
352
}
353
+
submission.Created = createdTime
354
355
+
if sourceRev.Valid {
356
+
submission.SourceRev = sourceRev.String
357
}
358
359
+
submissionMap[submission.ID] = &submission
360
+
}
361
+
362
+
if err := rows.Err(); err != nil {
363
+
return nil, err
364
}
365
+
366
+
// Get comments for all submissions using GetPullComments
367
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
368
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
369
+
if err != nil {
370
return nil, err
371
}
372
+
for _, comment := range comments {
373
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
374
+
submission.Comments = append(submission.Comments, comment)
375
+
}
376
+
}
377
+
378
+
// group the submissions by pull_at
379
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
380
+
for _, s := range submissionMap {
381
+
m[s.PullAt] = append(m[s.PullAt], s)
382
}
383
384
+
// sort each one by round number
385
+
for _, s := range m {
386
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
387
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
388
+
})
389
+
}
390
+
391
+
return m, nil
392
+
}
393
+
394
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
395
+
var conditions []string
396
var args []any
397
+
for _, filter := range filters {
398
+
conditions = append(conditions, filter.Condition())
399
+
args = append(args, filter.Arg()...)
400
}
401
+
402
+
whereClause := ""
403
+
if conditions != nil {
404
+
whereClause = " where " + strings.Join(conditions, " and ")
405
+
}
406
+
407
+
query := fmt.Sprintf(`
408
select
409
id,
410
pull_id,
···
416
created
417
from
418
pull_comments
419
+
%s
420
order by
421
created asc
422
+
`, whereClause)
423
+
424
+
rows, err := e.Query(query, args...)
425
if err != nil {
426
return nil, err
427
}
428
+
defer rows.Close()
429
430
+
var comments []models.PullComment
431
+
for rows.Next() {
432
var comment models.PullComment
433
+
var createdAt string
434
+
err := rows.Scan(
435
&comment.ID,
436
&comment.PullId,
437
&comment.SubmissionId,
···
439
&comment.OwnerDid,
440
&comment.CommentAt,
441
&comment.Body,
442
+
&createdAt,
443
)
444
if err != nil {
445
return nil, err
446
}
447
448
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
449
+
comment.Created = t
450
}
451
452
+
comments = append(comments, comment)
453
+
}
454
455
+
if err := rows.Err(); err != nil {
456
return nil, err
457
}
458
459
+
return comments, nil
460
}
461
462
// timeframe here is directly passed into the sql query filter, and any
···
593
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
594
newRoundNumber := len(pull.Submissions)
595
_, err := e.Exec(`
596
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
+
values (?, ?, ?, ?)
598
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
599
600
return err
601
}
+34
-7
appview/db/reaction.go
+34
-7
appview/db/reaction.go
···
62
return count, nil
63
}
64
65
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
-
countMap := map[models.ReactionKind]int{}
67
for _, kind := range models.OrderedReactionKinds {
68
-
count, err := GetReactionCount(e, threadAt, kind)
69
-
if err != nil {
70
-
return map[models.ReactionKind]int{}, nil
71
}
72
-
countMap[kind] = count
73
}
74
-
return countMap, nil
75
}
76
77
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
···
62
return count, nil
63
}
64
65
+
func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) {
66
+
query := `
67
+
select kind, reacted_by_did,
68
+
row_number() over (partition by kind order by created asc) as rn,
69
+
count(*) over (partition by kind) as total
70
+
from reactions
71
+
where thread_at = ?
72
+
order by kind, created asc`
73
+
74
+
rows, err := e.Query(query, threadAt)
75
+
if err != nil {
76
+
return nil, err
77
+
}
78
+
defer rows.Close()
79
+
80
+
reactionMap := map[models.ReactionKind]models.ReactionDisplayData{}
81
for _, kind := range models.OrderedReactionKinds {
82
+
reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}}
83
+
}
84
+
85
+
for rows.Next() {
86
+
var kind models.ReactionKind
87
+
var did string
88
+
var rn, total int
89
+
if err := rows.Scan(&kind, &did, &rn, &total); err != nil {
90
+
return nil, err
91
}
92
+
93
+
data := reactionMap[kind]
94
+
data.Count = total
95
+
if userLimit > 0 && rn <= userLimit {
96
+
data.Users = append(data.Users, did)
97
+
}
98
+
reactionMap[kind] = data
99
}
100
+
101
+
return reactionMap, rows.Err()
102
}
103
104
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+38
-10
appview/db/timeline.go
+38
-10
appview/db/timeline.go
···
9
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13
var events []models.TimelineEvent
14
15
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
16
if err != nil {
17
return nil, err
18
}
19
20
-
stars, err := getTimelineStars(e, limit, loggedInUserDid)
21
if err != nil {
22
return nil, err
23
}
24
25
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
26
if err != nil {
27
return nil, err
28
}
···
70
return isStarred, starCount
71
}
72
73
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
-
repos, err := GetRepos(e, limit)
75
if err != nil {
76
return nil, err
77
}
···
125
return events, nil
126
}
127
128
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
-
stars, err := GetStars(e, limit)
130
if err != nil {
131
return nil, err
132
}
···
166
return events, nil
167
}
168
169
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
-
follows, err := GetFollows(e, limit)
171
if err != nil {
172
return nil, err
173
}
···
9
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
13
var events []models.TimelineEvent
14
15
+
var userIsFollowing []string
16
+
if limitToUsersIsFollowing {
17
+
following, err := GetFollowing(e, loggedInUserDid)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
userIsFollowing = make([]string, 0, len(following))
23
+
for _, follow := range following {
24
+
userIsFollowing = append(userIsFollowing, follow.SubjectDid)
25
+
}
26
+
}
27
+
28
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing)
29
if err != nil {
30
return nil, err
31
}
32
33
+
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
34
if err != nil {
35
return nil, err
36
}
37
38
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
39
if err != nil {
40
return nil, err
41
}
···
83
return isStarred, starCount
84
}
85
86
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
+
filters := make([]filter, 0)
88
+
if userIsFollowing != nil {
89
+
filters = append(filters, FilterIn("did", userIsFollowing))
90
+
}
91
+
92
+
repos, err := GetRepos(e, limit, filters...)
93
if err != nil {
94
return nil, err
95
}
···
143
return events, nil
144
}
145
146
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
+
filters := make([]filter, 0)
148
+
if userIsFollowing != nil {
149
+
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
+
}
151
+
152
+
stars, err := GetStars(e, limit, filters...)
153
if err != nil {
154
return nil, err
155
}
···
189
return events, nil
190
}
191
192
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
193
+
filters := make([]filter, 0)
194
+
if userIsFollowing != nil {
195
+
filters = append(filters, FilterIn("user_did", userIsFollowing))
196
+
}
197
+
198
+
follows, err := GetFollows(e, limit, filters...)
199
if err != nil {
200
return nil, err
201
}
+18
-14
appview/issues/issues.go
+18
-14
appview/issues/issues.go
···
12
"time"
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
"github.com/bluesky-social/indigo/atproto/syntax"
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/go-chi/chi/v5"
···
26
"tangled.org/core/appview/pagination"
27
"tangled.org/core/appview/reporesolver"
28
"tangled.org/core/appview/validator"
29
-
"tangled.org/core/appview/xrpcclient"
30
"tangled.org/core/idresolver"
31
tlog "tangled.org/core/log"
32
"tangled.org/core/tid"
···
83
return
84
}
85
86
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
87
if err != nil {
88
l.Error("failed to get issue reactions", "err", err)
89
}
···
115
Issue: issue,
116
CommentList: issue.CommentList(),
117
OrderedReactionKinds: models.OrderedReactionKinds,
118
-
Reactions: reactionCountMap,
119
UserReacted: userReactions,
120
LabelDefs: defs,
121
})
···
166
return
167
}
168
169
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
170
if err != nil {
171
l.Error("failed to get record", "err", err)
172
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
173
return
174
}
175
176
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
177
Collection: tangled.RepoIssueNSID,
178
Repo: user.Did,
179
Rkey: newIssue.Rkey,
···
241
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
242
return
243
}
244
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
245
Collection: tangled.RepoIssueNSID,
246
Repo: issue.Did,
247
Rkey: issue.Rkey,
···
408
}
409
410
// create a record first
411
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
412
Collection: tangled.RepoIssueCommentNSID,
413
Repo: comment.Did,
414
Rkey: comment.Rkey,
···
559
// rkey is optional, it was introduced later
560
if newComment.Rkey != "" {
561
// update the record on pds
562
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
563
if err != nil {
564
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
565
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
566
return
567
}
568
569
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
570
Collection: tangled.RepoIssueCommentNSID,
571
Repo: user.Did,
572
Rkey: newComment.Rkey,
···
733
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
734
return
735
}
736
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
737
Collection: tangled.RepoIssueCommentNSID,
738
Repo: user.Did,
739
Rkey: comment.Rkey,
···
798
return
799
}
800
801
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
802
if err != nil {
803
log.Println("failed to fetch labels", err)
804
rp.pages.Error503(w)
···
861
rp.pages.Notice(w, "issues", "Failed to create issue.")
862
return
863
}
864
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
865
Collection: tangled.RepoIssueNSID,
866
Repo: user.Did,
867
Rkey: issue.Rkey,
···
919
// this is used to rollback changes made to the PDS
920
//
921
// it is a no-op if the provided ATURI is empty
922
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
923
if aturi == "" {
924
return nil
925
}
···
930
repo := parsed.Authority().String()
931
rkey := parsed.RecordKey().String()
932
933
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
934
Collection: collection,
935
Repo: repo,
936
Rkey: rkey,
···
12
"time"
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
"github.com/go-chi/chi/v5"
···
27
"tangled.org/core/appview/pagination"
28
"tangled.org/core/appview/reporesolver"
29
"tangled.org/core/appview/validator"
30
"tangled.org/core/idresolver"
31
tlog "tangled.org/core/log"
32
"tangled.org/core/tid"
···
83
return
84
}
85
86
+
reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
87
if err != nil {
88
l.Error("failed to get issue reactions", "err", err)
89
}
···
115
Issue: issue,
116
CommentList: issue.CommentList(),
117
OrderedReactionKinds: models.OrderedReactionKinds,
118
+
Reactions: reactionMap,
119
UserReacted: userReactions,
120
LabelDefs: defs,
121
})
···
166
return
167
}
168
169
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
170
if err != nil {
171
l.Error("failed to get record", "err", err)
172
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
173
return
174
}
175
176
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
177
Collection: tangled.RepoIssueNSID,
178
Repo: user.Did,
179
Rkey: newIssue.Rkey,
···
241
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
242
return
243
}
244
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
245
Collection: tangled.RepoIssueNSID,
246
Repo: issue.Did,
247
Rkey: issue.Rkey,
···
408
}
409
410
// create a record first
411
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
412
Collection: tangled.RepoIssueCommentNSID,
413
Repo: comment.Did,
414
Rkey: comment.Rkey,
···
559
// rkey is optional, it was introduced later
560
if newComment.Rkey != "" {
561
// update the record on pds
562
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
563
if err != nil {
564
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
565
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
566
return
567
}
568
569
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
570
Collection: tangled.RepoIssueCommentNSID,
571
Repo: user.Did,
572
Rkey: newComment.Rkey,
···
733
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
734
return
735
}
736
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
737
Collection: tangled.RepoIssueCommentNSID,
738
Repo: user.Did,
739
Rkey: comment.Rkey,
···
798
return
799
}
800
801
+
labelDefs, err := db.GetLabelDefinitions(
802
+
rp.db,
803
+
db.FilterIn("at_uri", f.Repo.Labels),
804
+
db.FilterContains("scope", tangled.RepoIssueNSID),
805
+
)
806
if err != nil {
807
log.Println("failed to fetch labels", err)
808
rp.pages.Error503(w)
···
865
rp.pages.Notice(w, "issues", "Failed to create issue.")
866
return
867
}
868
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
869
Collection: tangled.RepoIssueNSID,
870
Repo: user.Did,
871
Rkey: issue.Rkey,
···
923
// this is used to rollback changes made to the PDS
924
//
925
// it is a no-op if the provided ATURI is empty
926
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
927
if aturi == "" {
928
return nil
929
}
···
934
repo := parsed.Authority().String()
935
rkey := parsed.RecordKey().String()
936
937
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
938
Collection: collection,
939
Repo: repo,
940
Rkey: rkey,
+6
-6
appview/knots/knots.go
+6
-6
appview/knots/knots.go
···
185
return
186
}
187
188
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
189
var exCid *string
190
if ex != nil {
191
exCid = ex.Cid
192
}
193
194
// re-announce by registering under same rkey
195
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
196
Collection: tangled.KnotNSID,
197
Repo: user.Did,
198
Rkey: domain,
···
323
return
324
}
325
326
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
327
Collection: tangled.KnotNSID,
328
Repo: user.Did,
329
Rkey: domain,
···
431
return
432
}
433
434
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
435
var exCid *string
436
if ex != nil {
437
exCid = ex.Cid
438
}
439
440
// ignore the error here
441
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
442
Collection: tangled.KnotNSID,
443
Repo: user.Did,
444
Rkey: domain,
···
555
556
rkey := tid.TID()
557
558
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
559
Collection: tangled.KnotMemberNSID,
560
Repo: user.Did,
561
Rkey: rkey,
···
185
return
186
}
187
188
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
189
var exCid *string
190
if ex != nil {
191
exCid = ex.Cid
192
}
193
194
// re-announce by registering under same rkey
195
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
196
Collection: tangled.KnotNSID,
197
Repo: user.Did,
198
Rkey: domain,
···
323
return
324
}
325
326
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
327
Collection: tangled.KnotNSID,
328
Repo: user.Did,
329
Rkey: domain,
···
431
return
432
}
433
434
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
435
var exCid *string
436
if ex != nil {
437
exCid = ex.Cid
438
}
439
440
// ignore the error here
441
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
442
Collection: tangled.KnotNSID,
443
Repo: user.Did,
444
Rkey: domain,
···
555
556
rkey := tid.TID()
557
558
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
559
Collection: tangled.KnotMemberNSID,
560
Repo: user.Did,
561
Rkey: rkey,
+9
-9
appview/labels/labels.go
+9
-9
appview/labels/labels.go
···
9
"net/http"
10
"time"
11
12
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
"github.com/go-chi/chi/v5"
16
-
17
"tangled.org/core/api/tangled"
18
"tangled.org/core/appview/db"
19
"tangled.org/core/appview/middleware"
···
21
"tangled.org/core/appview/oauth"
22
"tangled.org/core/appview/pages"
23
"tangled.org/core/appview/validator"
24
-
"tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/log"
26
"tangled.org/core/rbac"
27
"tangled.org/core/tid"
28
)
29
30
type Labels struct {
···
196
return
197
}
198
199
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
200
Collection: tangled.LabelOpNSID,
201
Repo: did,
202
Rkey: rkey,
···
252
// this is used to rollback changes made to the PDS
253
//
254
// it is a no-op if the provided ATURI is empty
255
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
256
if aturi == "" {
257
return nil
258
}
···
263
repo := parsed.Authority().String()
264
rkey := parsed.RecordKey().String()
265
266
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
267
Collection: collection,
268
Repo: repo,
269
Rkey: rkey,
···
9
"net/http"
10
"time"
11
12
"tangled.org/core/api/tangled"
13
"tangled.org/core/appview/db"
14
"tangled.org/core/appview/middleware"
···
16
"tangled.org/core/appview/oauth"
17
"tangled.org/core/appview/pages"
18
"tangled.org/core/appview/validator"
19
"tangled.org/core/log"
20
"tangled.org/core/rbac"
21
"tangled.org/core/tid"
22
+
23
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
26
+
lexutil "github.com/bluesky-social/indigo/lex/util"
27
+
"github.com/go-chi/chi/v5"
28
)
29
30
type Labels struct {
···
196
return
197
}
198
199
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
Collection: tangled.LabelOpNSID,
201
Repo: did,
202
Rkey: rkey,
···
252
// this is used to rollback changes made to the PDS
253
//
254
// it is a no-op if the provided ATURI is empty
255
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
256
if aturi == "" {
257
return nil
258
}
···
263
repo := parsed.Authority().String()
264
rkey := parsed.RecordKey().String()
265
266
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
267
Collection: collection,
268
Repo: repo,
269
Rkey: rkey,
+5
-14
appview/middleware/middleware.go
+5
-14
appview/middleware/middleware.go
···
43
44
type middlewareFunc func(http.Handler) http.Handler
45
46
-
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
-
return func(next http.Handler) http.Handler {
48
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
-
_, _, _ = mw.oauth.GetSession(r)
50
-
next.ServeHTTP(w, r)
51
-
})
52
-
}
53
-
}
54
-
55
-
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
56
return func(next http.Handler) http.Handler {
57
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
returnURL := "/"
···
72
}
73
}
74
75
-
_, auth, err := a.GetSession(r)
76
if err != nil {
77
-
log.Println("not logged in, redirecting", "err", err)
78
redirectFunc(w, r)
79
return
80
}
81
82
-
if !auth {
83
-
log.Printf("not logged in, redirecting")
84
redirectFunc(w, r)
85
return
86
}
···
43
44
type middlewareFunc func(http.Handler) http.Handler
45
46
+
func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
47
return func(next http.Handler) http.Handler {
48
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
returnURL := "/"
···
63
}
64
}
65
66
+
sess, err := o.ResumeSession(r)
67
if err != nil {
68
+
log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String())
69
redirectFunc(w, r)
70
return
71
}
72
73
+
if sess == nil {
74
+
log.Printf("session is nil, redirecting...")
75
redirectFunc(w, r)
76
return
77
}
+14
-13
appview/models/label.go
+14
-13
appview/models/label.go
···
461
return result
462
}
463
464
func DefaultLabelDefs() []string {
465
-
rkeys := []string{
466
-
"wontfix",
467
-
"duplicate",
468
-
"assignee",
469
-
"good-first-issue",
470
-
"documentation",
471
}
472
-
473
-
defs := make([]string, len(rkeys))
474
-
for i, r := range rkeys {
475
-
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
476
-
}
477
-
478
-
return defs
479
}
480
481
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
···
461
return result
462
}
463
464
+
var (
465
+
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
+
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
+
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
+
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
+
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
+
)
471
+
472
func DefaultLabelDefs() []string {
473
+
return []string{
474
+
LabelWontfix,
475
+
LabelDuplicate,
476
+
LabelAssignee,
477
+
LabelGoodFirstIssue,
478
+
LabelDocumentation,
479
}
480
}
481
482
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+29
-1
appview/models/notifications.go
+29
-1
appview/models/notifications.go
···
1
package models
2
3
+
import (
4
+
"time"
5
+
)
6
7
type NotificationType string
8
···
32
RepoId *int64
33
IssueId *int64
34
PullId *int64
35
+
}
36
+
37
+
// lucide icon that represents this notification
38
+
func (n *Notification) Icon() string {
39
+
switch n.Type {
40
+
case NotificationTypeRepoStarred:
41
+
return "star"
42
+
case NotificationTypeIssueCreated:
43
+
return "circle-dot"
44
+
case NotificationTypeIssueCommented:
45
+
return "message-square"
46
+
case NotificationTypeIssueClosed:
47
+
return "ban"
48
+
case NotificationTypePullCreated:
49
+
return "git-pull-request-create"
50
+
case NotificationTypePullCommented:
51
+
return "message-square"
52
+
case NotificationTypePullMerged:
53
+
return "git-merge"
54
+
case NotificationTypePullClosed:
55
+
return "git-pull-request-closed"
56
+
case NotificationTypeFollowed:
57
+
return "user-plus"
58
+
default:
59
+
return ""
60
+
}
61
}
62
63
type NotificationWithEntity struct {
+51
-4
appview/models/pull.go
+51
-4
appview/models/pull.go
···
77
PullSource *PullSource
78
79
// optionally, populate this when querying for reverse mappings
80
-
Repo *Repo
81
}
82
83
func (p Pull) AsRecord() tangled.RepoPull {
···
125
126
type PullSubmission struct {
127
// ids
128
-
ID int
129
-
PullId int
130
131
// at ids
132
-
RepoAt syntax.ATURI
133
134
// content
135
RoundNumber int
···
207
return p.StackId != ""
208
}
209
210
func (s PullSubmission) IsFormatPatch() bool {
211
return patchutil.IsFormatPatch(s.Patch)
212
}
···
219
}
220
221
return patches
222
}
223
224
type Stack []*Pull
···
308
309
return mergeable
310
}
···
77
PullSource *PullSource
78
79
// optionally, populate this when querying for reverse mappings
80
+
Labels LabelState
81
+
Repo *Repo
82
}
83
84
func (p Pull) AsRecord() tangled.RepoPull {
···
126
127
type PullSubmission struct {
128
// ids
129
+
ID int
130
131
// at ids
132
+
PullAt syntax.ATURI
133
134
// content
135
RoundNumber int
···
207
return p.StackId != ""
208
}
209
210
+
func (p *Pull) Participants() []string {
211
+
participantSet := make(map[string]struct{})
212
+
participants := []string{}
213
+
214
+
addParticipant := func(did string) {
215
+
if _, exists := participantSet[did]; !exists {
216
+
participantSet[did] = struct{}{}
217
+
participants = append(participants, did)
218
+
}
219
+
}
220
+
221
+
addParticipant(p.OwnerDid)
222
+
223
+
for _, s := range p.Submissions {
224
+
for _, sp := range s.Participants() {
225
+
addParticipant(sp)
226
+
}
227
+
}
228
+
229
+
return participants
230
+
}
231
+
232
func (s PullSubmission) IsFormatPatch() bool {
233
return patchutil.IsFormatPatch(s.Patch)
234
}
···
241
}
242
243
return patches
244
+
}
245
+
246
+
func (s *PullSubmission) Participants() []string {
247
+
participantSet := make(map[string]struct{})
248
+
participants := []string{}
249
+
250
+
addParticipant := func(did string) {
251
+
if _, exists := participantSet[did]; !exists {
252
+
participantSet[did] = struct{}{}
253
+
participants = append(participants, did)
254
+
}
255
+
}
256
+
257
+
addParticipant(s.PullAt.Authority().String())
258
+
259
+
for _, c := range s.Comments {
260
+
addParticipant(c.OwnerDid)
261
+
}
262
+
263
+
return participants
264
}
265
266
type Stack []*Pull
···
350
351
return mergeable
352
}
353
+
354
+
type BranchDeleteStatus struct {
355
+
Repo *Repo
356
+
Branch string
357
+
}
+5
appview/models/reaction.go
+5
appview/models/reaction.go
+5
appview/models/repo.go
+5
appview/models/repo.go
+41
-48
appview/notifications/notifications.go
+41
-48
appview/notifications/notifications.go
···
10
"tangled.org/core/appview/middleware"
11
"tangled.org/core/appview/oauth"
12
"tangled.org/core/appview/pages"
13
)
14
15
type Notifications struct {
···
29
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
30
r := chi.NewRouter()
31
32
-
r.Use(middleware.AuthMiddleware(n.oauth))
33
34
-
r.Get("/", n.notificationsPage)
35
-
36
-
r.Get("/count", n.getUnreadCount)
37
-
r.Post("/{id}/read", n.markRead)
38
-
r.Post("/read-all", n.markAllRead)
39
-
r.Delete("/{id}", n.deleteNotification)
40
41
return r
42
}
43
44
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
45
-
userDid := n.oauth.GetDid(r)
46
47
-
limitStr := r.URL.Query().Get("limit")
48
-
offsetStr := r.URL.Query().Get("offset")
49
-
50
-
limit := 20 // default
51
-
if limitStr != "" {
52
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
53
-
limit = l
54
-
}
55
}
56
57
-
offset := 0 // default
58
-
if offsetStr != "" {
59
-
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
60
-
offset = o
61
-
}
62
}
63
64
-
notifications, err := n.db.GetNotificationsWithEntities(r.Context(), userDid, limit+1, offset)
65
if err != nil {
66
log.Println("failed to get notifications:", err)
67
n.pages.Error500(w)
68
return
69
}
70
71
-
hasMore := len(notifications) > limit
72
-
if hasMore {
73
-
notifications = notifications[:limit]
74
-
}
75
-
76
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
77
if err != nil {
78
log.Println("failed to mark notifications as read:", err)
79
}
80
81
unreadCount := 0
82
83
-
user := n.oauth.GetUser(r)
84
-
if user == nil {
85
-
http.Error(w, "Failed to get user", http.StatusInternalServerError)
86
-
return
87
-
}
88
-
89
-
params := pages.NotificationsParams{
90
LoggedInUser: user,
91
Notifications: notifications,
92
UnreadCount: unreadCount,
93
-
HasMore: hasMore,
94
-
NextOffset: offset + limit,
95
-
Limit: limit,
96
-
}
97
-
98
-
err = n.pages.Notifications(w, params)
99
-
if err != nil {
100
-
log.Println("failed to load notifs:", err)
101
-
n.pages.Error500(w)
102
-
return
103
-
}
104
}
105
106
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
107
-
userDid := n.oauth.GetDid(r)
108
109
-
count, err := n.db.GetUnreadNotificationCount(r.Context(), userDid)
110
if err != nil {
111
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
112
return
···
10
"tangled.org/core/appview/middleware"
11
"tangled.org/core/appview/oauth"
12
"tangled.org/core/appview/pages"
13
+
"tangled.org/core/appview/pagination"
14
)
15
16
type Notifications struct {
···
30
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
31
r := chi.NewRouter()
32
33
+
r.Get("/count", n.getUnreadCount)
34
35
+
r.Group(func(r chi.Router) {
36
+
r.Use(middleware.AuthMiddleware(n.oauth))
37
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
38
+
r.Post("/{id}/read", n.markRead)
39
+
r.Post("/read-all", n.markAllRead)
40
+
r.Delete("/{id}", n.deleteNotification)
41
+
})
42
43
return r
44
}
45
46
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
47
+
user := n.oauth.GetUser(r)
48
49
+
page, ok := r.Context().Value("page").(pagination.Page)
50
+
if !ok {
51
+
log.Println("failed to get page")
52
+
page = pagination.FirstPage()
53
}
54
55
+
total, err := db.CountNotifications(
56
+
n.db,
57
+
db.FilterEq("recipient_did", user.Did),
58
+
)
59
+
if err != nil {
60
+
log.Println("failed to get total notifications:", err)
61
+
n.pages.Error500(w)
62
+
return
63
}
64
65
+
notifications, err := db.GetNotificationsWithEntities(
66
+
n.db,
67
+
page,
68
+
db.FilterEq("recipient_did", user.Did),
69
+
)
70
if err != nil {
71
log.Println("failed to get notifications:", err)
72
n.pages.Error500(w)
73
return
74
}
75
76
+
err = n.db.MarkAllNotificationsRead(r.Context(), user.Did)
77
if err != nil {
78
log.Println("failed to mark notifications as read:", err)
79
}
80
81
unreadCount := 0
82
83
+
n.pages.Notifications(w, pages.NotificationsParams{
84
LoggedInUser: user,
85
Notifications: notifications,
86
UnreadCount: unreadCount,
87
+
Page: page,
88
+
Total: total,
89
+
})
90
}
91
92
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
93
+
user := n.oauth.GetUser(r)
94
+
if user == nil {
95
+
return
96
+
}
97
98
+
count, err := db.CountNotifications(
99
+
n.db,
100
+
db.FilterEq("recipient_did", user.Did),
101
+
db.FilterEq("read", 0),
102
+
)
103
if err != nil {
104
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
105
return
+8
-48
appview/notify/db/db.go
+8
-48
appview/notify/db/db.go
···
30
31
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
var err error
33
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt)))
34
if err != nil {
35
log.Printf("NewStar: failed to get repos: %v", err)
36
return
37
}
38
-
if len(repos) == 0 {
39
-
log.Printf("NewStar: no repo found for %s", star.RepoAt)
40
-
return
41
-
}
42
-
repo := repos[0]
43
44
// don't notify yourself
45
if repo.Did == star.StarredByDid {
···
76
}
77
78
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
79
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
80
if err != nil {
81
log.Printf("NewIssue: failed to get repos: %v", err)
82
return
83
}
84
-
if len(repos) == 0 {
85
-
log.Printf("NewIssue: no repo found for %s", issue.RepoAt)
86
-
return
87
-
}
88
-
repo := repos[0]
89
90
if repo.Did == issue.Did {
91
return
···
129
}
130
issue := issues[0]
131
132
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
133
if err != nil {
134
log.Printf("NewIssueComment: failed to get repos: %v", err)
135
return
136
}
137
-
if len(repos) == 0 {
138
-
log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt)
139
-
return
140
-
}
141
-
repo := repos[0]
142
143
recipients := make(map[string]bool)
144
···
211
}
212
213
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
214
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
215
if err != nil {
216
log.Printf("NewPull: failed to get repos: %v", err)
217
return
218
}
219
-
if len(repos) == 0 {
220
-
log.Printf("NewPull: no repo found for %s", pull.RepoAt)
221
-
return
222
-
}
223
-
repo := repos[0]
224
225
if repo.Did == pull.OwnerDid {
226
return
···
266
}
267
pull := pulls[0]
268
269
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt))
270
if err != nil {
271
log.Printf("NewPullComment: failed to get repos: %v", err)
272
return
273
}
274
-
if len(repos) == 0 {
275
-
log.Printf("NewPullComment: no repo found for %s", comment.RepoAt)
276
-
return
277
-
}
278
-
repo := repos[0]
279
280
recipients := make(map[string]bool)
281
···
335
336
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
337
// Get repo details
338
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
339
if err != nil {
340
log.Printf("NewIssueClosed: failed to get repos: %v", err)
341
return
342
}
343
-
if len(repos) == 0 {
344
-
log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt)
345
-
return
346
-
}
347
-
repo := repos[0]
348
349
// Don't notify yourself
350
if repo.Did == issue.Did {
···
380
381
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
382
// Get repo details
383
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
384
if err != nil {
385
log.Printf("NewPullMerged: failed to get repos: %v", err)
386
return
387
}
388
-
if len(repos) == 0 {
389
-
log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt)
390
-
return
391
-
}
392
-
repo := repos[0]
393
394
// Don't notify yourself
395
if repo.Did == pull.OwnerDid {
···
425
426
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
427
// Get repo details
428
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
429
if err != nil {
430
log.Printf("NewPullClosed: failed to get repos: %v", err)
431
return
432
}
433
-
if len(repos) == 0 {
434
-
log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt)
435
-
return
436
-
}
437
-
repo := repos[0]
438
439
// Don't notify yourself
440
if repo.Did == pull.OwnerDid {
···
30
31
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
var err error
33
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
34
if err != nil {
35
log.Printf("NewStar: failed to get repos: %v", err)
36
return
37
}
38
39
// don't notify yourself
40
if repo.Did == star.StarredByDid {
···
71
}
72
73
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
if err != nil {
76
log.Printf("NewIssue: failed to get repos: %v", err)
77
return
78
}
79
80
if repo.Did == issue.Did {
81
return
···
119
}
120
issue := issues[0]
121
122
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
if err != nil {
124
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
return
126
}
127
128
recipients := make(map[string]bool)
129
···
196
}
197
198
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
199
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
200
if err != nil {
201
log.Printf("NewPull: failed to get repos: %v", err)
202
return
203
}
204
205
if repo.Did == pull.OwnerDid {
206
return
···
246
}
247
pull := pulls[0]
248
249
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
250
if err != nil {
251
log.Printf("NewPullComment: failed to get repos: %v", err)
252
return
253
}
254
255
recipients := make(map[string]bool)
256
···
310
311
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
// Get repo details
313
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
314
if err != nil {
315
log.Printf("NewIssueClosed: failed to get repos: %v", err)
316
return
317
}
318
319
// Don't notify yourself
320
if repo.Did == issue.Did {
···
350
351
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
352
// Get repo details
353
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
354
if err != nil {
355
log.Printf("NewPullMerged: failed to get repos: %v", err)
356
return
357
}
358
359
// Don't notify yourself
360
if repo.Did == pull.OwnerDid {
···
390
391
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
// Get repo details
393
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
if err != nil {
395
log.Printf("NewPullClosed: failed to get repos: %v", err)
396
return
397
}
398
399
// Don't notify yourself
400
if repo.Did == pull.OwnerDid {
-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
-
}
···
+65
appview/oauth/handler.go
+65
appview/oauth/handler.go
···
···
1
+
package oauth
2
+
3
+
import (
4
+
"encoding/json"
5
+
"log"
6
+
"net/http"
7
+
8
+
"github.com/go-chi/chi/v5"
9
+
"github.com/lestrrat-go/jwx/v2/jwk"
10
+
)
11
+
12
+
func (o *OAuth) Router() http.Handler {
13
+
r := chi.NewRouter()
14
+
15
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
16
+
r.Get("/oauth/jwks.json", o.jwks)
17
+
r.Get("/oauth/callback", o.callback)
18
+
return r
19
+
}
20
+
21
+
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
22
+
doc := o.ClientApp.Config.ClientMetadata()
23
+
doc.JWKSURI = &o.JwksUri
24
+
25
+
w.Header().Set("Content-Type", "application/json")
26
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
27
+
http.Error(w, err.Error(), http.StatusInternalServerError)
28
+
return
29
+
}
30
+
}
31
+
32
+
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
33
+
jwks := o.Config.OAuth.Jwks
34
+
pubKey, err := pubKeyFromJwk(jwks)
35
+
if err != nil {
36
+
log.Printf("error parsing public key: %v", err)
37
+
http.Error(w, err.Error(), http.StatusInternalServerError)
38
+
return
39
+
}
40
+
41
+
response := map[string]any{
42
+
"keys": []jwk.Key{pubKey},
43
+
}
44
+
45
+
w.Header().Set("Content-Type", "application/json")
46
+
w.WriteHeader(http.StatusOK)
47
+
json.NewEncoder(w).Encode(response)
48
+
}
49
+
50
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
51
+
ctx := r.Context()
52
+
53
+
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
54
+
if err != nil {
55
+
http.Error(w, err.Error(), http.StatusInternalServerError)
56
+
return
57
+
}
58
+
59
+
if err := o.SaveSession(w, r, sessData); err != nil {
60
+
http.Error(w, err.Error(), http.StatusInternalServerError)
61
+
return
62
+
}
63
+
64
+
http.Redirect(w, r, "/", http.StatusFound)
65
+
}
+107
-202
appview/oauth/oauth.go
+107
-202
appview/oauth/oauth.go
···
1
package oauth
2
3
import (
4
"fmt"
5
-
"log"
6
"net/http"
7
-
"net/url"
8
"time"
9
10
-
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
-
sessioncache "tangled.org/core/appview/cache/session"
13
"tangled.org/core/appview/config"
14
-
"tangled.org/core/appview/oauth/client"
15
-
xrpc "tangled.org/core/appview/xrpcclient"
16
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
17
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
18
)
19
20
-
type OAuth struct {
21
-
store *sessions.CookieStore
22
-
config *config.Config
23
-
sess *sessioncache.SessionStore
24
-
}
25
26
-
func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth {
27
-
return &OAuth{
28
-
store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
29
-
config: config,
30
-
sess: sess,
31
}
32
}
33
34
-
func (o *OAuth) Stores() *sessions.CookieStore {
35
-
return o.store
36
}
37
38
-
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error {
39
// first we save the did in the user session
40
-
userSession, err := o.store.Get(r, SessionName)
41
if err != nil {
42
return err
43
}
44
45
-
userSession.Values[SessionDid] = oreq.Did
46
-
userSession.Values[SessionHandle] = oreq.Handle
47
-
userSession.Values[SessionPds] = oreq.PdsUrl
48
userSession.Values[SessionAuthenticated] = true
49
-
err = userSession.Save(r, w)
50
if err != nil {
51
-
return fmt.Errorf("error saving user session: %w", err)
52
}
53
-
54
-
// then save the whole thing in the db
55
-
session := sessioncache.OAuthSession{
56
-
Did: oreq.Did,
57
-
Handle: oreq.Handle,
58
-
PdsUrl: oreq.PdsUrl,
59
-
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
60
-
AuthServerIss: oreq.AuthserverIss,
61
-
DpopPrivateJwk: oreq.DpopPrivateJwk,
62
-
AccessJwt: oresp.AccessToken,
63
-
RefreshJwt: oresp.RefreshToken,
64
-
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
65
}
66
67
-
return o.sess.SaveSession(r.Context(), session)
68
-
}
69
-
70
-
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
71
-
userSession, err := o.store.Get(r, SessionName)
72
-
if err != nil || userSession.IsNew {
73
-
return fmt.Errorf("error getting user session (or new session?): %w", err)
74
}
75
76
-
did := userSession.Values[SessionDid].(string)
77
78
-
err = o.sess.DeleteSession(r.Context(), did)
79
if err != nil {
80
-
return fmt.Errorf("error deleting oauth session: %w", err)
81
}
82
83
-
userSession.Options.MaxAge = -1
84
-
85
-
return userSession.Save(r, w)
86
}
87
88
-
func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) {
89
-
userSession, err := o.store.Get(r, SessionName)
90
-
if err != nil || userSession.IsNew {
91
-
return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err)
92
}
93
-
94
-
did := userSession.Values[SessionDid].(string)
95
-
auth := userSession.Values[SessionAuthenticated].(bool)
96
-
97
-
session, err := o.sess.GetSession(r.Context(), did)
98
-
if err != nil {
99
-
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
100
}
101
102
-
expiry, err := time.Parse(time.RFC3339, session.Expiry)
103
if err != nil {
104
-
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
105
}
106
-
if time.Until(expiry) <= 5*time.Minute {
107
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
108
-
if err != nil {
109
-
return nil, false, err
110
-
}
111
112
-
self := o.ClientMetadata()
113
114
-
oauthClient, err := client.NewClient(
115
-
self.ClientID,
116
-
o.config.OAuth.Jwks,
117
-
self.RedirectURIs[0],
118
-
)
119
120
-
if err != nil {
121
-
return nil, false, err
122
-
}
123
124
-
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
125
-
if err != nil {
126
-
return nil, false, err
127
-
}
128
129
-
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
130
-
err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry)
131
-
if err != nil {
132
-
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
133
-
}
134
-
135
-
// update the current session
136
-
session.AccessJwt = resp.AccessToken
137
-
session.RefreshJwt = resp.RefreshToken
138
-
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
139
-
session.Expiry = newExpiry
140
}
141
-
142
-
return session, auth, nil
143
}
144
145
type User struct {
146
-
Handle string
147
-
Did string
148
-
Pds string
149
}
150
151
-
func (a *OAuth) GetUser(r *http.Request) *User {
152
-
clientSession, err := a.store.Get(r, SessionName)
153
154
-
if err != nil || clientSession.IsNew {
155
return nil
156
}
157
158
return &User{
159
-
Handle: clientSession.Values[SessionHandle].(string),
160
-
Did: clientSession.Values[SessionDid].(string),
161
-
Pds: clientSession.Values[SessionPds].(string),
162
}
163
}
164
165
-
func (a *OAuth) GetDid(r *http.Request) string {
166
-
clientSession, err := a.store.Get(r, SessionName)
167
-
168
-
if err != nil || clientSession.IsNew {
169
-
return ""
170
}
171
172
-
return clientSession.Values[SessionDid].(string)
173
}
174
175
-
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
176
-
session, auth, err := o.GetSession(r)
177
if err != nil {
178
return nil, fmt.Errorf("error getting session: %w", err)
179
}
180
-
if !auth {
181
-
return nil, fmt.Errorf("not authorized")
182
-
}
183
-
184
-
client := &oauth.XrpcClient{
185
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
186
-
err := o.sess.UpdateNonce(r.Context(), did, newNonce)
187
-
if err != nil {
188
-
log.Printf("error updating dpop pds nonce: %v", err)
189
-
}
190
-
},
191
-
}
192
-
193
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
194
-
if err != nil {
195
-
return nil, fmt.Errorf("error parsing private jwk: %w", err)
196
-
}
197
-
198
-
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
199
-
Did: session.Did,
200
-
PdsUrl: session.PdsUrl,
201
-
DpopPdsNonce: session.PdsUrl,
202
-
AccessToken: session.AccessJwt,
203
-
Issuer: session.AuthServerIss,
204
-
DpopPrivateJwk: privateJwk,
205
-
})
206
-
207
-
return xrpcClient, nil
208
}
209
210
-
// use this to create a client to communicate with knots or spindles
211
-
//
212
// this is a higher level abstraction on ServerGetServiceAuth
213
type ServiceClientOpts struct {
214
service string
···
259
return scheme + s.service
260
}
261
262
-
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
263
opts := ServiceClientOpts{}
264
for _, o := range os {
265
o(&opts)
266
}
267
268
-
authorizedClient, err := o.AuthorizedClient(r)
269
if err != nil {
270
return nil, err
271
}
···
276
opts.exp = sixty
277
}
278
279
-
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
280
if err != nil {
281
return nil, err
282
}
283
284
-
return &indigo_xrpc.Client{
285
-
Auth: &indigo_xrpc.AuthInfo{
286
AccessJwt: resp.Token,
287
},
288
Host: opts.Host(),
···
291
},
292
}, nil
293
}
294
-
295
-
type ClientMetadata struct {
296
-
ClientID string `json:"client_id"`
297
-
ClientName string `json:"client_name"`
298
-
SubjectType string `json:"subject_type"`
299
-
ClientURI string `json:"client_uri"`
300
-
RedirectURIs []string `json:"redirect_uris"`
301
-
GrantTypes []string `json:"grant_types"`
302
-
ResponseTypes []string `json:"response_types"`
303
-
ApplicationType string `json:"application_type"`
304
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
305
-
JwksURI string `json:"jwks_uri"`
306
-
Scope string `json:"scope"`
307
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
308
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
309
-
}
310
-
311
-
func (o *OAuth) ClientMetadata() ClientMetadata {
312
-
makeRedirectURIs := func(c string) []string {
313
-
return []string{fmt.Sprintf("%s/oauth/callback", c)}
314
-
}
315
-
316
-
clientURI := o.config.Core.AppviewHost
317
-
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
318
-
redirectURIs := makeRedirectURIs(clientURI)
319
-
320
-
if o.config.Core.Dev {
321
-
clientURI = "http://127.0.0.1:3000"
322
-
redirectURIs = makeRedirectURIs(clientURI)
323
-
324
-
query := url.Values{}
325
-
query.Add("redirect_uri", redirectURIs[0])
326
-
query.Add("scope", "atproto transition:generic")
327
-
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
328
-
}
329
-
330
-
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
331
-
332
-
return ClientMetadata{
333
-
ClientID: clientID,
334
-
ClientName: "Tangled",
335
-
SubjectType: "public",
336
-
ClientURI: clientURI,
337
-
RedirectURIs: redirectURIs,
338
-
GrantTypes: []string{"authorization_code", "refresh_token"},
339
-
ResponseTypes: []string{"code"},
340
-
ApplicationType: "web",
341
-
DpopBoundAccessTokens: true,
342
-
JwksURI: jwksURI,
343
-
Scope: "atproto transition:generic",
344
-
TokenEndpointAuthMethod: "private_key_jwt",
345
-
TokenEndpointAuthSigningAlg: "ES256",
346
-
}
347
-
}
···
1
package oauth
2
3
import (
4
+
"errors"
5
"fmt"
6
"net/http"
7
"time"
8
9
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
11
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
xrpc "github.com/bluesky-social/indigo/xrpc"
14
"github.com/gorilla/sessions"
15
+
"github.com/lestrrat-go/jwx/v2/jwk"
16
"tangled.org/core/appview/config"
17
)
18
19
+
func New(config *config.Config) (*OAuth, error) {
20
+
21
+
var oauthConfig oauth.ClientConfig
22
+
var clientUri string
23
24
+
if config.Core.Dev {
25
+
clientUri = "http://127.0.0.1:3000"
26
+
callbackUri := clientUri + "/oauth/callback"
27
+
oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"})
28
+
} else {
29
+
clientUri = config.Core.AppviewHost
30
+
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
31
+
callbackUri := clientUri + "/oauth/callback"
32
+
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
33
}
34
+
35
+
jwksUri := clientUri + "/oauth/jwks.json"
36
+
37
+
authStore, err := NewRedisStore(config.Redis.ToURL())
38
+
if err != nil {
39
+
return nil, err
40
+
}
41
+
42
+
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
43
+
44
+
return &OAuth{
45
+
ClientApp: oauth.NewClientApp(&oauthConfig, authStore),
46
+
Config: config,
47
+
SessStore: sessStore,
48
+
JwksUri: jwksUri,
49
+
}, nil
50
}
51
52
+
type OAuth struct {
53
+
ClientApp *oauth.ClientApp
54
+
SessStore *sessions.CookieStore
55
+
Config *config.Config
56
+
JwksUri string
57
}
58
59
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
60
// first we save the did in the user session
61
+
userSession, err := o.SessStore.Get(r, SessionName)
62
if err != nil {
63
return err
64
}
65
66
+
userSession.Values[SessionDid] = sessData.AccountDID.String()
67
+
userSession.Values[SessionPds] = sessData.HostURL
68
+
userSession.Values[SessionId] = sessData.SessionID
69
userSession.Values[SessionAuthenticated] = true
70
+
return userSession.Save(r, w)
71
+
}
72
+
73
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
74
+
userSession, err := o.SessStore.Get(r, SessionName)
75
if err != nil {
76
+
return nil, fmt.Errorf("error getting user session: %w", err)
77
}
78
+
if userSession.IsNew {
79
+
return nil, fmt.Errorf("no session available for user")
80
}
81
82
+
d := userSession.Values[SessionDid].(string)
83
+
sessDid, err := syntax.ParseDID(d)
84
+
if err != nil {
85
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
86
}
87
88
+
sessId := userSession.Values[SessionId].(string)
89
90
+
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
91
if err != nil {
92
+
return nil, fmt.Errorf("failed to resume session: %w", err)
93
}
94
95
+
return clientSess, nil
96
}
97
98
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
99
+
userSession, err := o.SessStore.Get(r, SessionName)
100
+
if err != nil {
101
+
return fmt.Errorf("error getting user session: %w", err)
102
}
103
+
if userSession.IsNew {
104
+
return fmt.Errorf("no session available for user")
105
}
106
107
+
d := userSession.Values[SessionDid].(string)
108
+
sessDid, err := syntax.ParseDID(d)
109
if err != nil {
110
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
111
}
112
113
+
sessId := userSession.Values[SessionId].(string)
114
115
+
// delete the session
116
+
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
117
118
+
// remove the cookie
119
+
userSession.Options.MaxAge = -1
120
+
err2 := o.SessStore.Save(r, w, userSession)
121
122
+
return errors.Join(err1, err2)
123
+
}
124
125
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
126
+
k, err := jwk.ParseKey([]byte(jwks))
127
+
if err != nil {
128
+
return nil, err
129
+
}
130
+
pubKey, err := k.PublicKey()
131
+
if err != nil {
132
+
return nil, err
133
}
134
+
return pubKey, nil
135
}
136
137
type User struct {
138
+
Did string
139
+
Pds string
140
}
141
142
+
func (o *OAuth) GetUser(r *http.Request) *User {
143
+
sess, err := o.SessStore.Get(r, SessionName)
144
145
+
if err != nil || sess.IsNew {
146
return nil
147
}
148
149
return &User{
150
+
Did: sess.Values[SessionDid].(string),
151
+
Pds: sess.Values[SessionPds].(string),
152
}
153
}
154
155
+
func (o *OAuth) GetDid(r *http.Request) string {
156
+
if u := o.GetUser(r); u != nil {
157
+
return u.Did
158
}
159
160
+
return ""
161
}
162
163
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
164
+
session, err := o.ResumeSession(r)
165
if err != nil {
166
return nil, fmt.Errorf("error getting session: %w", err)
167
}
168
+
return session.APIClient(), nil
169
}
170
171
// this is a higher level abstraction on ServerGetServiceAuth
172
type ServiceClientOpts struct {
173
service string
···
218
return scheme + s.service
219
}
220
221
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
222
opts := ServiceClientOpts{}
223
for _, o := range os {
224
o(&opts)
225
}
226
227
+
client, err := o.AuthorizedClient(r)
228
if err != nil {
229
return nil, err
230
}
···
235
opts.exp = sixty
236
}
237
238
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
239
if err != nil {
240
return nil, err
241
}
242
243
+
return &xrpc.Client{
244
+
Auth: &xrpc.AuthInfo{
245
AccessJwt: resp.Token,
246
},
247
Host: opts.Host(),
···
250
},
251
}, nil
252
}
+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
+
}
+7
-7
appview/pages/funcmap.go
+7
-7
appview/pages/funcmap.go
···
265
return nil
266
},
267
"i": func(name string, classes ...string) template.HTML {
268
-
data, err := icon(name, classes)
269
if err != nil {
270
log.Printf("icon %s does not exist", name)
271
-
data, _ = icon("airplay", classes)
272
}
273
return template.HTML(data)
274
},
275
-
"cssContentHash": CssContentHash,
276
"fileTree": filetree.FileTree,
277
"pathEscape": func(s string) string {
278
return url.PathEscape(s)
···
283
},
284
285
"tinyAvatar": func(handle string) string {
286
-
return p.avatarUri(handle, "tiny")
287
},
288
"fullAvatar": func(handle string) string {
289
-
return p.avatarUri(handle, "")
290
},
291
"langColor": enry.GetColor,
292
"layoutSide": func() string {
···
310
}
311
}
312
313
-
func (p *Pages) avatarUri(handle, size string) string {
314
handle = strings.TrimPrefix(handle, "@")
315
316
secret := p.avatar.SharedSecret
···
325
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
326
}
327
328
-
func icon(name string, classes []string) (template.HTML, error) {
329
iconPath := filepath.Join("static", "icons", name)
330
331
if filepath.Ext(name) == "" {
···
265
return nil
266
},
267
"i": func(name string, classes ...string) template.HTML {
268
+
data, err := p.icon(name, classes)
269
if err != nil {
270
log.Printf("icon %s does not exist", name)
271
+
data, _ = p.icon("airplay", classes)
272
}
273
return template.HTML(data)
274
},
275
+
"cssContentHash": p.CssContentHash,
276
"fileTree": filetree.FileTree,
277
"pathEscape": func(s string) string {
278
return url.PathEscape(s)
···
283
},
284
285
"tinyAvatar": func(handle string) string {
286
+
return p.AvatarUrl(handle, "tiny")
287
},
288
"fullAvatar": func(handle string) string {
289
+
return p.AvatarUrl(handle, "")
290
},
291
"langColor": enry.GetColor,
292
"layoutSide": func() string {
···
310
}
311
}
312
313
+
func (p *Pages) AvatarUrl(handle, size string) string {
314
handle = strings.TrimPrefix(handle, "@")
315
316
secret := p.avatar.SharedSecret
···
325
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
326
}
327
328
+
func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
329
iconPath := filepath.Join("static", "icons", name)
330
331
if filepath.Ext(name) == "" {
+6
-1
appview/pages/markup/markdown.go
+6
-1
appview/pages/markup/markdown.go
···
5
"bytes"
6
"fmt"
7
"io"
8
"net/url"
9
"path"
10
"strings"
···
20
"github.com/yuin/goldmark/renderer/html"
21
"github.com/yuin/goldmark/text"
22
"github.com/yuin/goldmark/util"
23
htmlparse "golang.org/x/net/html"
24
25
"tangled.org/core/api/tangled"
···
45
IsDev bool
46
RendererType RendererType
47
Sanitizer Sanitizer
48
}
49
50
func (rctx *RenderContext) RenderMarkdown(source string) string {
···
62
extension.WithFootnoteIDPrefix([]byte("footnote")),
63
),
64
treeblood.MathML(),
65
),
66
goldmark.WithParserOptions(
67
parser.WithAutoHeadingID(),
···
140
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
141
switch node.Type {
142
case htmlparse.ElementNode:
143
-
if node.Data == "img" || node.Data == "source" {
144
for i, attr := range node.Attr {
145
if attr.Key != "src" {
146
continue
···
5
"bytes"
6
"fmt"
7
"io"
8
+
"io/fs"
9
"net/url"
10
"path"
11
"strings"
···
21
"github.com/yuin/goldmark/renderer/html"
22
"github.com/yuin/goldmark/text"
23
"github.com/yuin/goldmark/util"
24
+
callout "gitlab.com/staticnoise/goldmark-callout"
25
htmlparse "golang.org/x/net/html"
26
27
"tangled.org/core/api/tangled"
···
47
IsDev bool
48
RendererType RendererType
49
Sanitizer Sanitizer
50
+
Files fs.FS
51
}
52
53
func (rctx *RenderContext) RenderMarkdown(source string) string {
···
65
extension.WithFootnoteIDPrefix([]byte("footnote")),
66
),
67
treeblood.MathML(),
68
+
callout.CalloutExtention,
69
),
70
goldmark.WithParserOptions(
71
parser.WithAutoHeadingID(),
···
144
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
145
switch node.Type {
146
case htmlparse.ElementNode:
147
+
switch node.Data {
148
+
case "img", "source":
149
for i, attr := range node.Attr {
150
if attr.Key != "src" {
151
continue
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
+45
-25
appview/pages/pages.go
+45
-25
appview/pages/pages.go
···
61
CamoUrl: config.Camo.Host,
62
CamoSecret: config.Camo.SharedSecret,
63
Sanitizer: markup.NewSanitizer(),
64
}
65
66
p := &Pages{
···
306
LoggedInUser *oauth.User
307
Timeline []models.TimelineEvent
308
Repos []models.Repo
309
}
310
311
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
312
return p.execute("timeline/timeline", w, params)
313
}
314
315
type UserProfileSettingsParams struct {
316
LoggedInUser *oauth.User
317
Tabs []map[string]any
···
326
LoggedInUser *oauth.User
327
Notifications []*models.NotificationWithEntity
328
UnreadCount int
329
-
HasMore bool
330
-
NextOffset int
331
-
Limit int
332
}
333
334
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
···
344
}
345
346
type NotificationCountParams struct {
347
-
Count int
348
}
349
350
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
···
972
LabelDefs map[string]*models.LabelDefinition
973
974
OrderedReactionKinds []models.ReactionKind
975
-
Reactions map[models.ReactionKind]int
976
UserReacted map[models.ReactionKind]bool
977
}
978
···
997
ThreadAt syntax.ATURI
998
Kind models.ReactionKind
999
Count int
1000
IsReacted bool
1001
}
1002
···
1087
FilteringBy models.PullState
1088
Stacks map[string]models.Stack
1089
Pipelines map[string]models.Pipeline
1090
}
1091
1092
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1113
}
1114
1115
type RepoSinglePullParams struct {
1116
-
LoggedInUser *oauth.User
1117
-
RepoInfo repoinfo.RepoInfo
1118
-
Active string
1119
-
Pull *models.Pull
1120
-
Stack models.Stack
1121
-
AbandonedPulls []*models.Pull
1122
-
MergeCheck types.MergeCheckResponse
1123
-
ResubmitCheck ResubmitResult
1124
-
Pipelines map[string]models.Pipeline
1125
1126
OrderedReactionKinds []models.ReactionKind
1127
-
Reactions map[models.ReactionKind]int
1128
UserReacted map[models.ReactionKind]bool
1129
}
1130
1131
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1215
}
1216
1217
type PullActionsParams struct {
1218
-
LoggedInUser *oauth.User
1219
-
RepoInfo repoinfo.RepoInfo
1220
-
Pull *models.Pull
1221
-
RoundNumber int
1222
-
MergeCheck types.MergeCheckResponse
1223
-
ResubmitCheck ResubmitResult
1224
-
Stack models.Stack
1225
}
1226
1227
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1458
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1459
}
1460
1461
-
sub, err := fs.Sub(Files, "static")
1462
if err != nil {
1463
p.logger.Error("no static dir found? that's crazy", "err", err)
1464
panic(err)
···
1481
})
1482
}
1483
1484
-
func CssContentHash() string {
1485
-
cssFile, err := Files.Open("static/tw.css")
1486
if err != nil {
1487
slog.Debug("Error opening CSS file", "err", err)
1488
return ""
···
61
CamoUrl: config.Camo.Host,
62
CamoSecret: config.Camo.SharedSecret,
63
Sanitizer: markup.NewSanitizer(),
64
+
Files: Files,
65
}
66
67
p := &Pages{
···
307
LoggedInUser *oauth.User
308
Timeline []models.TimelineEvent
309
Repos []models.Repo
310
+
GfiLabel *models.LabelDefinition
311
}
312
313
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
314
return p.execute("timeline/timeline", w, params)
315
}
316
317
+
type GoodFirstIssuesParams struct {
318
+
LoggedInUser *oauth.User
319
+
Issues []models.Issue
320
+
RepoGroups []*models.RepoGroup
321
+
LabelDefs map[string]*models.LabelDefinition
322
+
GfiLabel *models.LabelDefinition
323
+
Page pagination.Page
324
+
}
325
+
326
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
327
+
return p.execute("goodfirstissues/index", w, params)
328
+
}
329
+
330
type UserProfileSettingsParams struct {
331
LoggedInUser *oauth.User
332
Tabs []map[string]any
···
341
LoggedInUser *oauth.User
342
Notifications []*models.NotificationWithEntity
343
UnreadCount int
344
+
Page pagination.Page
345
+
Total int64
346
}
347
348
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
···
358
}
359
360
type NotificationCountParams struct {
361
+
Count int64
362
}
363
364
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
···
986
LabelDefs map[string]*models.LabelDefinition
987
988
OrderedReactionKinds []models.ReactionKind
989
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
990
UserReacted map[models.ReactionKind]bool
991
}
992
···
1011
ThreadAt syntax.ATURI
1012
Kind models.ReactionKind
1013
Count int
1014
+
Users []string
1015
IsReacted bool
1016
}
1017
···
1102
FilteringBy models.PullState
1103
Stacks map[string]models.Stack
1104
Pipelines map[string]models.Pipeline
1105
+
LabelDefs map[string]*models.LabelDefinition
1106
}
1107
1108
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1129
}
1130
1131
type RepoSinglePullParams struct {
1132
+
LoggedInUser *oauth.User
1133
+
RepoInfo repoinfo.RepoInfo
1134
+
Active string
1135
+
Pull *models.Pull
1136
+
Stack models.Stack
1137
+
AbandonedPulls []*models.Pull
1138
+
BranchDeleteStatus *models.BranchDeleteStatus
1139
+
MergeCheck types.MergeCheckResponse
1140
+
ResubmitCheck ResubmitResult
1141
+
Pipelines map[string]models.Pipeline
1142
1143
OrderedReactionKinds []models.ReactionKind
1144
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
1145
UserReacted map[models.ReactionKind]bool
1146
+
1147
+
LabelDefs map[string]*models.LabelDefinition
1148
}
1149
1150
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1234
}
1235
1236
type PullActionsParams struct {
1237
+
LoggedInUser *oauth.User
1238
+
RepoInfo repoinfo.RepoInfo
1239
+
Pull *models.Pull
1240
+
RoundNumber int
1241
+
MergeCheck types.MergeCheckResponse
1242
+
ResubmitCheck ResubmitResult
1243
+
BranchDeleteStatus *models.BranchDeleteStatus
1244
+
Stack models.Stack
1245
}
1246
1247
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1478
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1479
}
1480
1481
+
sub, err := fs.Sub(p.embedFS, "static")
1482
if err != nil {
1483
p.logger.Error("no static dir found? that's crazy", "err", err)
1484
panic(err)
···
1501
})
1502
}
1503
1504
+
func (p *Pages) CssContentHash() string {
1505
+
cssFile, err := p.embedFS.Open("static/tw.css")
1506
if err != nil {
1507
slog.Debug("Error opening CSS file", "err", err)
1508
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
{{ $d := .def }}
3
{{ $v := .val }}
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
8
{{ $lhs := printf "%s" $d.Name }}
···
2
{{ $d := .def }}
3
{{ $v := .val }}
4
{{ $withPrefix := .withPrefix }}
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
8
{{ $lhs := printf "%s" $d.Name }}
+16
-12
appview/pages/templates/layouts/base.html
+16
-12
appview/pages/templates/layouts/base.html
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
<!-- preload main font -->
18
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
···
21
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
{{ block "extrameta" . }}{{ end }}
23
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"
25
-
style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);">
26
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
29
{{ if .LoggedInUser }}
30
<div id="upgrade-banner"
···
38
{{ end }}
39
40
{{ block "mainLayout" . }}
41
-
<div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4">
42
-
{{ block "contentLayout" . }}
43
-
<main class="col-span-1 md:col-span-8">
44
{{ block "content" . }}{{ end }}
45
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
50
{{ block "contentAfter" . }}{{ end }}
51
</main>
52
-
{{ end }}
53
</div>
54
{{ end }}
55
56
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
58
{{ template "layouts/fragments/footer" . }}
59
</footer>
60
{{ end }}
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
20
<!-- preload main font -->
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
22
···
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
25
{{ block "extrameta" . }}{{ end }}
26
</head>
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
28
{{ block "topbarLayout" . }}
29
+
<header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
31
{{ if .LoggedInUser }}
32
<div id="upgrade-banner"
···
40
{{ end }}
41
42
{{ block "mainLayout" . }}
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
47
{{ block "content" . }}{{ end }}
48
</main>
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
53
{{ block "contentAfter" . }}{{ end }}
54
</main>
55
+
{{ end }}
56
+
</div>
57
</div>
58
{{ end }}
59
60
{{ block "footerLayout" . }}
61
+
<footer class="mt-12">
62
{{ template "layouts/fragments/footer" . }}
63
</footer>
64
{{ end }}
+2
-2
appview/pages/templates/layouts/fragments/topbar.html
+2
-2
appview/pages/templates/layouts/fragments/topbar.html
···
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
···
51
<summary
52
class="cursor-pointer list-none flex items-center gap-1"
53
>
54
-
{{ $user := didOrHandle .Did .Handle }}
55
<img
56
src="{{ tinyAvatar $user }}"
57
alt=""
···
1
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
···
51
<summary
52
class="cursor-pointer list-none flex items-center gap-1"
53
>
54
+
{{ $user := .Did }}
55
<img
56
src="{{ tinyAvatar $user }}"
57
alt=""
+68
-199
appview/pages/templates/notifications/fragments/item.html
+68
-199
appview/pages/templates/notifications/fragments/item.html
···
1
{{define "notifications/fragments/item"}}
2
-
<div class="border border-gray-200 dark:border-gray-700 rounded-sm p-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors {{if not .Read}}bg-blue-50 dark:bg-blue-900/20{{end}}">
3
-
{{if .Issue}}
4
-
{{template "issueNotification" .}}
5
-
{{else if .Pull}}
6
-
{{template "pullNotification" .}}
7
-
{{else if .Repo}}
8
-
{{template "repoNotification" .}}
9
-
{{else if eq .Type "followed"}}
10
-
{{template "followNotification" .}}
11
-
{{else}}
12
-
{{template "genericNotification" .}}
13
-
{{end}}
14
-
</div>
15
-
{{end}}
16
-
17
-
{{define "issueNotification"}}
18
-
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
19
-
<a
20
-
href="{{$url}}"
21
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
22
-
>
23
-
<div class="flex items-center justify-between">
24
-
<div class="min-w-0 flex-1">
25
-
<!-- First line: icon + actor action -->
26
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
27
-
{{if eq .Type "issue_created"}}
28
-
<span class="text-green-600 dark:text-green-500">
29
-
{{ i "circle-dot" "w-4 h-4" }}
30
-
</span>
31
-
{{else if eq .Type "issue_commented"}}
32
-
<span class="text-gray-500 dark:text-gray-400">
33
-
{{ i "message-circle" "w-4 h-4" }}
34
-
</span>
35
-
{{else if eq .Type "issue_closed"}}
36
-
<span class="text-gray-500 dark:text-gray-400">
37
-
{{ i "ban" "w-4 h-4" }}
38
-
</span>
39
-
{{end}}
40
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
41
-
{{if eq .Type "issue_created"}}
42
-
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
43
-
{{else if eq .Type "issue_commented"}}
44
-
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
45
-
{{else if eq .Type "issue_closed"}}
46
-
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
47
-
{{end}}
48
-
{{if not .Read}}
49
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
50
-
{{end}}
51
</div>
52
53
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
54
-
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
55
-
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
56
-
<span>on</span>
57
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
58
-
</div>
59
</div>
60
-
61
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
62
-
{{ template "repo/fragments/time" .Created }}
63
-
</div>
64
-
</div>
65
-
</a>
66
{{end}}
67
68
-
{{define "pullNotification"}}
69
-
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
70
-
<a
71
-
href="{{$url}}"
72
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
73
-
>
74
-
<div class="flex items-center justify-between">
75
-
<div class="min-w-0 flex-1">
76
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
77
-
{{if eq .Type "pull_created"}}
78
-
<span class="text-green-600 dark:text-green-500">
79
-
{{ i "git-pull-request-create" "w-4 h-4" }}
80
-
</span>
81
-
{{else if eq .Type "pull_commented"}}
82
-
<span class="text-gray-500 dark:text-gray-400">
83
-
{{ i "message-circle" "w-4 h-4" }}
84
-
</span>
85
-
{{else if eq .Type "pull_merged"}}
86
-
<span class="text-purple-600 dark:text-purple-500">
87
-
{{ i "git-merge" "w-4 h-4" }}
88
-
</span>
89
-
{{else if eq .Type "pull_closed"}}
90
-
<span class="text-red-600 dark:text-red-500">
91
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
92
-
</span>
93
-
{{end}}
94
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
95
-
{{if eq .Type "pull_created"}}
96
-
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
97
-
{{else if eq .Type "pull_commented"}}
98
-
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
99
-
{{else if eq .Type "pull_merged"}}
100
-
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
101
-
{{else if eq .Type "pull_closed"}}
102
-
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
103
-
{{end}}
104
-
{{if not .Read}}
105
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
106
-
{{end}}
107
-
</div>
108
-
109
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
110
-
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
111
-
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
112
-
<span>on</span>
113
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
114
-
</div>
115
-
</div>
116
-
117
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
118
-
{{ template "repo/fragments/time" .Created }}
119
</div>
120
</div>
121
-
</a>
122
-
{{end}}
123
124
-
{{define "repoNotification"}}
125
-
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
126
-
<a
127
-
href="{{$url}}"
128
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
129
-
>
130
-
<div class="flex items-center justify-between">
131
-
<div class="flex items-center gap-2 min-w-0 flex-1">
132
-
<span class="text-yellow-500 dark:text-yellow-400">
133
-
{{ i "star" "w-4 h-4" }}
134
-
</span>
135
136
-
<div class="min-w-0 flex-1">
137
-
<!-- Single line for stars: actor action subject -->
138
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
139
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
140
-
<span class="text-gray-500 dark:text-gray-400">starred</span>
141
-
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
142
-
{{if not .Read}}
143
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
144
-
{{end}}
145
-
</div>
146
-
</div>
147
-
</div>
148
149
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
150
-
{{ template "repo/fragments/time" .Created }}
151
-
</div>
152
-
</div>
153
-
</a>
154
-
{{end}}
155
156
-
{{define "followNotification"}}
157
-
{{$url := printf "/%s" (resolve .ActorDid)}}
158
-
<a
159
-
href="{{$url}}"
160
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
161
-
>
162
-
<div class="flex items-center justify-between">
163
-
<div class="flex items-center gap-2 min-w-0 flex-1">
164
-
<span class="text-blue-600 dark:text-blue-400">
165
-
{{ i "user-plus" "w-4 h-4" }}
166
-
</span>
167
-
168
-
<div class="min-w-0 flex-1">
169
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
170
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
171
-
<span class="text-gray-500 dark:text-gray-400">followed you</span>
172
-
{{if not .Read}}
173
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
174
-
{{end}}
175
-
</div>
176
-
</div>
177
-
</div>
178
-
179
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
180
-
{{ template "repo/fragments/time" .Created }}
181
-
</div>
182
-
</div>
183
-
</a>
184
-
{{end}}
185
-
186
-
{{define "genericNotification"}}
187
-
<a
188
-
href="#"
189
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
190
-
>
191
-
<div class="flex items-center justify-between">
192
-
<div class="flex items-center gap-2 min-w-0 flex-1">
193
-
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
194
-
{{ i "bell" "w-4 h-4" }}
195
-
</span>
196
197
-
<div class="min-w-0 flex-1">
198
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
199
-
<span>New notification</span>
200
-
{{if not .Read}}
201
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
202
-
{{end}}
203
-
</div>
204
-
</div>
205
-
</div>
206
-
207
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
208
-
{{ template "repo/fragments/time" .Created }}
209
-
</div>
210
-
</div>
211
-
</a>
212
-
{{end}}
···
1
{{define "notifications/fragments/item"}}
2
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
3
+
<div
4
+
class="
5
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
6
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
7
+
flex gap-2 items-center
8
+
">
9
+
{{ template "notificationIcon" . }}
10
+
<div class="flex-1 w-full flex flex-col gap-1">
11
+
<span>{{ template "notificationHeader" . }}</span>
12
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
</div>
14
15
</div>
16
+
</a>
17
{{end}}
18
19
+
{{ define "notificationIcon" }}
20
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
23
+
{{ i .Icon "size-3 text-black dark:text-white" }}
24
</div>
25
</div>
26
+
{{ end }}
27
28
+
{{ define "notificationHeader" }}
29
+
{{ $actor := resolve .ActorDid }}
30
31
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
32
+
{{ if eq .Type "repo_starred" }}
33
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
34
+
{{ else if eq .Type "issue_created" }}
35
+
opened an issue
36
+
{{ else if eq .Type "issue_commented" }}
37
+
commented on an issue
38
+
{{ else if eq .Type "issue_closed" }}
39
+
closed an issue
40
+
{{ else if eq .Type "pull_created" }}
41
+
created a pull request
42
+
{{ else if eq .Type "pull_commented" }}
43
+
commented on a pull request
44
+
{{ else if eq .Type "pull_merged" }}
45
+
merged a pull request
46
+
{{ else if eq .Type "pull_closed" }}
47
+
closed a pull request
48
+
{{ else if eq .Type "followed" }}
49
+
followed you
50
+
{{ else }}
51
+
{{ end }}
52
+
{{ end }}
53
54
+
{{ define "notificationSummary" }}
55
+
{{ if eq .Type "repo_starred" }}
56
+
<!-- no summary -->
57
+
{{ else if .Issue }}
58
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
59
+
{{ else if .Pull }}
60
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
61
+
{{ else if eq .Type "followed" }}
62
+
<!-- no summary -->
63
+
{{ else }}
64
+
{{ end }}
65
+
{{ end }}
66
67
+
{{ define "notificationUrl" }}
68
+
{{ $url := "" }}
69
+
{{ if eq .Type "repo_starred" }}
70
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
71
+
{{ else if .Issue }}
72
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
73
+
{{ else if .Pull }}
74
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
75
+
{{ else if eq .Type "followed" }}
76
+
{{$url = printf "/%s" (resolve .ActorDid)}}
77
+
{{ else }}
78
+
{{ end }}
79
80
+
{{ $url }}
81
+
{{ end }}
+44
-25
appview/pages/templates/notifications/list.html
+44
-25
appview/pages/templates/notifications/list.html
···
1
{{ define "title" }}notifications{{ end }}
2
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<div class="flex items-center justify-between mb-4">
6
<p class="text-xl font-bold dark:text-white">Notifications</p>
7
<a href="/settings/notifications" class="flex items-center gap-2">
8
{{ i "settings" "w-4 h-4" }}
···
11
</div>
12
</div>
13
14
-
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
15
-
{{if .Notifications}}
16
-
<div class="flex flex-col gap-4" id="notifications-list">
17
-
{{range .Notifications}}
18
-
{{template "notifications/fragments/item" .}}
19
-
{{end}}
20
-
</div>
21
22
-
{{if .HasMore}}
23
-
<div class="mt-6 text-center">
24
-
<button
25
-
class="btn gap-2 group"
26
-
hx-get="/notifications?offset={{.NextOffset}}&limit={{.Limit}}"
27
-
hx-target="#notifications-list"
28
-
hx-swap="beforeend"
29
-
>
30
-
{{ i "chevron-down" "w-4 h-4 group-[.htmx-request]:hidden" }}
31
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
-
Load more
33
-
</button>
34
-
</div>
35
-
{{end}}
36
-
{{else}}
37
<div class="text-center py-12">
38
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
39
{{ i "bell-off" "w-16 h-16" }}
···
41
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
42
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
43
</div>
44
-
{{end}}
45
</div>
46
{{ end }}
···
1
{{ define "title" }}notifications{{ end }}
2
3
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex items-center justify-between">
6
<p class="text-xl font-bold dark:text-white">Notifications</p>
7
<a href="/settings/notifications" class="flex items-center gap-2">
8
{{ i "settings" "w-4 h-4" }}
···
11
</div>
12
</div>
13
14
+
{{if .Notifications}}
15
+
<div class="flex flex-col gap-2" id="notifications-list">
16
+
{{range .Notifications}}
17
+
{{template "notifications/fragments/item" .}}
18
+
{{end}}
19
+
</div>
20
21
+
{{else}}
22
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
23
<div class="text-center py-12">
24
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
25
{{ i "bell-off" "w-16 h-16" }}
···
27
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
28
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
29
</div>
30
+
</div>
31
+
{{end}}
32
+
33
+
{{ template "pagination" . }}
34
+
{{ end }}
35
+
36
+
{{ define "pagination" }}
37
+
<div class="flex justify-end mt-4 gap-2">
38
+
{{ if gt .Page.Offset 0 }}
39
+
{{ $prev := .Page.Previous }}
40
+
<a
41
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
42
+
hx-boost="true"
43
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
44
+
>
45
+
{{ i "chevron-left" "w-4 h-4" }}
46
+
previous
47
+
</a>
48
+
{{ else }}
49
+
<div></div>
50
+
{{ end }}
51
+
52
+
{{ $next := .Page.Next }}
53
+
{{ if lt $next.Offset .Total }}
54
+
{{ $next := .Page.Next }}
55
+
<a
56
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
+
hx-boost="true"
58
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
59
+
>
60
+
next
61
+
{{ i "chevron-right" "w-4 h-4" }}
62
+
</a>
63
+
{{ end }}
64
</div>
65
{{ end }}
+3
-3
appview/pages/templates/repo/commit.html
+3
-3
appview/pages/templates/repo/commit.html
···
80
{{end}}
81
82
{{ define "topbarLayout" }}
83
-
<header class="px-1 col-span-full" style="z-index: 20;">
84
{{ template "layouts/fragments/topbar" . }}
85
</header>
86
{{ end }}
87
88
{{ define "mainLayout" }}
89
-
<div class="px-1 col-span-full flex flex-col gap-4">
90
{{ block "contentLayout" . }}
91
{{ block "content" . }}{{ end }}
92
{{ end }}
···
105
{{ end }}
106
107
{{ define "footerLayout" }}
108
-
<footer class="px-1 col-span-full mt-12">
109
{{ template "layouts/fragments/footer" . }}
110
</footer>
111
{{ end }}
···
80
{{end}}
81
82
{{ define "topbarLayout" }}
83
+
<header class="col-span-full" style="z-index: 20;">
84
{{ template "layouts/fragments/topbar" . }}
85
</header>
86
{{ end }}
87
88
{{ define "mainLayout" }}
89
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
90
{{ block "contentLayout" . }}
91
{{ block "content" . }}{{ end }}
92
{{ end }}
···
105
{{ end }}
106
107
{{ define "footerLayout" }}
108
+
<footer class="col-span-full mt-12">
109
{{ template "layouts/fragments/footer" . }}
110
</footer>
111
{{ end }}
+7
appview/pages/templates/repo/fork.html
+7
appview/pages/templates/repo/fork.html
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
<fieldset class="space-y-3">
10
<legend class="dark:text-white">Select a knot to fork into</legend>
11
<div class="space-y-2">
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
+
10
+
<fieldset class="space-y-3">
11
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
+
</fieldset>
15
+
16
<fieldset class="space-y-3">
17
<legend class="dark:text-white">Select a knot to fork into</legend>
18
<div class="space-y-2">
+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
{{ $title := or .Title .RepoInfo.FullName }}
3
{{ $description := or .Description .RepoInfo.Description }}
4
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
-
6
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
<meta property="og:type" content="object" />
9
<meta property="og:url" content="{{ $url }}" />
10
<meta property="og:description" content="{{ $description }}" />
11
{{ end }}
···
2
{{ $title := or .Title .RepoInfo.FullName }}
3
{{ $description := or .Description .RepoInfo.Description }}
4
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }}
6
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
<meta property="og:type" content="object" />
9
<meta property="og:url" content="{{ $url }}" />
10
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
{{ end }}
+26
appview/pages/templates/repo/fragments/participants.html
+26
appview/pages/templates/repo/fragments/participants.html
···
···
1
+
{{ define "repo/fragments/participants" }}
2
+
{{ $all := . }}
3
+
{{ $ps := take $all 5 }}
4
+
<div class="px-2 md:px-0">
5
+
<div class="py-1 flex items-center text-sm">
6
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
+
</div>
9
+
<div class="flex items-center -space-x-3 mt-2">
10
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
+
{{ range $i, $p := $ps }}
12
+
<img
13
+
src="{{ tinyAvatar . }}"
14
+
alt=""
15
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
+
/>
17
+
{{ end }}
18
+
19
+
{{ if gt (len $all) 5 }}
20
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
+
+{{ sub (len $all) 5 }}
22
+
</span>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
+6
-1
appview/pages/templates/repo/fragments/reaction.html
+6
-1
appview/pages/templates/repo/fragments/reaction.html
···
2
<button
3
id="reactIndi-{{ .Kind }}"
4
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
-
leading-4 px-3 gap-1
6
{{ if eq .Count 0 }}
7
hidden
8
{{ end }}
···
20
dark:hover:border-gray-600
21
{{ end }}
22
"
23
{{ if .IsReacted }}
24
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
25
{{ else }}
···
2
<button
3
id="reactIndi-{{ .Kind }}"
4
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
+
leading-4 px-3 gap-1 relative group
6
{{ if eq .Count 0 }}
7
hidden
8
{{ end }}
···
20
dark:hover:border-gray-600
21
{{ end }}
22
"
23
+
{{ if gt (length .Users) 0 }}
24
+
title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}"
25
+
{{ else }}
26
+
title="{{ .Kind }}"
27
+
{{ end }}
28
{{ if .IsReacted }}
29
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
30
{{ else }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+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 }}
+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
</div>
139
</form>
140
{{ else }}
141
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center">
142
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
143
+
sign up
144
+
</a>
145
+
<span class="text-gray-500 dark:text-gray-400">or</span>
146
+
<a href="/login" class="underline">login</a>
147
+
to add to the discussion
148
</div>
149
{{ end }}
150
{{ end }}
+5
-29
appview/pages/templates/repo/issues/issue.html
+5
-29
appview/pages/templates/repo/issues/issue.html
···
22
"Defs" $.LabelDefs
23
"Subject" $.Issue.AtUri
24
"State" $.Issue.Labels) }}
25
-
{{ template "issueParticipants" . }}
26
</div>
27
</div>
28
{{ end }}
···
110
<div class="flex items-center gap-2">
111
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
{{ range $kind := .OrderedReactionKinds }}
113
{{
114
template "repo/fragments/reaction"
115
(dict
116
"Kind" $kind
117
-
"Count" (index $.Reactions $kind)
118
"IsReacted" (index $.UserReacted $kind)
119
-
"ThreadAt" $.Issue.AtUri)
120
}}
121
{{ end }}
122
</div>
123
{{ end }}
124
125
-
{{ define "issueParticipants" }}
126
-
{{ $all := .Issue.Participants }}
127
-
{{ $ps := take $all 5 }}
128
-
<div>
129
-
<div class="py-1 flex items-center text-sm">
130
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
131
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
132
-
</div>
133
-
<div class="flex items-center -space-x-3 mt-2">
134
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
135
-
{{ range $i, $p := $ps }}
136
-
<img
137
-
src="{{ tinyAvatar . }}"
138
-
alt=""
139
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
140
-
/>
141
-
{{ end }}
142
-
143
-
{{ if gt (len $all) 5 }}
144
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
145
-
+{{ sub (len $all) 5 }}
146
-
</span>
147
-
{{ end }}
148
-
</div>
149
-
</div>
150
-
{{ end }}
151
152
{{ define "repoAfter" }}
153
<div class="flex flex-col gap-4 mt-4">
···
22
"Defs" $.LabelDefs
23
"Subject" $.Issue.AtUri
24
"State" $.Issue.Labels) }}
25
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
26
</div>
27
</div>
28
{{ end }}
···
110
<div class="flex items-center gap-2">
111
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
{{ range $kind := .OrderedReactionKinds }}
113
+
{{ $reactionData := index $.Reactions $kind }}
114
{{
115
template "repo/fragments/reaction"
116
(dict
117
"Kind" $kind
118
+
"Count" $reactionData.Count
119
"IsReacted" (index $.UserReacted $kind)
120
+
"ThreadAt" $.Issue.AtUri
121
+
"Users" $reactionData.Users)
122
}}
123
{{ end }}
124
</div>
125
{{ end }}
126
127
128
{{ define "repoAfter" }}
129
<div class="flex flex-col gap-4 mt-4">
+2
-52
appview/pages/templates/repo/issues/issues.html
+2
-52
appview/pages/templates/repo/issues/issues.html
···
37
{{ end }}
38
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
83
-
{{ $state := .Labels }}
84
-
{{ range $k, $d := $.LabelDefs }}
85
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
86
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
87
-
{{ end }}
88
-
{{ end }}
89
-
</div>
90
-
</div>
91
-
{{ end }}
92
</div>
93
{{ block "pagination" . }} {{ end }}
94
{{ end }}
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
33
<span>comment</span>
34
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
</button>
36
+
{{ if .BranchDeleteStatus }}
37
+
<button
38
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
39
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
40
+
hx-swap="none"
41
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
42
+
{{ i "git-branch" "w-4 h-4" }}
43
+
<span>delete branch</span>
44
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
+
</button>
46
+
{{ end }}
47
{{ if and $isPushAllowed $isOpen $isLastRound }}
48
{{ $disabled := "" }}
49
{{ if $isConflicted }}
+4
-2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+4
-2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
66
<div class="flex items-center gap-2 mt-2">
67
{{ template "repo/fragments/reactionsPopUp" . }}
68
{{ range $kind := . }}
69
{{
70
template "repo/fragments/reaction"
71
(dict
72
"Kind" $kind
73
-
"Count" (index $.Reactions $kind)
74
"IsReacted" (index $.UserReacted $kind)
75
-
"ThreadAt" $.Pull.PullAt)
76
}}
77
{{ end }}
78
</div>
···
66
<div class="flex items-center gap-2 mt-2">
67
{{ template "repo/fragments/reactionsPopUp" . }}
68
{{ range $kind := . }}
69
+
{{ $reactionData := index $.Reactions $kind }}
70
{{
71
template "repo/fragments/reaction"
72
(dict
73
"Kind" $kind
74
+
"Count" $reactionData.Count
75
"IsReacted" (index $.UserReacted $kind)
76
+
"ThreadAt" $.Pull.PullAt
77
+
"Users" $reactionData.Users)
78
}}
79
{{ end }}
80
</div>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
3
id="pull-comment-card-{{ .RoundNumber }}"
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
7
</div>
8
<form
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
···
3
id="pull-comment-card-{{ .RoundNumber }}"
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
+
{{ resolve .LoggedInUser.Did }}
7
</div>
8
<form
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
···
28
29
{{ end }}
30
31
-
{{ define "topbarLayout" }}
32
-
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/fragments/topbar" . }}
34
-
</header>
35
-
{{ end }}
36
-
37
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex flex-col gap-4">
39
{{ block "contentLayout" . }}
40
{{ block "content" . }}{{ end }}
41
{{ end }}
···
52
{{ end }}
53
</div>
54
{{ end }}
55
-
56
-
{{ define "footerLayout" }}
57
-
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/fragments/footer" . }}
59
-
</footer>
60
-
{{ end }}
61
-
62
63
{{ define "contentAfter" }}
64
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
···
28
29
{{ end }}
30
31
{{ define "mainLayout" }}
32
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
33
{{ block "contentLayout" . }}
34
{{ block "content" . }}{{ end }}
35
{{ end }}
···
46
{{ end }}
47
</div>
48
{{ end }}
49
50
{{ define "contentAfter" }}
51
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1
-13
appview/pages/templates/repo/pulls/patch.html
+1
-13
appview/pages/templates/repo/pulls/patch.html
···
34
</section>
35
{{ end }}
36
37
-
{{ define "topbarLayout" }}
38
-
<header class="px-1 col-span-full" style="z-index: 20;">
39
-
{{ template "layouts/fragments/topbar" . }}
40
-
</header>
41
-
{{ end }}
42
-
43
{{ define "mainLayout" }}
44
-
<div class="px-1 col-span-full flex flex-col gap-4">
45
{{ block "contentLayout" . }}
46
{{ block "content" . }}{{ end }}
47
{{ end }}
···
57
</div>
58
{{ end }}
59
</div>
60
-
{{ end }}
61
-
62
-
{{ define "footerLayout" }}
63
-
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/fragments/footer" . }}
65
-
</footer>
66
{{ end }}
67
68
{{ define "contentAfter" }}
+47
-16
appview/pages/templates/repo/pulls/pull.html
+47
-16
appview/pages/templates/repo/pulls/pull.html
···
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
13
{{ define "repoContent" }}
14
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
{{ with $item }}
40
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
43
<!-- round number -->
44
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
</div>
47
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
<span class="gap-1 flex items-center">
50
{{ $owner := resolve $.Pull.OwnerDid }}
51
{{ $re := "re" }}
···
72
<span class="hidden md:inline">diff</span>
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
</a>
75
-
{{ if not (eq .RoundNumber 0) }}
76
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
-
hx-boost="true"
78
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
-
<span class="hidden md:inline">interdiff</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</a>
83
<span id="interdiff-error-{{.RoundNumber}}"></span>
84
-
{{ end }}
85
</div>
86
</summary>
87
···
146
147
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
{{ range $cidx, $c := .Comments }}
149
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
150
{{ if gt $cidx 0 }}
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
{{ end }}
···
169
{{ end }}
170
171
{{ if $.LoggedInUser }}
172
-
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
173
{{ else }}
174
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
175
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
176
-
<a href="/login" class="underline">login</a> to join the discussion
177
</div>
178
{{ end }}
179
</div>
···
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
+
{{ define "repoContentLayout" }}
13
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
14
+
<div class="col-span-1 md:col-span-8">
15
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
16
+
{{ block "repoContent" . }}{{ end }}
17
+
</section>
18
+
{{ block "repoAfter" . }}{{ end }}
19
+
</div>
20
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
21
+
{{ template "repo/fragments/labelPanel"
22
+
(dict "RepoInfo" $.RepoInfo
23
+
"Defs" $.LabelDefs
24
+
"Subject" $.Pull.PullAt
25
+
"State" $.Pull.Labels) }}
26
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
30
31
{{ define "repoContent" }}
32
{{ template "repo/pulls/fragments/pullHeader" . }}
···
57
{{ with $item }}
58
<details {{ if eq $idx $lastIdx }}open{{ end }}>
59
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
60
+
<div class="flex flex-wrap gap-2 items-stretch">
61
<!-- round number -->
62
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
63
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
64
</div>
65
<!-- round summary -->
66
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
67
<span class="gap-1 flex items-center">
68
{{ $owner := resolve $.Pull.OwnerDid }}
69
{{ $re := "re" }}
···
90
<span class="hidden md:inline">diff</span>
91
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
92
</a>
93
+
{{ if ne $idx 0 }}
94
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
95
+
hx-boost="true"
96
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
97
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
98
+
<span class="hidden md:inline">interdiff</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</a>
101
+
{{ end }}
102
<span id="interdiff-error-{{.RoundNumber}}"></span>
103
</div>
104
</summary>
105
···
164
165
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
166
{{ range $cidx, $c := .Comments }}
167
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
168
{{ if gt $cidx 0 }}
169
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
170
{{ end }}
···
187
{{ end }}
188
189
{{ if $.LoggedInUser }}
190
+
{{ template "repo/pulls/fragments/pullActions"
191
+
(dict
192
+
"LoggedInUser" $.LoggedInUser
193
+
"Pull" $.Pull
194
+
"RepoInfo" $.RepoInfo
195
+
"RoundNumber" .RoundNumber
196
+
"MergeCheck" $.MergeCheck
197
+
"ResubmitCheck" $.ResubmitCheck
198
+
"BranchDeleteStatus" $.BranchDeleteStatus
199
+
"Stack" $.Stack) }}
200
{{ else }}
201
+
<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">
202
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
203
+
sign up
204
+
</a>
205
+
<span class="text-gray-500 dark:text-gray-400">or</span>
206
+
<a href="/login" class="underline">login</a>
207
+
to add to the discussion
208
</div>
209
{{ end }}
210
</div>
+7
appview/pages/templates/repo/pulls/pulls.html
+7
appview/pages/templates/repo/pulls/pulls.html
···
108
<span class="before:content-['·']"></span>
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
{{ end }}
111
+
112
+
{{ $state := .Labels }}
113
+
{{ range $k, $d := $.LabelDefs }}
114
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
115
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
116
+
{{ end }}
117
+
{{ end }}
118
</div>
119
</div>
120
{{ if .StackId }}
+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
<div class="flex flex-col gap-4">
13
{{ template "timeline/fragments/hero" . }}
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
16
{{ template "timeline/fragments/trending" . }}
17
{{ template "timeline/fragments/timeline" . }}
18
<div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/login.html
+1
appview/pages/templates/user/login.html
···
8
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>login · tangled</title>
13
</head>
···
8
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>login · tangled</title>
14
</head>
+1
-3
appview/pages/templates/user/settings/profile.html
+1
-3
appview/pages/templates/user/settings/profile.html
···
33
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
<span>Handle</span>
35
</div>
36
-
{{ if .LoggedInUser.Handle }}
37
<span class="font-bold">
38
-
@{{ .LoggedInUser.Handle }}
39
</span>
40
-
{{ end }}
41
</div>
42
</div>
43
<div class="flex items-center justify-between p-4">
+3
-2
appview/pages/templates/user/signup.html
+3
-2
appview/pages/templates/user/signup.html
···
8
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>sign up · tangled</title>
13
···
41
invite code, desired username, and password in the next
42
page to complete your registration.
43
</span>
44
-
<div class="w-full mt-4">
45
-
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
46
</div>
47
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
48
<span>join now</span>
···
8
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>sign up · tangled</title>
14
···
42
invite code, desired username, and password in the next
43
page to complete your registration.
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
</div>
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
<span>join now</span>
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
+2
-1
appview/pipelines/pipelines.go
+2
-1
appview/pipelines/pipelines.go
+119
-30
appview/pulls/pulls.go
+119
-30
appview/pulls/pulls.go
···
98
}
99
100
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
101
resubmitResult := pages.Unknown
102
if user.Did == pull.OwnerDid {
103
resubmitResult = s.resubmitCheck(r, f, pull, stack)
104
}
105
106
s.pages.PullActionsFragment(w, pages.PullActionsParams{
107
-
LoggedInUser: user,
108
-
RepoInfo: f.RepoInfo(user),
109
-
Pull: pull,
110
-
RoundNumber: roundNumber,
111
-
MergeCheck: mergeCheckResponse,
112
-
ResubmitCheck: resubmitResult,
113
-
Stack: stack,
114
})
115
return
116
}
···
153
}
154
155
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
156
resubmitResult := pages.Unknown
157
if user != nil && user.Did == pull.OwnerDid {
158
resubmitResult = s.resubmitCheck(r, f, pull, stack)
···
189
m[p.Sha] = p
190
}
191
192
-
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
193
if err != nil {
194
log.Println("failed to get pull reactions")
195
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
201
}
202
203
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
204
-
LoggedInUser: user,
205
-
RepoInfo: repoInfo,
206
-
Pull: pull,
207
-
Stack: stack,
208
-
AbandonedPulls: abandonedPulls,
209
-
MergeCheck: mergeCheckResponse,
210
-
ResubmitCheck: resubmitResult,
211
-
Pipelines: m,
212
213
OrderedReactionKinds: models.OrderedReactionKinds,
214
-
Reactions: reactionCountMap,
215
UserReacted: userReactions,
216
})
217
}
218
···
283
return result
284
}
285
286
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
287
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
288
return pages.Unknown
···
557
m[p.Sha] = p
558
}
559
560
s.pages.RepoPulls(w, pages.RepoPullsParams{
561
LoggedInUser: s.oauth.GetUser(r),
562
RepoInfo: f.RepoInfo(user),
563
Pulls: pulls,
564
FilteringBy: state,
565
Stacks: stacks,
566
Pipelines: m,
···
630
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
631
return
632
}
633
-
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
634
Collection: tangled.RepoPullCommentNSID,
635
Repo: user.Did,
636
Rkey: tid.TID(),
···
1058
1059
// We've already checked earlier if it's diff-based and title is empty,
1060
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1061
-
if title == "" {
1062
formatPatches, err := patchutil.ExtractPatches(patch)
1063
if err != nil {
1064
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1069
return
1070
}
1071
1072
-
title = formatPatches[0].Title
1073
-
body = formatPatches[0].Body
1074
}
1075
1076
rkey := tid.TID()
···
1103
return
1104
}
1105
1106
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1107
Collection: tangled.RepoPullNSID,
1108
Repo: user.Did,
1109
Rkey: rkey,
···
1114
Repo: string(f.RepoAt()),
1115
Branch: targetBranch,
1116
},
1117
-
Patch: patch,
1118
-
Source: recordPullSource,
1119
},
1120
},
1121
})
···
1200
}
1201
writes = append(writes, &write)
1202
}
1203
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1204
Repo: user.Did,
1205
Writes: writes,
1206
})
···
1731
return
1732
}
1733
1734
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1735
if err != nil {
1736
// failed to get record
1737
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
···
1754
}
1755
}
1756
1757
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1758
Collection: tangled.RepoPullNSID,
1759
Repo: user.Did,
1760
Rkey: pull.Rkey,
···
1766
Repo: string(f.RepoAt()),
1767
Branch: pull.TargetBranch,
1768
},
1769
-
Patch: patch, // new patch
1770
-
Source: recordPullSource,
1771
},
1772
},
1773
})
···
2026
return
2027
}
2028
2029
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
2030
Repo: user.Did,
2031
Writes: writes,
2032
})
···
98
}
99
100
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
101
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
102
resubmitResult := pages.Unknown
103
if user.Did == pull.OwnerDid {
104
resubmitResult = s.resubmitCheck(r, f, pull, stack)
105
}
106
107
s.pages.PullActionsFragment(w, pages.PullActionsParams{
108
+
LoggedInUser: user,
109
+
RepoInfo: f.RepoInfo(user),
110
+
Pull: pull,
111
+
RoundNumber: roundNumber,
112
+
MergeCheck: mergeCheckResponse,
113
+
ResubmitCheck: resubmitResult,
114
+
BranchDeleteStatus: branchDeleteStatus,
115
+
Stack: stack,
116
})
117
return
118
}
···
155
}
156
157
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
158
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
159
resubmitResult := pages.Unknown
160
if user != nil && user.Did == pull.OwnerDid {
161
resubmitResult = s.resubmitCheck(r, f, pull, stack)
···
192
m[p.Sha] = p
193
}
194
195
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
196
if err != nil {
197
log.Println("failed to get pull reactions")
198
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
203
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
204
}
205
206
+
labelDefs, err := db.GetLabelDefinitions(
207
+
s.db,
208
+
db.FilterIn("at_uri", f.Repo.Labels),
209
+
db.FilterContains("scope", tangled.RepoPullNSID),
210
+
)
211
+
if err != nil {
212
+
log.Println("failed to fetch labels", err)
213
+
s.pages.Error503(w)
214
+
return
215
+
}
216
+
217
+
defs := make(map[string]*models.LabelDefinition)
218
+
for _, l := range labelDefs {
219
+
defs[l.AtUri().String()] = &l
220
+
}
221
+
222
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
223
+
LoggedInUser: user,
224
+
RepoInfo: repoInfo,
225
+
Pull: pull,
226
+
Stack: stack,
227
+
AbandonedPulls: abandonedPulls,
228
+
BranchDeleteStatus: branchDeleteStatus,
229
+
MergeCheck: mergeCheckResponse,
230
+
ResubmitCheck: resubmitResult,
231
+
Pipelines: m,
232
233
OrderedReactionKinds: models.OrderedReactionKinds,
234
+
Reactions: reactionMap,
235
UserReacted: userReactions,
236
+
237
+
LabelDefs: defs,
238
})
239
}
240
···
305
return result
306
}
307
308
+
func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
309
+
if pull.State != models.PullMerged {
310
+
return nil
311
+
}
312
+
313
+
user := s.oauth.GetUser(r)
314
+
if user == nil {
315
+
return nil
316
+
}
317
+
318
+
var branch string
319
+
var repo *models.Repo
320
+
// check if the branch exists
321
+
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
322
+
if pull.IsBranchBased() {
323
+
branch = pull.PullSource.Branch
324
+
repo = &f.Repo
325
+
} else if pull.IsForkBased() {
326
+
branch = pull.PullSource.Branch
327
+
repo = pull.PullSource.Repo
328
+
} else {
329
+
return nil
330
+
}
331
+
332
+
scheme := "http"
333
+
if !s.config.Core.Dev {
334
+
scheme = "https"
335
+
}
336
+
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
337
+
xrpcc := &indigoxrpc.Client{
338
+
Host: host,
339
+
}
340
+
341
+
resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name))
342
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
343
+
return nil
344
+
}
345
+
346
+
return &models.BranchDeleteStatus{
347
+
Repo: repo,
348
+
Branch: resp.Name,
349
+
}
350
+
}
351
+
352
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
353
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
354
return pages.Unknown
···
623
m[p.Sha] = p
624
}
625
626
+
labelDefs, err := db.GetLabelDefinitions(
627
+
s.db,
628
+
db.FilterIn("at_uri", f.Repo.Labels),
629
+
db.FilterContains("scope", tangled.RepoPullNSID),
630
+
)
631
+
if err != nil {
632
+
log.Println("failed to fetch labels", err)
633
+
s.pages.Error503(w)
634
+
return
635
+
}
636
+
637
+
defs := make(map[string]*models.LabelDefinition)
638
+
for _, l := range labelDefs {
639
+
defs[l.AtUri().String()] = &l
640
+
}
641
+
642
s.pages.RepoPulls(w, pages.RepoPullsParams{
643
LoggedInUser: s.oauth.GetUser(r),
644
RepoInfo: f.RepoInfo(user),
645
Pulls: pulls,
646
+
LabelDefs: defs,
647
FilteringBy: state,
648
Stacks: stacks,
649
Pipelines: m,
···
713
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
714
return
715
}
716
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
717
Collection: tangled.RepoPullCommentNSID,
718
Repo: user.Did,
719
Rkey: tid.TID(),
···
1141
1142
// We've already checked earlier if it's diff-based and title is empty,
1143
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1144
+
if title == "" || body == "" {
1145
formatPatches, err := patchutil.ExtractPatches(patch)
1146
if err != nil {
1147
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1152
return
1153
}
1154
1155
+
if title == "" {
1156
+
title = formatPatches[0].Title
1157
+
}
1158
+
if body == "" {
1159
+
body = formatPatches[0].Body
1160
+
}
1161
}
1162
1163
rkey := tid.TID()
···
1190
return
1191
}
1192
1193
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1194
Collection: tangled.RepoPullNSID,
1195
Repo: user.Did,
1196
Rkey: rkey,
···
1201
Repo: string(f.RepoAt()),
1202
Branch: targetBranch,
1203
},
1204
+
Patch: patch,
1205
+
Source: recordPullSource,
1206
+
CreatedAt: time.Now().Format(time.RFC3339),
1207
},
1208
},
1209
})
···
1288
}
1289
writes = append(writes, &write)
1290
}
1291
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1292
Repo: user.Did,
1293
Writes: writes,
1294
})
···
1819
return
1820
}
1821
1822
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1823
if err != nil {
1824
// failed to get record
1825
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
···
1842
}
1843
}
1844
1845
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1846
Collection: tangled.RepoPullNSID,
1847
Repo: user.Did,
1848
Rkey: pull.Rkey,
···
1854
Repo: string(f.RepoAt()),
1855
Branch: pull.TargetBranch,
1856
},
1857
+
Patch: patch, // new patch
1858
+
Source: recordPullSource,
1859
+
CreatedAt: time.Now().Format(time.RFC3339),
1860
},
1861
},
1862
})
···
2115
return
2116
}
2117
2118
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2119
Repo: user.Did,
2120
Writes: writes,
2121
})
+11
-10
appview/repo/artifact.go
+11
-10
appview/repo/artifact.go
···
10
"net/url"
11
"time"
12
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
-
"github.com/dustin/go-humanize"
17
-
"github.com/go-chi/chi/v5"
18
-
"github.com/go-git/go-git/v5/plumbing"
19
-
"github.com/ipfs/go-cid"
20
"tangled.org/core/api/tangled"
21
"tangled.org/core/appview/db"
22
"tangled.org/core/appview/models"
···
25
"tangled.org/core/appview/xrpcclient"
26
"tangled.org/core/tid"
27
"tangled.org/core/types"
28
)
29
30
// TODO: proper statuses here on early exit
···
60
return
61
}
62
63
-
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
64
if err != nil {
65
log.Println("failed to upload blob", err)
66
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
72
rkey := tid.TID()
73
createdAt := time.Now()
74
75
-
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
76
Collection: tangled.RepoArtifactNSID,
77
Repo: user.Did,
78
Rkey: rkey,
···
249
return
250
}
251
252
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
253
Collection: tangled.RepoArtifactNSID,
254
Repo: user.Did,
255
Rkey: artifact.Rkey,
···
10
"net/url"
11
"time"
12
13
"tangled.org/core/api/tangled"
14
"tangled.org/core/appview/db"
15
"tangled.org/core/appview/models"
···
18
"tangled.org/core/appview/xrpcclient"
19
"tangled.org/core/tid"
20
"tangled.org/core/types"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
lexutil "github.com/bluesky-social/indigo/lex/util"
24
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
25
+
"github.com/dustin/go-humanize"
26
+
"github.com/go-chi/chi/v5"
27
+
"github.com/go-git/go-git/v5/plumbing"
28
+
"github.com/ipfs/go-cid"
29
)
30
31
// TODO: proper statuses here on early exit
···
61
return
62
}
63
64
+
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
65
if err != nil {
66
log.Println("failed to upload blob", err)
67
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
73
rkey := tid.TID()
74
createdAt := time.Now()
75
76
+
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
77
Collection: tangled.RepoArtifactNSID,
78
Repo: user.Did,
79
Rkey: rkey,
···
250
return
251
}
252
253
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
254
Collection: tangled.RepoArtifactNSID,
255
Repo: user.Did,
256
Rkey: artifact.Rkey,
+12
-1
appview/repo/index.go
+12
-1
appview/repo/index.go
···
200
})
201
}
202
203
+
tx, err := rp.db.Begin()
204
+
if err != nil {
205
+
return nil, err
206
+
}
207
+
defer tx.Rollback()
208
+
209
// update appview's cache
210
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
211
if err != nil {
212
// non-fatal
213
log.Println("failed to cache lang results", err)
214
+
}
215
+
216
+
err = tx.Commit()
217
+
if err != nil {
218
+
return nil, err
219
}
220
}
221
+500
appview/repo/ogcard/card.go
+500
appview/repo/ogcard/card.go
···
···
1
+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
+
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
+
// SPDX-License-Identifier: MIT
4
+
5
+
package ogcard
6
+
7
+
import (
8
+
"bytes"
9
+
"fmt"
10
+
"image"
11
+
"image/color"
12
+
"io"
13
+
"log"
14
+
"math"
15
+
"net/http"
16
+
"strings"
17
+
"sync"
18
+
"time"
19
+
20
+
"github.com/goki/freetype"
21
+
"github.com/goki/freetype/truetype"
22
+
"github.com/srwiley/oksvg"
23
+
"github.com/srwiley/rasterx"
24
+
"golang.org/x/image/draw"
25
+
"golang.org/x/image/font"
26
+
"tangled.org/core/appview/pages"
27
+
28
+
_ "golang.org/x/image/webp" // for processing webp images
29
+
)
30
+
31
+
type Card struct {
32
+
Img *image.RGBA
33
+
Font *truetype.Font
34
+
Margin int
35
+
Width int
36
+
Height int
37
+
}
38
+
39
+
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
+
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
+
if err != nil {
42
+
return nil, err
43
+
}
44
+
return truetype.Parse(interVar)
45
+
})
46
+
47
+
// DefaultSize returns the default size for a card
48
+
func DefaultSize() (int, int) {
49
+
return 1200, 600
50
+
}
51
+
52
+
// NewCard creates a new card with the given dimensions in pixels
53
+
func NewCard(width, height int) (*Card, error) {
54
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
+
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
+
57
+
font, err := fontCache()
58
+
if err != nil {
59
+
return nil, err
60
+
}
61
+
62
+
return &Card{
63
+
Img: img,
64
+
Font: font,
65
+
Margin: 0,
66
+
Width: width,
67
+
Height: height,
68
+
}, nil
69
+
}
70
+
71
+
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
+
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
+
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
+
bounds := c.Img.Bounds()
75
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
+
if vertical {
77
+
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
+
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
+
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
+
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
+
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
+
}
83
+
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
+
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
+
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
+
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
+
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
+
}
89
+
90
+
// SetMargin sets the margins for the card
91
+
func (c *Card) SetMargin(margin int) {
92
+
c.Margin = margin
93
+
}
94
+
95
+
type (
96
+
VAlign int64
97
+
HAlign int64
98
+
)
99
+
100
+
const (
101
+
Top VAlign = iota
102
+
Middle
103
+
Bottom
104
+
)
105
+
106
+
const (
107
+
Left HAlign = iota
108
+
Center
109
+
Right
110
+
)
111
+
112
+
// DrawText draws text within the card, respecting margins and alignment
113
+
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
+
ft := freetype.NewContext()
115
+
ft.SetDPI(72)
116
+
ft.SetFont(c.Font)
117
+
ft.SetFontSize(sizePt)
118
+
ft.SetClip(c.Img.Bounds())
119
+
ft.SetDst(c.Img)
120
+
ft.SetSrc(image.NewUniform(textColor))
121
+
122
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
+
125
+
bounds := c.Img.Bounds()
126
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
+
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
+
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
+
130
+
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
+
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
+
// knowing the total height, which is related to how many lines we'll have.
133
+
lines := make([]string, 0)
134
+
textWords := strings.Split(text, " ")
135
+
currentLine := ""
136
+
heightTotal := 0
137
+
138
+
for {
139
+
if len(textWords) == 0 {
140
+
// Ran out of words.
141
+
if currentLine != "" {
142
+
heightTotal += fontHeight
143
+
lines = append(lines, currentLine)
144
+
}
145
+
break
146
+
}
147
+
148
+
nextWord := textWords[0]
149
+
proposedLine := currentLine
150
+
if proposedLine != "" {
151
+
proposedLine += " "
152
+
}
153
+
proposedLine += nextWord
154
+
155
+
proposedLineWidth := font.MeasureString(face, proposedLine)
156
+
if proposedLineWidth.Ceil() > boxWidth {
157
+
// no, proposed line is too big; we'll use the last "currentLine"
158
+
heightTotal += fontHeight
159
+
if currentLine != "" {
160
+
lines = append(lines, currentLine)
161
+
currentLine = ""
162
+
// leave nextWord in textWords and keep going
163
+
} else {
164
+
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
+
// regardless as a line by itself. It will be clipped by the drawing routine.
166
+
lines = append(lines, nextWord)
167
+
textWords = textWords[1:]
168
+
}
169
+
} else {
170
+
// yes, it will fit
171
+
currentLine = proposedLine
172
+
textWords = textWords[1:]
173
+
}
174
+
}
175
+
176
+
textY := 0
177
+
switch valign {
178
+
case Top:
179
+
textY = fontHeight
180
+
case Bottom:
181
+
textY = boxHeight - heightTotal + fontHeight
182
+
case Middle:
183
+
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
+
}
185
+
186
+
for _, line := range lines {
187
+
lineWidth := font.MeasureString(face, line)
188
+
189
+
textX := 0
190
+
switch halign {
191
+
case Left:
192
+
textX = 0
193
+
case Right:
194
+
textX = boxWidth - lineWidth.Ceil()
195
+
case Center:
196
+
textX = (boxWidth - lineWidth.Ceil()) / 2
197
+
}
198
+
199
+
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
+
_, err := ft.DrawString(line, pt)
201
+
if err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
textY += fontHeight
206
+
}
207
+
208
+
return lines, nil
209
+
}
210
+
211
+
// DrawTextAt draws text at a specific position with the given alignment
212
+
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
+
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
+
return err
215
+
}
216
+
217
+
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
+
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
+
ft := freetype.NewContext()
220
+
ft.SetDPI(72)
221
+
ft.SetFont(c.Font)
222
+
ft.SetFontSize(sizePt)
223
+
ft.SetClip(c.Img.Bounds())
224
+
ft.SetDst(c.Img)
225
+
ft.SetSrc(image.NewUniform(textColor))
226
+
227
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
+
lineWidth := font.MeasureString(face, text)
230
+
textWidth := lineWidth.Ceil()
231
+
232
+
// Adjust position based on alignment
233
+
adjustedX := x
234
+
adjustedY := y
235
+
236
+
switch halign {
237
+
case Left:
238
+
// x is already at the left position
239
+
case Right:
240
+
adjustedX = x - textWidth
241
+
case Center:
242
+
adjustedX = x - textWidth/2
243
+
}
244
+
245
+
switch valign {
246
+
case Top:
247
+
adjustedY = y + fontHeight
248
+
case Bottom:
249
+
adjustedY = y
250
+
case Middle:
251
+
adjustedY = y + fontHeight/2
252
+
}
253
+
254
+
pt := freetype.Pt(adjustedX, adjustedY)
255
+
_, err := ft.DrawString(text, pt)
256
+
return textWidth, err
257
+
}
258
+
259
+
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
+
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
+
// Draw the text multiple times with slight offsets to create bold effect
262
+
offsets := []struct{ dx, dy int }{
263
+
{0, 0}, // original
264
+
{1, 0}, // right
265
+
{0, 1}, // down
266
+
{1, 1}, // diagonal
267
+
}
268
+
269
+
var width int
270
+
for _, offset := range offsets {
271
+
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
+
if err != nil {
273
+
return 0, err
274
+
}
275
+
if width == 0 {
276
+
width = w
277
+
}
278
+
}
279
+
return width, nil
280
+
}
281
+
282
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
+
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
+
svgData, err := pages.Files.ReadFile(svgPath)
285
+
if err != nil {
286
+
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
+
}
288
+
289
+
// Convert color to hex string for SVG
290
+
rgba, isRGBA := iconColor.(color.RGBA)
291
+
if !isRGBA {
292
+
r, g, b, a := iconColor.RGBA()
293
+
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
+
}
295
+
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
+
297
+
// Replace currentColor with our desired color in the SVG
298
+
svgString := string(svgData)
299
+
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
+
301
+
// Make the stroke thicker
302
+
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
+
304
+
// Parse SVG
305
+
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
+
if err != nil {
307
+
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
+
}
309
+
310
+
// Set the icon size
311
+
w, h := float64(size), float64(size)
312
+
icon.SetTarget(0, 0, w, h)
313
+
314
+
// Create a temporary RGBA image for the icon
315
+
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
+
317
+
// Create scanner and rasterizer
318
+
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
+
raster := rasterx.NewDasher(size, size, scanner)
320
+
321
+
// Draw the icon
322
+
icon.Draw(raster, 1.0)
323
+
324
+
// Draw the icon onto the card at the specified position
325
+
bounds := c.Img.Bounds()
326
+
destRect := image.Rect(x, y, x+size, y+size)
327
+
328
+
// Make sure we don't draw outside the card bounds
329
+
if destRect.Max.X > bounds.Max.X {
330
+
destRect.Max.X = bounds.Max.X
331
+
}
332
+
if destRect.Max.Y > bounds.Max.Y {
333
+
destRect.Max.Y = bounds.Max.Y
334
+
}
335
+
336
+
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
+
338
+
return nil
339
+
}
340
+
341
+
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
+
func (c *Card) DrawImage(img image.Image) {
343
+
bounds := c.Img.Bounds()
344
+
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
+
srcBounds := img.Bounds()
346
+
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
+
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
+
349
+
var scale float64
350
+
if srcAspect > targetAspect {
351
+
// Image is wider than target, scale by width
352
+
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
+
} else {
354
+
// Image is taller or equal, scale by height
355
+
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
+
}
357
+
358
+
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
+
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
+
361
+
// Center the image within the target rectangle
362
+
offsetX := (targetRect.Dx() - newWidth) / 2
363
+
offsetY := (targetRect.Dy() - newHeight) / 2
364
+
365
+
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
+
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
+
}
368
+
369
+
func fallbackImage() image.Image {
370
+
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
+
img.Set(0, 0, color.White)
373
+
return img
374
+
}
375
+
376
+
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
+
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
+
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
+
// this rendering process to be slowed down
380
+
client := &http.Client{
381
+
Timeout: 1 * time.Second, // 1 second timeout
382
+
}
383
+
384
+
resp, err := client.Get(url)
385
+
if err != nil {
386
+
log.Printf("error when fetching external image from %s: %v", url, err)
387
+
return nil, false
388
+
}
389
+
defer resp.Body.Close()
390
+
391
+
if resp.StatusCode != http.StatusOK {
392
+
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
+
return nil, false
394
+
}
395
+
396
+
contentType := resp.Header.Get("Content-Type")
397
+
// Support content types are in-sync with the allowed custom avatar file types
398
+
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
399
+
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
400
+
return nil, false
401
+
}
402
+
403
+
body := resp.Body
404
+
bodyBytes, err := io.ReadAll(body)
405
+
if err != nil {
406
+
log.Printf("error when fetching external image from %s: %v", url, err)
407
+
return nil, false
408
+
}
409
+
410
+
bodyBuffer := bytes.NewReader(bodyBytes)
411
+
_, imgType, err := image.DecodeConfig(bodyBuffer)
412
+
if err != nil {
413
+
log.Printf("error when decoding external image from %s: %v", url, err)
414
+
return nil, false
415
+
}
416
+
417
+
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
418
+
if (contentType == "image/png" && imgType != "png") ||
419
+
(contentType == "image/jpeg" && imgType != "jpeg") ||
420
+
(contentType == "image/gif" && imgType != "gif") ||
421
+
(contentType == "image/webp" && imgType != "webp") {
422
+
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
423
+
return nil, false
424
+
}
425
+
426
+
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
427
+
if err != nil {
428
+
log.Printf("error w/ bodyBuffer.Seek")
429
+
return nil, false
430
+
}
431
+
img, _, err := image.Decode(bodyBuffer)
432
+
if err != nil {
433
+
log.Printf("error when decoding external image from %s: %v", url, err)
434
+
return nil, false
435
+
}
436
+
437
+
return img, true
438
+
}
439
+
440
+
func (c *Card) DrawExternalImage(url string) {
441
+
image, ok := c.fetchExternalImage(url)
442
+
if !ok {
443
+
image = fallbackImage()
444
+
}
445
+
c.DrawImage(image)
446
+
}
447
+
448
+
// DrawCircularExternalImage draws an external image as a circle at the specified position
449
+
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
450
+
img, ok := c.fetchExternalImage(url)
451
+
if !ok {
452
+
img = fallbackImage()
453
+
}
454
+
455
+
// Create a circular mask
456
+
circle := image.NewRGBA(image.Rect(0, 0, size, size))
457
+
center := size / 2
458
+
radius := float64(size / 2)
459
+
460
+
// Scale the source image to fit the circle
461
+
srcBounds := img.Bounds()
462
+
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
463
+
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
464
+
465
+
// Draw the image with circular clipping
466
+
for cy := 0; cy < size; cy++ {
467
+
for cx := 0; cx < size; cx++ {
468
+
// Calculate distance from center
469
+
dx := float64(cx - center)
470
+
dy := float64(cy - center)
471
+
distance := math.Sqrt(dx*dx + dy*dy)
472
+
473
+
// Only draw pixels within the circle
474
+
if distance <= radius {
475
+
circle.Set(cx, cy, scaledImg.At(cx, cy))
476
+
}
477
+
}
478
+
}
479
+
480
+
// Draw the circle onto the card
481
+
bounds := c.Img.Bounds()
482
+
destRect := image.Rect(x, y, x+size, y+size)
483
+
484
+
// Make sure we don't draw outside the card bounds
485
+
if destRect.Max.X > bounds.Max.X {
486
+
destRect.Max.X = bounds.Max.X
487
+
}
488
+
if destRect.Max.Y > bounds.Max.Y {
489
+
destRect.Max.Y = bounds.Max.Y
490
+
}
491
+
492
+
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
493
+
494
+
return nil
495
+
}
496
+
497
+
// DrawRect draws a rect with the given color
498
+
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
499
+
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
500
+
}
+376
appview/repo/opengraph.go
+376
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/repo/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
+
// Split main content: 50% for name/description, 50% for spacing
39
+
topSection, _ := mainContent.Split(false, 50)
40
+
41
+
// Split top section: 40% for repo name, 60% for description
42
+
repoNameCard, descriptionCard := topSection.Split(false, 50)
43
+
44
+
// Draw repo name with owner in regular and repo name in bold
45
+
repoNameCard.SetMargin(10)
46
+
var ownerHandle string
47
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
48
+
if err != nil {
49
+
ownerHandle = repo.Did
50
+
} else {
51
+
ownerHandle = "@" + owner.Handle.String()
52
+
}
53
+
54
+
// Draw repo name with wrapping support
55
+
repoNameCard.SetMargin(10)
56
+
bounds := repoNameCard.Img.Bounds()
57
+
startX := bounds.Min.X + repoNameCard.Margin
58
+
startY := bounds.Min.Y + repoNameCard.Margin
59
+
currentX := startX
60
+
textColor := color.RGBA{88, 96, 105, 255}
61
+
62
+
// Draw owner handle in gray
63
+
ownerWidth, err := repoNameCard.DrawTextAtWithWidth(ownerHandle, currentX, startY, textColor, 54, ogcard.Top, ogcard.Left)
64
+
if err != nil {
65
+
return nil, err
66
+
}
67
+
currentX += ownerWidth
68
+
69
+
// Draw separator
70
+
sepWidth, err := repoNameCard.DrawTextAtWithWidth(" / ", currentX, startY, textColor, 54, ogcard.Top, ogcard.Left)
71
+
if err != nil {
72
+
return nil, err
73
+
}
74
+
currentX += sepWidth
75
+
76
+
// Draw repo name in bold
77
+
_, err = repoNameCard.DrawBoldText(repo.Name, currentX, startY, color.Black, 54, ogcard.Top, ogcard.Left)
78
+
if err != nil {
79
+
return nil, err
80
+
}
81
+
82
+
// Draw description (DrawText handles multi-line wrapping automatically)
83
+
descriptionCard.SetMargin(10)
84
+
description := repo.Description
85
+
if len(description) > 70 {
86
+
description = description[:70] + "…"
87
+
}
88
+
89
+
_, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left)
90
+
if err != nil {
91
+
log.Printf("failed to draw description: %v", err)
92
+
return nil, err
93
+
}
94
+
95
+
// Draw avatar circle on the right side
96
+
avatarBounds := avatarArea.Img.Bounds()
97
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
98
+
if avatarSize > 220 {
99
+
avatarSize = 220
100
+
}
101
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
102
+
avatarY := avatarBounds.Min.Y + 20
103
+
104
+
// Get avatar URL and draw it
105
+
avatarURL := rp.pages.AvatarUrl(ownerHandle, "256")
106
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
107
+
if err != nil {
108
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
109
+
}
110
+
111
+
// Split bottom area: icons area (65%) and language bar (35%)
112
+
iconsArea, languageBarCard := bottomArea.Split(false, 75)
113
+
114
+
// Split icons area: left side for stats (80%), right side for dolly (20%)
115
+
statsArea, dollyArea := iconsArea.Split(true, 80)
116
+
117
+
// Draw stats with icons in the stats area
118
+
starsText := repo.RepoStats.StarCount
119
+
issuesText := repo.RepoStats.IssueCount.Open
120
+
pullRequestsText := repo.RepoStats.PullCount.Open
121
+
122
+
iconColor := color.RGBA{88, 96, 105, 255}
123
+
iconSize := 36
124
+
textSize := 36.0
125
+
126
+
// Position stats in the middle of the stats area
127
+
statsBounds := statsArea.Img.Bounds()
128
+
statsX := statsBounds.Min.X + 60 // left padding
129
+
statsY := statsBounds.Min.Y
130
+
currentX = statsX
131
+
labelSize := 22.0
132
+
// Draw star icon, count, and label
133
+
// Align icon baseline with text baseline
134
+
iconBaselineOffset := int(textSize) / 2
135
+
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
136
+
if err != nil {
137
+
log.Printf("failed to draw star icon: %v", err)
138
+
}
139
+
starIconX := currentX
140
+
currentX += iconSize + 15
141
+
142
+
starText := fmt.Sprintf("%d", starsText)
143
+
err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
144
+
if err != nil {
145
+
log.Printf("failed to draw star text: %v", err)
146
+
}
147
+
starTextWidth := len(starText) * 20
148
+
starGroupWidth := iconSize + 15 + starTextWidth
149
+
150
+
// Draw "stars" label below and centered under the icon+text group
151
+
labelY := statsY + iconSize + 15
152
+
labelX := starIconX + starGroupWidth/2
153
+
err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
154
+
if err != nil {
155
+
log.Printf("failed to draw stars label: %v", err)
156
+
}
157
+
158
+
currentX += starTextWidth + 50
159
+
160
+
// Draw issues icon, count, and label
161
+
issueStartX := currentX
162
+
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
163
+
if err != nil {
164
+
log.Printf("failed to draw circle-dot icon: %v", err)
165
+
}
166
+
currentX += iconSize + 15
167
+
168
+
issueText := fmt.Sprintf("%d", issuesText)
169
+
err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
170
+
if err != nil {
171
+
log.Printf("failed to draw issue text: %v", err)
172
+
}
173
+
issueTextWidth := len(issueText) * 20
174
+
issueGroupWidth := iconSize + 15 + issueTextWidth
175
+
176
+
// Draw "issues" label below and centered under the icon+text group
177
+
labelX = issueStartX + issueGroupWidth/2
178
+
err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
179
+
if err != nil {
180
+
log.Printf("failed to draw issues label: %v", err)
181
+
}
182
+
183
+
currentX += issueTextWidth + 50
184
+
185
+
// Draw pull request icon, count, and label
186
+
prStartX := currentX
187
+
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
188
+
if err != nil {
189
+
log.Printf("failed to draw git-pull-request icon: %v", err)
190
+
}
191
+
currentX += iconSize + 15
192
+
193
+
prText := fmt.Sprintf("%d", pullRequestsText)
194
+
err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
195
+
if err != nil {
196
+
log.Printf("failed to draw PR text: %v", err)
197
+
}
198
+
prTextWidth := len(prText) * 20
199
+
prGroupWidth := iconSize + 15 + prTextWidth
200
+
201
+
// Draw "pulls" label below and centered under the icon+text group
202
+
labelX = prStartX + prGroupWidth/2
203
+
err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
204
+
if err != nil {
205
+
log.Printf("failed to draw pulls label: %v", err)
206
+
}
207
+
208
+
dollyBounds := dollyArea.Img.Bounds()
209
+
dollySize := 90
210
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
211
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
212
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
213
+
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
214
+
if err != nil {
215
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
216
+
}
217
+
218
+
// Draw language bar at bottom
219
+
err = drawLanguagesCard(languageBarCard, languageStats)
220
+
if err != nil {
221
+
log.Printf("failed to draw language bar: %v", err)
222
+
return nil, err
223
+
}
224
+
225
+
return mainCard, nil
226
+
}
227
+
228
+
// hexToColor converts a hex color to a go color
229
+
func hexToColor(colorStr string) (*color.RGBA, error) {
230
+
colorStr = strings.TrimLeft(colorStr, "#")
231
+
232
+
b, err := hex.DecodeString(colorStr)
233
+
if err != nil {
234
+
return nil, err
235
+
}
236
+
237
+
if len(b) < 3 {
238
+
return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
239
+
}
240
+
241
+
clr := color.RGBA{b[0], b[1], b[2], 255}
242
+
243
+
return &clr, nil
244
+
}
245
+
246
+
func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error {
247
+
bounds := card.Img.Bounds()
248
+
cardWidth := bounds.Dx()
249
+
250
+
if len(languageStats) == 0 {
251
+
// Draw a light gray bar if no languages detected
252
+
card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255})
253
+
return nil
254
+
}
255
+
256
+
// Limit to top 5 languages for the visual bar
257
+
displayLanguages := languageStats
258
+
if len(displayLanguages) > 5 {
259
+
displayLanguages = displayLanguages[:5]
260
+
}
261
+
262
+
currentX := bounds.Min.X
263
+
264
+
for _, lang := range displayLanguages {
265
+
var langColor *color.RGBA
266
+
var err error
267
+
268
+
if lang.Color != "" {
269
+
langColor, err = hexToColor(lang.Color)
270
+
if err != nil {
271
+
// Fallback to a default color
272
+
langColor = &color.RGBA{149, 157, 165, 255}
273
+
}
274
+
} else {
275
+
// Default color if no color specified
276
+
langColor = &color.RGBA{149, 157, 165, 255}
277
+
}
278
+
279
+
langWidth := float32(cardWidth) * (lang.Percentage / 100)
280
+
card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor)
281
+
currentX += int(langWidth)
282
+
}
283
+
284
+
// Fill remaining space with the last color (if any gap due to rounding)
285
+
if currentX < bounds.Max.X && len(displayLanguages) > 0 {
286
+
lastLang := displayLanguages[len(displayLanguages)-1]
287
+
var lastColor *color.RGBA
288
+
var err error
289
+
290
+
if lastLang.Color != "" {
291
+
lastColor, err = hexToColor(lastLang.Color)
292
+
if err != nil {
293
+
lastColor = &color.RGBA{149, 157, 165, 255}
294
+
}
295
+
} else {
296
+
lastColor = &color.RGBA{149, 157, 165, 255}
297
+
}
298
+
card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor)
299
+
}
300
+
301
+
return nil
302
+
}
303
+
304
+
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
305
+
f, err := rp.repoResolver.Resolve(r)
306
+
if err != nil {
307
+
log.Println("failed to get repo and knot", err)
308
+
return
309
+
}
310
+
311
+
// Get language stats directly from database
312
+
var languageStats []types.RepoLanguageDetails
313
+
langs, err := db.GetRepoLanguages(
314
+
rp.db,
315
+
db.FilterEq("repo_at", f.RepoAt()),
316
+
db.FilterEq("is_default_ref", 1),
317
+
)
318
+
if err != nil {
319
+
log.Printf("failed to get language stats from db: %v", err)
320
+
// non-fatal, continue without language stats
321
+
} else if len(langs) > 0 {
322
+
var total int64
323
+
for _, l := range langs {
324
+
total += l.Bytes
325
+
}
326
+
327
+
for _, l := range langs {
328
+
percentage := float32(l.Bytes) / float32(total) * 100
329
+
color := enry.GetColor(l.Language)
330
+
languageStats = append(languageStats, types.RepoLanguageDetails{
331
+
Name: l.Language,
332
+
Percentage: percentage,
333
+
Color: color,
334
+
})
335
+
}
336
+
337
+
sort.Slice(languageStats, func(i, j int) bool {
338
+
if languageStats[i].Name == enry.OtherLanguage {
339
+
return false
340
+
}
341
+
if languageStats[j].Name == enry.OtherLanguage {
342
+
return true
343
+
}
344
+
if languageStats[i].Percentage != languageStats[j].Percentage {
345
+
return languageStats[i].Percentage > languageStats[j].Percentage
346
+
}
347
+
return languageStats[i].Name < languageStats[j].Name
348
+
})
349
+
}
350
+
351
+
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
352
+
if err != nil {
353
+
log.Println("failed to draw repo summary card", err)
354
+
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
355
+
return
356
+
}
357
+
358
+
var imageBuffer bytes.Buffer
359
+
err = png.Encode(&imageBuffer, card.Img)
360
+
if err != nil {
361
+
log.Println("failed to encode repo summary card", err)
362
+
http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError)
363
+
return
364
+
}
365
+
366
+
imageBytes := imageBuffer.Bytes()
367
+
368
+
w.Header().Set("Content-Type", "image/png")
369
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
370
+
w.WriteHeader(http.StatusOK)
371
+
_, err = w.Write(imageBytes)
372
+
if err != nil {
373
+
log.Println("failed to write repo summary card", err)
374
+
return
375
+
}
376
+
}
+86
-42
appview/repo/repo.go
+86
-42
appview/repo/repo.go
···
17
"strings"
18
"time"
19
20
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
-
lexutil "github.com/bluesky-social/indigo/lex/util"
22
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
"tangled.org/core/api/tangled"
24
"tangled.org/core/appview/commitverify"
25
"tangled.org/core/appview/config"
···
40
"tangled.org/core/types"
41
"tangled.org/core/xrpc/serviceauth"
42
43
securejoin "github.com/cyphar/filepath-securejoin"
44
"github.com/go-chi/chi/v5"
45
"github.com/go-git/go-git/v5/plumbing"
46
-
47
-
"github.com/bluesky-social/indigo/atproto/syntax"
48
)
49
50
type Repo struct {
···
307
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308
//
309
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
311
if err != nil {
312
// failed to get record
313
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
314
return
315
}
316
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
317
Collection: tangled.RepoNSID,
318
Repo: newRepo.Did,
319
Rkey: newRepo.Rkey,
···
628
})
629
}
630
631
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
632
f, err := rp.repoResolver.Resolve(r)
633
if err != nil {
···
863
user := rp.oauth.GetUser(r)
864
l := rp.logger.With("handler", "EditSpindle")
865
l = l.With("did", user.Did)
866
-
l = l.With("handle", user.Handle)
867
868
errorId := "operation-error"
869
fail := func(msg string, err error) {
···
916
return
917
}
918
919
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
920
if err != nil {
921
fail("Failed to update spindle, no record found on PDS.", err)
922
return
923
}
924
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
925
Collection: tangled.RepoNSID,
926
Repo: newRepo.Did,
927
Rkey: newRepo.Rkey,
···
951
user := rp.oauth.GetUser(r)
952
l := rp.logger.With("handler", "AddLabel")
953
l = l.With("did", user.Did)
954
-
l = l.With("handle", user.Handle)
955
956
f, err := rp.repoResolver.Resolve(r)
957
if err != nil {
···
1020
1021
// emit a labelRecord
1022
labelRecord := label.AsRecord()
1023
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1024
Collection: tangled.LabelDefinitionNSID,
1025
Repo: label.Did,
1026
Rkey: label.Rkey,
···
1043
newRepo.Labels = append(newRepo.Labels, aturi)
1044
repoRecord := newRepo.AsRecord()
1045
1046
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1047
if err != nil {
1048
fail("Failed to update labels, no record found on PDS.", err)
1049
return
1050
}
1051
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1052
Collection: tangled.RepoNSID,
1053
Repo: newRepo.Did,
1054
Rkey: newRepo.Rkey,
···
1111
user := rp.oauth.GetUser(r)
1112
l := rp.logger.With("handler", "DeleteLabel")
1113
l = l.With("did", user.Did)
1114
-
l = l.With("handle", user.Handle)
1115
1116
f, err := rp.repoResolver.Resolve(r)
1117
if err != nil {
···
1141
}
1142
1143
// delete label record from PDS
1144
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1145
Collection: tangled.LabelDefinitionNSID,
1146
Repo: label.Did,
1147
Rkey: label.Rkey,
···
1163
newRepo.Labels = updated
1164
repoRecord := newRepo.AsRecord()
1165
1166
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1167
if err != nil {
1168
fail("Failed to update labels, no record found on PDS.", err)
1169
return
1170
}
1171
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1172
Collection: tangled.RepoNSID,
1173
Repo: newRepo.Did,
1174
Rkey: newRepo.Rkey,
···
1220
user := rp.oauth.GetUser(r)
1221
l := rp.logger.With("handler", "SubscribeLabel")
1222
l = l.With("did", user.Did)
1223
-
l = l.With("handle", user.Handle)
1224
1225
f, err := rp.repoResolver.Resolve(r)
1226
if err != nil {
···
1261
return
1262
}
1263
1264
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1265
if err != nil {
1266
fail("Failed to update labels, no record found on PDS.", err)
1267
return
1268
}
1269
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1270
Collection: tangled.RepoNSID,
1271
Repo: newRepo.Did,
1272
Rkey: newRepo.Rkey,
···
1307
user := rp.oauth.GetUser(r)
1308
l := rp.logger.With("handler", "UnsubscribeLabel")
1309
l = l.With("did", user.Did)
1310
-
l = l.With("handle", user.Handle)
1311
1312
f, err := rp.repoResolver.Resolve(r)
1313
if err != nil {
···
1350
return
1351
}
1352
1353
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1354
if err != nil {
1355
fail("Failed to update labels, no record found on PDS.", err)
1356
return
1357
}
1358
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1359
Collection: tangled.RepoNSID,
1360
Repo: newRepo.Did,
1361
Rkey: newRepo.Rkey,
···
1479
user := rp.oauth.GetUser(r)
1480
l := rp.logger.With("handler", "AddCollaborator")
1481
l = l.With("did", user.Did)
1482
-
l = l.With("handle", user.Handle)
1483
1484
f, err := rp.repoResolver.Resolve(r)
1485
if err != nil {
···
1526
currentUser := rp.oauth.GetUser(r)
1527
rkey := tid.TID()
1528
createdAt := time.Now()
1529
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1530
Collection: tangled.RepoCollaboratorNSID,
1531
Repo: currentUser.Did,
1532
Rkey: rkey,
···
1617
}
1618
1619
// remove record from pds
1620
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1621
if err != nil {
1622
log.Println("failed to get authorized client", err)
1623
return
1624
}
1625
-
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1626
Collection: tangled.RepoNSID,
1627
Repo: user.Did,
1628
Rkey: f.Rkey,
···
1764
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1765
user := rp.oauth.GetUser(r)
1766
l := rp.logger.With("handler", "Secrets")
1767
-
l = l.With("handle", user.Handle)
1768
l = l.With("did", user.Did)
1769
1770
f, err := rp.repoResolver.Resolve(r)
···
2129
}
2130
2131
// choose a name for a fork
2132
-
forkName := f.Name
2133
// this check is *only* to see if the forked repo name already exists
2134
// in the user's account.
2135
existingRepo, err := db.GetRepo(
2136
rp.db,
2137
db.FilterEq("did", user.Did),
2138
-
db.FilterEq("name", f.Name),
2139
)
2140
if err != nil {
2141
-
if errors.Is(err, sql.ErrNoRows) {
2142
-
// no existing repo with this name found, we can use the name as is
2143
-
} else {
2144
log.Println("error fetching existing repo from db", "err", err)
2145
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2146
return
2147
}
2148
} else if existingRepo != nil {
2149
-
// repo with this name already exists, append random string
2150
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
2151
}
2152
l = l.With("forkName", forkName)
2153
···
2175
}
2176
record := repo.AsRecord()
2177
2178
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
2179
if err != nil {
2180
l.Error("failed to create xrpcclient", "err", err)
2181
rp.pages.Notice(w, "repo", "Failed to fork repository.")
2182
return
2183
}
2184
2185
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
2186
Collection: tangled.RepoNSID,
2187
Repo: user.Did,
2188
Rkey: rkey,
···
2214
rollback := func() {
2215
err1 := tx.Rollback()
2216
err2 := rp.enforcer.E.LoadPolicy()
2217
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
2218
2219
// ignore txn complete errors, this is okay
2220
if errors.Is(err1, sql.ErrTxDone) {
···
2287
aturi = ""
2288
2289
rp.notifier.NewRepo(r.Context(), repo)
2290
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
2291
}
2292
}
2293
2294
// this is used to rollback changes made to the PDS
2295
//
2296
// it is a no-op if the provided ATURI is empty
2297
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
2298
if aturi == "" {
2299
return nil
2300
}
···
2305
repo := parsed.Authority().String()
2306
rkey := parsed.RecordKey().String()
2307
2308
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
2309
Collection: collection,
2310
Repo: repo,
2311
Rkey: rkey,
···
17
"strings"
18
"time"
19
20
"tangled.org/core/api/tangled"
21
"tangled.org/core/appview/commitverify"
22
"tangled.org/core/appview/config"
···
37
"tangled.org/core/types"
38
"tangled.org/core/xrpc/serviceauth"
39
40
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
41
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
43
+
lexutil "github.com/bluesky-social/indigo/lex/util"
44
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
45
securejoin "github.com/cyphar/filepath-securejoin"
46
"github.com/go-chi/chi/v5"
47
"github.com/go-git/go-git/v5/plumbing"
48
)
49
50
type Repo struct {
···
307
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308
//
309
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
311
if err != nil {
312
// failed to get record
313
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
314
return
315
}
316
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
317
Collection: tangled.RepoNSID,
318
Repo: newRepo.Did,
319
Rkey: newRepo.Rkey,
···
628
})
629
}
630
631
+
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
632
+
f, err := rp.repoResolver.Resolve(r)
633
+
if err != nil {
634
+
log.Println("failed to get repo and knot", err)
635
+
return
636
+
}
637
+
638
+
noticeId := "delete-branch-error"
639
+
fail := func(msg string, err error) {
640
+
log.Println(msg, "err", err)
641
+
rp.pages.Notice(w, noticeId, msg)
642
+
}
643
+
644
+
branch := r.FormValue("branch")
645
+
if branch == "" {
646
+
fail("No branch provided.", nil)
647
+
return
648
+
}
649
+
650
+
client, err := rp.oauth.ServiceClient(
651
+
r,
652
+
oauth.WithService(f.Knot),
653
+
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
654
+
oauth.WithDev(rp.config.Core.Dev),
655
+
)
656
+
if err != nil {
657
+
fail("Failed to connect to knotserver", nil)
658
+
return
659
+
}
660
+
661
+
err = tangled.RepoDeleteBranch(
662
+
r.Context(),
663
+
client,
664
+
&tangled.RepoDeleteBranch_Input{
665
+
Branch: branch,
666
+
Repo: f.RepoAt().String(),
667
+
},
668
+
)
669
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
670
+
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
671
+
return
672
+
}
673
+
log.Println("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
674
+
675
+
rp.pages.HxRefresh(w)
676
+
}
677
+
678
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
679
f, err := rp.repoResolver.Resolve(r)
680
if err != nil {
···
910
user := rp.oauth.GetUser(r)
911
l := rp.logger.With("handler", "EditSpindle")
912
l = l.With("did", user.Did)
913
914
errorId := "operation-error"
915
fail := func(msg string, err error) {
···
962
return
963
}
964
965
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
966
if err != nil {
967
fail("Failed to update spindle, no record found on PDS.", err)
968
return
969
}
970
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
971
Collection: tangled.RepoNSID,
972
Repo: newRepo.Did,
973
Rkey: newRepo.Rkey,
···
997
user := rp.oauth.GetUser(r)
998
l := rp.logger.With("handler", "AddLabel")
999
l = l.With("did", user.Did)
1000
1001
f, err := rp.repoResolver.Resolve(r)
1002
if err != nil {
···
1065
1066
// emit a labelRecord
1067
labelRecord := label.AsRecord()
1068
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1069
Collection: tangled.LabelDefinitionNSID,
1070
Repo: label.Did,
1071
Rkey: label.Rkey,
···
1088
newRepo.Labels = append(newRepo.Labels, aturi)
1089
repoRecord := newRepo.AsRecord()
1090
1091
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1092
if err != nil {
1093
fail("Failed to update labels, no record found on PDS.", err)
1094
return
1095
}
1096
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1097
Collection: tangled.RepoNSID,
1098
Repo: newRepo.Did,
1099
Rkey: newRepo.Rkey,
···
1156
user := rp.oauth.GetUser(r)
1157
l := rp.logger.With("handler", "DeleteLabel")
1158
l = l.With("did", user.Did)
1159
1160
f, err := rp.repoResolver.Resolve(r)
1161
if err != nil {
···
1185
}
1186
1187
// delete label record from PDS
1188
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1189
Collection: tangled.LabelDefinitionNSID,
1190
Repo: label.Did,
1191
Rkey: label.Rkey,
···
1207
newRepo.Labels = updated
1208
repoRecord := newRepo.AsRecord()
1209
1210
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1211
if err != nil {
1212
fail("Failed to update labels, no record found on PDS.", err)
1213
return
1214
}
1215
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1216
Collection: tangled.RepoNSID,
1217
Repo: newRepo.Did,
1218
Rkey: newRepo.Rkey,
···
1264
user := rp.oauth.GetUser(r)
1265
l := rp.logger.With("handler", "SubscribeLabel")
1266
l = l.With("did", user.Did)
1267
1268
f, err := rp.repoResolver.Resolve(r)
1269
if err != nil {
···
1304
return
1305
}
1306
1307
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1308
if err != nil {
1309
fail("Failed to update labels, no record found on PDS.", err)
1310
return
1311
}
1312
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1313
Collection: tangled.RepoNSID,
1314
Repo: newRepo.Did,
1315
Rkey: newRepo.Rkey,
···
1350
user := rp.oauth.GetUser(r)
1351
l := rp.logger.With("handler", "UnsubscribeLabel")
1352
l = l.With("did", user.Did)
1353
1354
f, err := rp.repoResolver.Resolve(r)
1355
if err != nil {
···
1392
return
1393
}
1394
1395
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1396
if err != nil {
1397
fail("Failed to update labels, no record found on PDS.", err)
1398
return
1399
}
1400
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1401
Collection: tangled.RepoNSID,
1402
Repo: newRepo.Did,
1403
Rkey: newRepo.Rkey,
···
1521
user := rp.oauth.GetUser(r)
1522
l := rp.logger.With("handler", "AddCollaborator")
1523
l = l.With("did", user.Did)
1524
1525
f, err := rp.repoResolver.Resolve(r)
1526
if err != nil {
···
1567
currentUser := rp.oauth.GetUser(r)
1568
rkey := tid.TID()
1569
createdAt := time.Now()
1570
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1571
Collection: tangled.RepoCollaboratorNSID,
1572
Repo: currentUser.Did,
1573
Rkey: rkey,
···
1658
}
1659
1660
// remove record from pds
1661
+
atpClient, err := rp.oauth.AuthorizedClient(r)
1662
if err != nil {
1663
log.Println("failed to get authorized client", err)
1664
return
1665
}
1666
+
_, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1667
Collection: tangled.RepoNSID,
1668
Repo: user.Did,
1669
Rkey: f.Rkey,
···
1805
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1806
user := rp.oauth.GetUser(r)
1807
l := rp.logger.With("handler", "Secrets")
1808
l = l.With("did", user.Did)
1809
1810
f, err := rp.repoResolver.Resolve(r)
···
2169
}
2170
2171
// choose a name for a fork
2172
+
forkName := r.FormValue("repo_name")
2173
+
if forkName == "" {
2174
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2175
+
return
2176
+
}
2177
+
2178
// this check is *only* to see if the forked repo name already exists
2179
// in the user's account.
2180
existingRepo, err := db.GetRepo(
2181
rp.db,
2182
db.FilterEq("did", user.Did),
2183
+
db.FilterEq("name", forkName),
2184
)
2185
if err != nil {
2186
+
if !errors.Is(err, sql.ErrNoRows) {
2187
log.Println("error fetching existing repo from db", "err", err)
2188
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2189
return
2190
}
2191
} else if existingRepo != nil {
2192
+
// repo with this name already exists
2193
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2194
+
return
2195
}
2196
l = l.With("forkName", forkName)
2197
···
2219
}
2220
record := repo.AsRecord()
2221
2222
+
atpClient, err := rp.oauth.AuthorizedClient(r)
2223
if err != nil {
2224
l.Error("failed to create xrpcclient", "err", err)
2225
rp.pages.Notice(w, "repo", "Failed to fork repository.")
2226
return
2227
}
2228
2229
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2230
Collection: tangled.RepoNSID,
2231
Repo: user.Did,
2232
Rkey: rkey,
···
2258
rollback := func() {
2259
err1 := tx.Rollback()
2260
err2 := rp.enforcer.E.LoadPolicy()
2261
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
2262
2263
// ignore txn complete errors, this is okay
2264
if errors.Is(err1, sql.ErrTxDone) {
···
2331
aturi = ""
2332
2333
rp.notifier.NewRepo(r.Context(), repo)
2334
+
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
2335
}
2336
}
2337
2338
// this is used to rollback changes made to the PDS
2339
//
2340
// it is a no-op if the provided ATURI is empty
2341
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2342
if aturi == "" {
2343
return nil
2344
}
···
2349
repo := parsed.Authority().String()
2350
rkey := parsed.RecordKey().String()
2351
2352
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2353
Collection: collection,
2354
Repo: repo,
2355
Rkey: rkey,
+2
appview/repo/router.go
+2
appview/repo/router.go
···
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
r := chi.NewRouter()
12
r.Get("/", rp.RepoIndex)
13
r.Get("/feed.atom", rp.RepoAtomFeed)
14
r.Get("/commits/{ref}", rp.RepoLog)
15
r.Route("/tree/{ref}", func(r chi.Router) {
···
18
})
19
r.Get("/commit/{ref}", rp.RepoCommit)
20
r.Get("/branches", rp.RepoBranches)
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
···
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
r := chi.NewRouter()
12
r.Get("/", rp.RepoIndex)
13
+
r.Get("/opengraph", rp.RepoOpenGraphSummary)
14
r.Get("/feed.atom", rp.RepoAtomFeed)
15
r.Get("/commits/{ref}", rp.RepoLog)
16
r.Route("/tree/{ref}", func(r chi.Router) {
···
19
})
20
r.Get("/commit/{ref}", rp.RepoCommit)
21
r.Get("/branches", rp.RepoBranches)
22
+
r.Delete("/branches", rp.DeleteBranch)
23
r.Route("/tags", func(r chi.Router) {
24
r.Get("/", rp.RepoTags)
25
r.Route("/{tag}", func(r chi.Router) {
+2
-2
appview/settings/settings.go
+2
-2
appview/settings/settings.go
···
470
}
471
472
// store in pds too
473
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
474
Collection: tangled.PublicKeyNSID,
475
Repo: did,
476
Rkey: rkey,
···
527
528
if rkey != "" {
529
// remove from pds too
530
-
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
531
Collection: tangled.PublicKeyNSID,
532
Repo: did,
533
Rkey: rkey,
···
470
}
471
472
// store in pds too
473
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
474
Collection: tangled.PublicKeyNSID,
475
Repo: did,
476
Rkey: rkey,
···
527
528
if rkey != "" {
529
// remove from pds too
530
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
531
Collection: tangled.PublicKeyNSID,
532
Repo: did,
533
Rkey: rkey,
+1
-3
appview/signup/signup.go
+1
-3
appview/signup/signup.go
···
20
"tangled.org/core/appview/models"
21
"tangled.org/core/appview/pages"
22
"tangled.org/core/appview/state/userutil"
23
-
"tangled.org/core/appview/xrpcclient"
24
"tangled.org/core/idresolver"
25
)
26
···
29
db *db.DB
30
cf *dns.Cloudflare
31
posthog posthog.Client
32
-
xrpc *xrpcclient.Client
33
idResolver *idresolver.Resolver
34
pages *pages.Pages
35
l *slog.Logger
···
133
noticeId := "signup-msg"
134
135
if err := s.validateCaptcha(cfToken, r); err != nil {
136
-
s.l.Warn("turnstile validation failed", "error", err)
137
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
return
139
}
···
20
"tangled.org/core/appview/models"
21
"tangled.org/core/appview/pages"
22
"tangled.org/core/appview/state/userutil"
23
"tangled.org/core/idresolver"
24
)
25
···
28
db *db.DB
29
cf *dns.Cloudflare
30
posthog posthog.Client
31
idResolver *idresolver.Resolver
32
pages *pages.Pages
33
l *slog.Logger
···
131
noticeId := "signup-msg"
132
133
if err := s.validateCaptcha(cfToken, r); err != nil {
134
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
135
s.pages.Notice(w, noticeId, "Captcha validation failed.")
136
return
137
}
+5
-5
appview/spindles/spindles.go
+5
-5
appview/spindles/spindles.go
···
189
return
190
}
191
192
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
193
var exCid *string
194
if ex != nil {
195
exCid = ex.Cid
196
}
197
198
// re-announce by registering under same rkey
199
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
200
Collection: tangled.SpindleNSID,
201
Repo: user.Did,
202
Rkey: instance,
···
332
return
333
}
334
335
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
336
Collection: tangled.SpindleNSID,
337
Repo: user.Did,
338
Rkey: instance,
···
542
return
543
}
544
545
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
546
Collection: tangled.SpindleMemberNSID,
547
Repo: user.Did,
548
Rkey: rkey,
···
683
}
684
685
// remove from pds
686
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
687
Collection: tangled.SpindleMemberNSID,
688
Repo: user.Did,
689
Rkey: members[0].Rkey,
···
189
return
190
}
191
192
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
193
var exCid *string
194
if ex != nil {
195
exCid = ex.Cid
196
}
197
198
// re-announce by registering under same rkey
199
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
Collection: tangled.SpindleNSID,
201
Repo: user.Did,
202
Rkey: instance,
···
332
return
333
}
334
335
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
336
Collection: tangled.SpindleNSID,
337
Repo: user.Did,
338
Rkey: instance,
···
542
return
543
}
544
545
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
546
Collection: tangled.SpindleMemberNSID,
547
Repo: user.Did,
548
Rkey: rkey,
···
683
}
684
685
// remove from pds
686
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
687
Collection: tangled.SpindleMemberNSID,
688
Repo: user.Did,
689
Rkey: members[0].Rkey,
+2
-2
appview/state/follow.go
+2
-2
appview/state/follow.go
···
43
case http.MethodPost:
44
createdAt := time.Now().Format(time.RFC3339)
45
rkey := tid.TID()
46
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
Collection: tangled.GraphFollowNSID,
48
Repo: currentUser.Did,
49
Rkey: rkey,
···
88
return
89
}
90
91
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
92
Collection: tangled.GraphFollowNSID,
93
Repo: currentUser.Did,
94
Rkey: follow.Rkey,
···
43
case http.MethodPost:
44
createdAt := time.Now().Format(time.RFC3339)
45
rkey := tid.TID()
46
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
47
Collection: tangled.GraphFollowNSID,
48
Repo: currentUser.Did,
49
Rkey: rkey,
···
88
return
89
}
90
91
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
92
Collection: tangled.GraphFollowNSID,
93
Repo: currentUser.Did,
94
Rkey: follow.Rkey,
+151
appview/state/gfi.go
+151
appview/state/gfi.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"sort"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
"tangled.org/core/consts"
16
+
)
17
+
18
+
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
+
user := s.oauth.GetUser(r)
20
+
21
+
page, ok := r.Context().Value("page").(pagination.Page)
22
+
if !ok {
23
+
page = pagination.FirstPage()
24
+
}
25
+
26
+
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
+
28
+
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
29
+
if err != nil {
30
+
log.Println("failed to get repo labels", err)
31
+
s.pages.Error503(w)
32
+
return
33
+
}
34
+
35
+
if len(repoLabels) == 0 {
36
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
37
+
LoggedInUser: user,
38
+
RepoGroups: []*models.RepoGroup{},
39
+
LabelDefs: make(map[string]*models.LabelDefinition),
40
+
Page: page,
41
+
})
42
+
return
43
+
}
44
+
45
+
repoUris := make([]string, 0, len(repoLabels))
46
+
for _, rl := range repoLabels {
47
+
repoUris = append(repoUris, rl.RepoAt.String())
48
+
}
49
+
50
+
allIssues, err := db.GetIssuesPaginated(
51
+
s.db,
52
+
pagination.Page{
53
+
Limit: 500,
54
+
},
55
+
db.FilterIn("repo_at", repoUris),
56
+
db.FilterEq("open", 1),
57
+
)
58
+
if err != nil {
59
+
log.Println("failed to get issues", err)
60
+
s.pages.Error503(w)
61
+
return
62
+
}
63
+
64
+
var goodFirstIssues []models.Issue
65
+
for _, issue := range allIssues {
66
+
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
67
+
goodFirstIssues = append(goodFirstIssues, issue)
68
+
}
69
+
}
70
+
71
+
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
72
+
for _, issue := range goodFirstIssues {
73
+
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
74
+
group.Issues = append(group.Issues, issue)
75
+
} else {
76
+
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
77
+
Repo: issue.Repo,
78
+
Issues: []models.Issue{issue},
79
+
}
80
+
}
81
+
}
82
+
83
+
var sortedGroups []*models.RepoGroup
84
+
for _, group := range repoGroups {
85
+
sortedGroups = append(sortedGroups, group)
86
+
}
87
+
88
+
sort.Slice(sortedGroups, func(i, j int) bool {
89
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
90
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
91
+
92
+
// If one is tangled and the other isn't, non-tangled comes first
93
+
if iIsTangled != jIsTangled {
94
+
return jIsTangled // true if j is tangled (i should come first)
95
+
}
96
+
97
+
// Both tangled or both not tangled: sort by name
98
+
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
99
+
})
100
+
101
+
groupStart := page.Offset
102
+
groupEnd := page.Offset + page.Limit
103
+
if groupStart > len(sortedGroups) {
104
+
groupStart = len(sortedGroups)
105
+
}
106
+
if groupEnd > len(sortedGroups) {
107
+
groupEnd = len(sortedGroups)
108
+
}
109
+
110
+
paginatedGroups := sortedGroups[groupStart:groupEnd]
111
+
112
+
var allIssuesFromGroups []models.Issue
113
+
for _, group := range paginatedGroups {
114
+
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
115
+
}
116
+
117
+
var allLabelDefs []models.LabelDefinition
118
+
if len(allIssuesFromGroups) > 0 {
119
+
labelDefUris := make(map[string]bool)
120
+
for _, issue := range allIssuesFromGroups {
121
+
for labelDefUri := range issue.Labels.Inner() {
122
+
labelDefUris[labelDefUri] = true
123
+
}
124
+
}
125
+
126
+
uriList := make([]string, 0, len(labelDefUris))
127
+
for uri := range labelDefUris {
128
+
uriList = append(uriList, uri)
129
+
}
130
+
131
+
if len(uriList) > 0 {
132
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
133
+
if err != nil {
134
+
log.Println("failed to fetch labels", err)
135
+
}
136
+
}
137
+
}
138
+
139
+
labelDefsMap := make(map[string]*models.LabelDefinition)
140
+
for i := range allLabelDefs {
141
+
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
142
+
}
143
+
144
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
145
+
LoggedInUser: user,
146
+
RepoGroups: paginatedGroups,
147
+
LabelDefs: labelDefsMap,
148
+
Page: page,
149
+
GfiLabel: labelDefsMap[goodFirstIssueLabel],
150
+
})
151
+
}
+14
-1
appview/state/knotstream.go
+14
-1
appview/state/knotstream.go
···
172
})
173
}
174
175
+
tx, err := d.Begin()
176
+
if err != nil {
177
+
return err
178
+
}
179
+
defer tx.Rollback()
180
+
181
+
// update appview's cache
182
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
183
+
if err != nil {
184
+
fmt.Printf("failed; %s\n", err)
185
+
// non-fatal
186
+
}
187
+
188
+
return tx.Commit()
189
}
190
191
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+63
appview/state/login.go
+63
appview/state/login.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"strings"
8
+
9
+
"tangled.org/core/appview/pages"
10
+
)
11
+
12
+
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
13
+
switch r.Method {
14
+
case http.MethodGet:
15
+
returnURL := r.URL.Query().Get("return_url")
16
+
s.pages.Login(w, pages.LoginParams{
17
+
ReturnUrl: returnURL,
18
+
})
19
+
case http.MethodPost:
20
+
handle := r.FormValue("handle")
21
+
22
+
// when users copy their handle from bsky.app, it tends to have these characters around it:
23
+
//
24
+
// @nelind.dk:
25
+
// \u202a ensures that the handle is always rendered left to right and
26
+
// \u202c reverts that so the rest of the page renders however it should
27
+
handle = strings.TrimPrefix(handle, "\u202a")
28
+
handle = strings.TrimSuffix(handle, "\u202c")
29
+
30
+
// `@` is harmless
31
+
handle = strings.TrimPrefix(handle, "@")
32
+
33
+
// basic handle validation
34
+
if !strings.Contains(handle, ".") {
35
+
log.Println("invalid handle format", "raw", handle)
36
+
s.pages.Notice(
37
+
w,
38
+
"login-msg",
39
+
fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle),
40
+
)
41
+
return
42
+
}
43
+
44
+
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
45
+
if err != nil {
46
+
http.Error(w, err.Error(), http.StatusInternalServerError)
47
+
return
48
+
}
49
+
50
+
s.pages.HxRedirect(w, redirectURL)
51
+
}
52
+
}
53
+
54
+
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
55
+
err := s.oauth.DeleteSession(w, r)
56
+
if err != nil {
57
+
log.Println("failed to logout", "err", err)
58
+
} else {
59
+
log.Println("logged out successfully")
60
+
}
61
+
62
+
s.pages.HxRedirect(w, "/login")
63
+
}
+2
-2
appview/state/profile.go
+2
-2
appview/state/profile.go
···
634
vanityStats = append(vanityStats, string(v.Kind))
635
}
636
637
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
638
var cid *string
639
if ex != nil {
640
cid = ex.Cid
641
}
642
643
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
644
Collection: tangled.ActorProfileNSID,
645
Repo: user.Did,
646
Rkey: "self",
···
634
vanityStats = append(vanityStats, string(v.Kind))
635
}
636
637
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
638
var cid *string
639
if ex != nil {
640
cid = ex.Cid
641
}
642
643
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
644
Collection: tangled.ActorProfileNSID,
645
Repo: user.Did,
646
Rkey: "self",
+11
-9
appview/state/reaction.go
+11
-9
appview/state/reaction.go
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
"tangled.org/core/api/tangled"
13
"tangled.org/core/appview/db"
14
"tangled.org/core/appview/models"
···
47
case http.MethodPost:
48
createdAt := time.Now().Format(time.RFC3339)
49
rkey := tid.TID()
50
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
51
Collection: tangled.FeedReactionNSID,
52
Repo: currentUser.Did,
53
Rkey: rkey,
···
70
return
71
}
72
73
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
74
if err != nil {
75
-
log.Println("failed to get reaction count for ", subjectUri)
76
}
77
78
log.Println("created atproto record: ", resp.Uri)
···
80
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
81
ThreadAt: subjectUri,
82
Kind: reactionKind,
83
-
Count: count,
84
IsReacted: true,
85
})
86
···
92
return
93
}
94
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
96
Collection: tangled.FeedReactionNSID,
97
Repo: currentUser.Did,
98
Rkey: reaction.Rkey,
···
109
// this is not an issue, the firehose event might have already done this
110
}
111
112
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
113
if err != nil {
114
-
log.Println("failed to get reaction count for ", subjectUri)
115
return
116
}
117
118
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
119
ThreadAt: subjectUri,
120
Kind: reactionKind,
121
-
Count: count,
122
IsReacted: false,
123
})
124
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
12
"tangled.org/core/api/tangled"
13
"tangled.org/core/appview/db"
14
"tangled.org/core/appview/models"
···
47
case http.MethodPost:
48
createdAt := time.Now().Format(time.RFC3339)
49
rkey := tid.TID()
50
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
51
Collection: tangled.FeedReactionNSID,
52
Repo: currentUser.Did,
53
Rkey: rkey,
···
70
return
71
}
72
73
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
74
if err != nil {
75
+
log.Println("failed to get reactions for ", subjectUri)
76
}
77
78
log.Println("created atproto record: ", resp.Uri)
···
80
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
81
ThreadAt: subjectUri,
82
Kind: reactionKind,
83
+
Count: reactionMap[reactionKind].Count,
84
+
Users: reactionMap[reactionKind].Users,
85
IsReacted: true,
86
})
87
···
93
return
94
}
95
96
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
97
Collection: tangled.FeedReactionNSID,
98
Repo: currentUser.Did,
99
Rkey: reaction.Rkey,
···
110
// this is not an issue, the firehose event might have already done this
111
}
112
113
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
114
if err != nil {
115
+
log.Println("failed to get reactions for ", subjectUri)
116
return
117
}
118
119
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
120
ThreadAt: subjectUri,
121
Kind: reactionKind,
122
+
Count: reactionMap[reactionKind].Count,
123
+
Users: reactionMap[reactionKind].Users,
124
IsReacted: false,
125
})
126
+9
-10
appview/state/router.go
+9
-10
appview/state/router.go
···
5
"strings"
6
7
"github.com/go-chi/chi/v5"
8
-
"github.com/gorilla/sessions"
9
"tangled.org/core/appview/issues"
10
"tangled.org/core/appview/knots"
11
"tangled.org/core/appview/labels"
12
"tangled.org/core/appview/middleware"
13
"tangled.org/core/appview/notifications"
14
-
oauthhandler "tangled.org/core/appview/oauth/handler"
15
"tangled.org/core/appview/pipelines"
16
"tangled.org/core/appview/pulls"
17
"tangled.org/core/appview/repo"
···
34
s.pages,
35
)
36
37
-
router.Use(middleware.TryRefreshSession())
38
router.Get("/favicon.svg", s.Favicon)
39
router.Get("/favicon.ico", s.Favicon)
40
41
userRouter := s.UserRouter(&middleware)
42
standardRouter := s.StandardRouter(&middleware)
···
122
// special-case handler for serving tangled.org/core
123
r.Get("/core", s.Core())
124
125
r.Route("/repo", func(r chi.Router) {
126
r.Route("/new", func(r chi.Router) {
127
r.Use(middleware.AuthMiddleware(s.oauth))
···
131
// r.Post("/import", s.ImportRepo)
132
})
133
134
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
135
r.Post("/", s.Follow)
136
r.Delete("/", s.Follow)
···
161
r.Mount("/notifications", s.NotificationsRouter(mw))
162
163
r.Mount("/signup", s.SignupRouter())
164
-
r.Mount("/", s.OAuthRouter())
165
166
r.Get("/keys/{user}", s.Keys)
167
r.Get("/terms", s.TermsOfService)
···
186
187
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
188
}
189
-
}
190
-
191
-
func (s *State) OAuthRouter() http.Handler {
192
-
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
193
-
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
194
-
return oauth.Router()
195
}
196
197
func (s *State) SettingsRouter() http.Handler {
···
5
"strings"
6
7
"github.com/go-chi/chi/v5"
8
"tangled.org/core/appview/issues"
9
"tangled.org/core/appview/knots"
10
"tangled.org/core/appview/labels"
11
"tangled.org/core/appview/middleware"
12
"tangled.org/core/appview/notifications"
13
"tangled.org/core/appview/pipelines"
14
"tangled.org/core/appview/pulls"
15
"tangled.org/core/appview/repo"
···
32
s.pages,
33
)
34
35
router.Get("/favicon.svg", s.Favicon)
36
router.Get("/favicon.ico", s.Favicon)
37
+
router.Get("/pwa-manifest.json", s.PWAManifest)
38
+
router.Get("/robots.txt", s.RobotsTxt)
39
40
userRouter := s.UserRouter(&middleware)
41
standardRouter := s.StandardRouter(&middleware)
···
121
// special-case handler for serving tangled.org/core
122
r.Get("/core", s.Core())
123
124
+
r.Get("/login", s.Login)
125
+
r.Post("/login", s.Login)
126
+
r.Post("/logout", s.Logout)
127
+
128
r.Route("/repo", func(r chi.Router) {
129
r.Route("/new", func(r chi.Router) {
130
r.Use(middleware.AuthMiddleware(s.oauth))
···
134
// r.Post("/import", s.ImportRepo)
135
})
136
137
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
138
+
139
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
140
r.Post("/", s.Follow)
141
r.Delete("/", s.Follow)
···
166
r.Mount("/notifications", s.NotificationsRouter(mw))
167
168
r.Mount("/signup", s.SignupRouter())
169
+
r.Mount("/", s.oauth.Router())
170
171
r.Get("/keys/{user}", s.Keys)
172
r.Get("/terms", s.TermsOfService)
···
191
192
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
193
}
194
}
195
196
func (s *State) SettingsRouter() http.Handler {
+2
-2
appview/state/star.go
+2
-2
appview/state/star.go
···
40
case http.MethodPost:
41
createdAt := time.Now().Format(time.RFC3339)
42
rkey := tid.TID()
43
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
Collection: tangled.FeedStarNSID,
45
Repo: currentUser.Did,
46
Rkey: rkey,
···
92
return
93
}
94
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
96
Collection: tangled.FeedStarNSID,
97
Repo: currentUser.Did,
98
Rkey: star.Rkey,
···
40
case http.MethodPost:
41
createdAt := time.Now().Format(time.RFC3339)
42
rkey := tid.TID()
43
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
44
Collection: tangled.FeedStarNSID,
45
Repo: currentUser.Did,
46
Rkey: rkey,
···
92
return
93
}
94
95
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
Collection: tangled.FeedStarNSID,
97
Repo: currentUser.Did,
98
Rkey: star.Rkey,
+68
-20
appview/state/state.go
+68
-20
appview/state/state.go
···
11
"strings"
12
"time"
13
14
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
-
"github.com/bluesky-social/indigo/atproto/syntax"
16
-
lexutil "github.com/bluesky-social/indigo/lex/util"
17
-
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
"tangled.org/core/api/tangled"
21
"tangled.org/core/appview"
22
"tangled.org/core/appview/cache"
···
38
tlog "tangled.org/core/log"
39
"tangled.org/core/rbac"
40
"tangled.org/core/tid"
41
)
42
43
type State struct {
···
75
res = idresolver.DefaultResolver()
76
}
77
78
-
pgs := pages.NewPages(config, res)
79
cache := cache.New(config.Redis.Addr)
80
sess := session.New(cache)
81
-
oauth := oauth.NewOAuth(config, sess)
82
validator := validator.New(d, res, enforcer)
83
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
162
state := &State{
163
d,
164
notifier,
165
-
oauth,
166
enforcer,
167
-
pgs,
168
sess,
169
res,
170
posthog,
···
198
s.pages.Favicon(w)
199
}
200
201
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
202
user := s.oauth.GetUser(r)
203
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
230
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
231
user := s.oauth.GetUser(r)
232
233
var userDid string
234
if user != nil {
235
userDid = user.Did
236
}
237
-
timeline, err := db.MakeTimeline(s.db, 50, userDid)
238
if err != nil {
239
log.Println(err)
240
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
247
return
248
}
249
250
s.pages.Timeline(w, pages.TimelineParams{
251
LoggedInUser: user,
252
Timeline: timeline,
253
Repos: repos,
254
})
255
}
256
···
262
263
l := s.logger.With("handler", "UpgradeBanner")
264
l = l.With("did", user.Did)
265
-
l = l.With("handle", user.Handle)
266
267
regs, err := db.GetRegistrations(
268
s.db,
···
293
}
294
295
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
296
-
timeline, err := db.MakeTimeline(s.db, 5, "")
297
if err != nil {
298
log.Println(err)
299
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
402
403
user := s.oauth.GetUser(r)
404
l = l.With("did", user.Did)
405
-
l = l.With("handle", user.Handle)
406
407
// form validation
408
domain := r.FormValue("domain")
···
466
}
467
record := repo.AsRecord()
468
469
-
xrpcClient, err := s.oauth.AuthorizedClient(r)
470
if err != nil {
471
l.Info("PDS write failed", "err", err)
472
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
473
return
474
}
475
476
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
477
Collection: tangled.RepoNSID,
478
Repo: user.Did,
479
Rkey: rkey,
···
505
rollback := func() {
506
err1 := tx.Rollback()
507
err2 := s.enforcer.E.LoadPolicy()
508
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
509
510
// ignore txn complete errors, this is okay
511
if errors.Is(err1, sql.ErrTxDone) {
···
578
aturi = ""
579
580
s.notifier.NewRepo(r.Context(), repo)
581
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
582
}
583
}
584
585
// this is used to rollback changes made to the PDS
586
//
587
// it is a no-op if the provided ATURI is empty
588
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
589
if aturi == "" {
590
return nil
591
}
···
596
repo := parsed.Authority().String()
597
rkey := parsed.RecordKey().String()
598
599
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
600
Collection: collection,
601
Repo: repo,
602
Rkey: rkey,
···
11
"strings"
12
"time"
13
14
"tangled.org/core/api/tangled"
15
"tangled.org/core/appview"
16
"tangled.org/core/appview/cache"
···
32
tlog "tangled.org/core/log"
33
"tangled.org/core/rbac"
34
"tangled.org/core/tid"
35
+
36
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
37
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
38
+
"github.com/bluesky-social/indigo/atproto/syntax"
39
+
lexutil "github.com/bluesky-social/indigo/lex/util"
40
+
securejoin "github.com/cyphar/filepath-securejoin"
41
+
"github.com/go-chi/chi/v5"
42
+
"github.com/posthog/posthog-go"
43
)
44
45
type State struct {
···
77
res = idresolver.DefaultResolver()
78
}
79
80
+
pages := pages.NewPages(config, res)
81
cache := cache.New(config.Redis.Addr)
82
sess := session.New(cache)
83
+
oauth2, err := oauth.New(config)
84
+
if err != nil {
85
+
return nil, fmt.Errorf("failed to start oauth handler: %w", err)
86
+
}
87
validator := validator.New(d, res, enforcer)
88
89
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
167
state := &State{
168
d,
169
notifier,
170
+
oauth2,
171
enforcer,
172
+
pages,
173
sess,
174
res,
175
posthog,
···
203
s.pages.Favicon(w)
204
}
205
206
+
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
207
+
w.Header().Set("Content-Type", "text/plain")
208
+
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
209
+
210
+
robotsTxt := `User-agent: *
211
+
Allow: /
212
+
`
213
+
w.Write([]byte(robotsTxt))
214
+
}
215
+
216
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
217
+
const manifestJson = `{
218
+
"name": "tangled",
219
+
"description": "tightly-knit social coding.",
220
+
"icons": [
221
+
{
222
+
"src": "/favicon.svg",
223
+
"sizes": "144x144"
224
+
}
225
+
],
226
+
"start_url": "/",
227
+
"id": "org.tangled",
228
+
229
+
"display": "standalone",
230
+
"background_color": "#111827",
231
+
"theme_color": "#111827"
232
+
}`
233
+
234
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
235
+
w.Header().Set("Content-Type", "application/json")
236
+
w.Write([]byte(manifestJson))
237
+
}
238
+
239
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
240
user := s.oauth.GetUser(r)
241
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
268
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
269
user := s.oauth.GetUser(r)
270
271
+
// TODO: set this flag based on the UI
272
+
filtered := false
273
+
274
var userDid string
275
if user != nil {
276
userDid = user.Did
277
}
278
+
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
279
if err != nil {
280
log.Println(err)
281
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
288
return
289
}
290
291
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
292
+
if err != nil {
293
+
// non-fatal
294
+
}
295
+
296
s.pages.Timeline(w, pages.TimelineParams{
297
LoggedInUser: user,
298
Timeline: timeline,
299
Repos: repos,
300
+
GfiLabel: gfiLabel,
301
})
302
}
303
···
309
310
l := s.logger.With("handler", "UpgradeBanner")
311
l = l.With("did", user.Did)
312
313
regs, err := db.GetRegistrations(
314
s.db,
···
339
}
340
341
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
342
+
// TODO: set this flag based on the UI
343
+
filtered := false
344
+
345
+
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
346
if err != nil {
347
log.Println(err)
348
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
451
452
user := s.oauth.GetUser(r)
453
l = l.With("did", user.Did)
454
455
// form validation
456
domain := r.FormValue("domain")
···
514
}
515
record := repo.AsRecord()
516
517
+
atpClient, err := s.oauth.AuthorizedClient(r)
518
if err != nil {
519
l.Info("PDS write failed", "err", err)
520
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
521
return
522
}
523
524
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
525
Collection: tangled.RepoNSID,
526
Repo: user.Did,
527
Rkey: rkey,
···
553
rollback := func() {
554
err1 := tx.Rollback()
555
err2 := s.enforcer.E.LoadPolicy()
556
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
557
558
// ignore txn complete errors, this is okay
559
if errors.Is(err1, sql.ErrTxDone) {
···
626
aturi = ""
627
628
s.notifier.NewRepo(r.Context(), repo)
629
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
630
}
631
}
632
633
// this is used to rollback changes made to the PDS
634
//
635
// it is a no-op if the provided ATURI is empty
636
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
637
if aturi == "" {
638
return nil
639
}
···
644
repo := parsed.Authority().String()
645
rkey := parsed.RecordKey().String()
646
647
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
648
Collection: collection,
649
Repo: repo,
650
Rkey: rkey,
+9
-7
appview/strings/strings.go
+9
-7
appview/strings/strings.go
···
22
"github.com/bluesky-social/indigo/api/atproto"
23
"github.com/bluesky-social/indigo/atproto/identity"
24
"github.com/bluesky-social/indigo/atproto/syntax"
25
-
lexutil "github.com/bluesky-social/indigo/lex/util"
26
"github.com/go-chi/chi/v5"
27
)
28
29
type Strings struct {
···
254
}
255
256
// first replace the existing record in the PDS
257
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
258
if err != nil {
259
fail("Failed to updated existing record.", err)
260
return
261
}
262
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
263
Collection: tangled.StringNSID,
264
Repo: entry.Did.String(),
265
Rkey: entry.Rkey,
···
284
s.Notifier.EditString(r.Context(), &entry)
285
286
// if that went okay, redir to the string
287
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
288
}
289
290
}
···
336
return
337
}
338
339
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
340
Collection: tangled.StringNSID,
341
Repo: user.Did,
342
Rkey: string.Rkey,
···
360
s.Notifier.NewString(r.Context(), &string)
361
362
// successful
363
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
364
}
365
}
366
···
403
404
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
405
406
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
407
}
408
409
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
···
22
"github.com/bluesky-social/indigo/api/atproto"
23
"github.com/bluesky-social/indigo/atproto/identity"
24
"github.com/bluesky-social/indigo/atproto/syntax"
25
"github.com/go-chi/chi/v5"
26
+
27
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
29
)
30
31
type Strings struct {
···
256
}
257
258
// first replace the existing record in the PDS
259
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
260
if err != nil {
261
fail("Failed to updated existing record.", err)
262
return
263
}
264
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
265
Collection: tangled.StringNSID,
266
Repo: entry.Did.String(),
267
Rkey: entry.Rkey,
···
286
s.Notifier.EditString(r.Context(), &entry)
287
288
// if that went okay, redir to the string
289
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
290
}
291
292
}
···
338
return
339
}
340
341
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
342
Collection: tangled.StringNSID,
343
Repo: user.Did,
344
Rkey: string.Rkey,
···
362
s.Notifier.NewString(r.Context(), &string)
363
364
// successful
365
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
366
}
367
}
368
···
405
406
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
407
408
+
s.Pages.HxRedirect(w, "/strings/"+user.Did)
409
}
410
411
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-99
appview/xrpcclient/xrpc.go
-99
appview/xrpcclient/xrpc.go
···
1
package xrpcclient
2
3
import (
4
-
"bytes"
5
-
"context"
6
"errors"
7
-
"io"
8
"net/http"
9
10
-
"github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
14
)
15
16
var (
···
19
ErrXrpcFailed = errors.New("xrpc request failed")
20
ErrXrpcInvalid = errors.New("invalid xrpc request")
21
)
22
-
23
-
type Client struct {
24
-
*oauth.XrpcClient
25
-
authArgs *oauth.XrpcAuthedRequestArgs
26
-
}
27
-
28
-
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
29
-
return &Client{
30
-
XrpcClient: client,
31
-
authArgs: authArgs,
32
-
}
33
-
}
34
-
35
-
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
36
-
var out atproto.RepoPutRecord_Output
37
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
38
-
return nil, err
39
-
}
40
-
41
-
return &out, nil
42
-
}
43
-
44
-
func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) {
45
-
var out atproto.RepoApplyWrites_Output
46
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
47
-
return nil, err
48
-
}
49
-
50
-
return &out, nil
51
-
}
52
-
53
-
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
54
-
var out atproto.RepoGetRecord_Output
55
-
56
-
params := map[string]interface{}{
57
-
"cid": cid,
58
-
"collection": collection,
59
-
"repo": repo,
60
-
"rkey": rkey,
61
-
}
62
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
63
-
return nil, err
64
-
}
65
-
66
-
return &out, nil
67
-
}
68
-
69
-
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
70
-
var out atproto.RepoUploadBlob_Output
71
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return &out, nil
76
-
}
77
-
78
-
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
79
-
buf := new(bytes.Buffer)
80
-
81
-
params := map[string]interface{}{
82
-
"cid": cid,
83
-
"did": did,
84
-
}
85
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
86
-
return nil, err
87
-
}
88
-
89
-
return buf.Bytes(), nil
90
-
}
91
-
92
-
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
93
-
var out atproto.RepoDeleteRecord_Output
94
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
95
-
return nil, err
96
-
}
97
-
98
-
return &out, nil
99
-
}
100
-
101
-
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
102
-
var out atproto.ServerGetServiceAuth_Output
103
-
104
-
params := map[string]interface{}{
105
-
"aud": aud,
106
-
"exp": exp,
107
-
"lxm": lxm,
108
-
}
109
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
110
-
return nil, err
111
-
}
112
-
113
-
return &out, nil
114
-
}
115
116
// produces a more manageable error
117
func HandleXrpcErr(err error) error {
···
1
package xrpcclient
2
3
import (
4
"errors"
5
"net/http"
6
7
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
8
)
9
10
var (
···
13
ErrXrpcFailed = errors.New("xrpc request failed")
14
ErrXrpcInvalid = errors.New("invalid xrpc request")
15
)
16
17
// produces a more manageable error
18
func HandleXrpcErr(err error) error {
+1
-1
cmd/genjwks/main.go
+1
-1
cmd/genjwks/main.go
+1
-1
docs/spindle/pipeline.md
+1
-1
docs/spindle/pipeline.md
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
+10
-6
go.mod
+10
-6
go.mod
···
8
github.com/alecthomas/chroma/v2 v2.15.0
9
github.com/avast/retry-go/v4 v4.6.1
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
···
21
github.com/go-chi/chi/v5 v5.2.0
22
github.com/go-enry/go-enry/v2 v2.9.2
23
github.com/go-git/go-git/v5 v5.14.0
24
github.com/google/uuid v1.6.0
25
github.com/gorilla/feeds v1.2.0
26
github.com/gorilla/sessions v1.4.0
···
36
github.com/redis/go-redis/v9 v9.7.3
37
github.com/resend/resend-go/v2 v2.15.0
38
github.com/sethvargo/go-envconfig v1.1.0
39
github.com/stretchr/testify v1.10.0
40
github.com/urfave/cli/v3 v3.3.3
41
github.com/whyrusleeping/cbor-gen v0.3.1
42
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
-
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
golang.org/x/net v0.42.0
47
-
golang.org/x/sync v0.16.0
48
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
49
gopkg.in/yaml.v3 v3.0.1
50
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
51
)
52
53
require (
···
156
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
157
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
158
github.com/wyatt915/treeblood v0.1.15 // indirect
159
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
160
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
161
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
···
168
go.uber.org/atomic v1.11.0 // indirect
169
go.uber.org/multierr v1.11.0 // indirect
170
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
-
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
175
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
176
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
···
8
github.com/alecthomas/chroma/v2 v2.15.0
9
github.com/avast/retry-go/v4 v4.6.1
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
···
21
github.com/go-chi/chi/v5 v5.2.0
22
github.com/go-enry/go-enry/v2 v2.9.2
23
github.com/go-git/go-git/v5 v5.14.0
24
+
github.com/goki/freetype v1.0.5
25
github.com/google/uuid v1.6.0
26
github.com/gorilla/feeds v1.2.0
27
github.com/gorilla/sessions v1.4.0
···
37
github.com/redis/go-redis/v9 v9.7.3
38
github.com/resend/resend-go/v2 v2.15.0
39
github.com/sethvargo/go-envconfig v1.1.0
40
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
41
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
42
github.com/stretchr/testify v1.10.0
43
github.com/urfave/cli/v3 v3.3.3
44
github.com/whyrusleeping/cbor-gen v0.3.1
45
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
46
+
github.com/yuin/goldmark v1.7.13
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
48
golang.org/x/crypto v0.40.0
49
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
+
golang.org/x/image v0.31.0
51
golang.org/x/net v0.42.0
52
+
golang.org/x/sync v0.17.0
53
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
54
gopkg.in/yaml.v3 v3.0.1
55
)
56
57
require (
···
160
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
161
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
162
github.com/wyatt915/treeblood v0.1.15 // indirect
163
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
164
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
165
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
166
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
···
173
go.uber.org/atomic v1.11.0 // indirect
174
go.uber.org/multierr v1.11.0 // indirect
175
go.uber.org/zap v1.27.0 // indirect
176
golang.org/x/sys v0.34.0 // indirect
177
+
golang.org/x/text v0.29.0 // indirect
178
golang.org/x/time v0.12.0 // indirect
179
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
180
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+18
-12
go.sum
+18
-12
go.sum
···
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
136
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
137
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
138
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
139
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
140
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
141
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
243
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
244
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
245
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
246
-
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
247
-
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
248
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
249
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
250
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
399
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
400
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
401
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
402
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
403
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
404
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
436
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
437
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
438
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
439
-
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
440
-
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
441
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
442
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
443
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
444
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
445
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
···
489
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
490
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
491
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
492
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
493
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
494
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
528
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
529
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
530
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
531
-
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
532
-
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
533
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
534
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
535
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
583
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
584
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
585
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
586
-
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
587
-
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
588
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
589
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
590
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
652
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
653
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
654
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
655
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
656
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
657
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
658
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
···
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
27
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
136
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
137
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
138
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
139
+
github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
140
+
github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
141
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
142
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
143
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
245
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
246
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
247
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
248
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
249
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
250
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
399
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
400
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
401
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
402
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
403
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
404
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
405
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
406
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
407
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
408
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
440
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
441
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
442
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
443
+
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
444
+
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
445
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
446
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
447
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
448
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c=
449
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
450
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
451
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
···
495
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
496
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
497
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
498
+
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
499
+
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
500
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
501
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
502
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
536
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
537
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
538
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
539
+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
540
+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
541
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
542
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
543
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
591
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
592
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
593
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
594
+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
595
+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
596
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
597
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
598
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
660
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
661
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
662
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
663
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
664
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
}
135
136
.prose hr {
137
-
@apply my-2;
138
}
139
140
.prose li:has(input) {
141
-
@apply list-none;
142
}
143
144
.prose ul:has(input) {
145
-
@apply pl-2;
146
}
147
148
.prose .heading .anchor {
149
-
@apply no-underline mx-2 opacity-0;
150
}
151
152
.prose .heading:hover .anchor {
153
-
@apply opacity-70;
154
}
155
156
.prose .heading .anchor:hover {
157
-
@apply opacity-70;
158
}
159
160
.prose a.footnote-backref {
161
-
@apply no-underline;
162
}
163
164
.prose li {
165
-
@apply my-0 py-0;
166
}
167
168
-
.prose ul, .prose ol {
169
-
@apply my-1 py-0;
170
}
171
172
.prose img {
···
176
}
177
178
.prose input {
179
-
@apply inline-block my-0 mb-1 mx-1;
180
}
181
182
.prose input[type="checkbox"] {
183
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
184
}
185
}
186
@layer utilities {
187
.error {
···
228
}
229
/* LineHighlight */
230
.chroma .hl {
231
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
232
}
233
234
/* LineNumbersTable */
···
134
}
135
136
.prose hr {
137
+
@apply my-2;
138
}
139
140
.prose li:has(input) {
141
+
@apply list-none;
142
}
143
144
.prose ul:has(input) {
145
+
@apply pl-2;
146
}
147
148
.prose .heading .anchor {
149
+
@apply no-underline mx-2 opacity-0;
150
}
151
152
.prose .heading:hover .anchor {
153
+
@apply opacity-70;
154
}
155
156
.prose .heading .anchor:hover {
157
+
@apply opacity-70;
158
}
159
160
.prose a.footnote-backref {
161
+
@apply no-underline;
162
}
163
164
.prose li {
165
+
@apply my-0 py-0;
166
}
167
168
+
.prose ul,
169
+
.prose ol {
170
+
@apply my-1 py-0;
171
}
172
173
.prose img {
···
177
}
178
179
.prose input {
180
+
@apply inline-block my-0 mb-1 mx-1;
181
}
182
183
.prose input[type="checkbox"] {
184
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
185
}
186
+
187
+
/* Base callout */
188
+
details[data-callout] {
189
+
@apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;
190
+
}
191
+
192
+
details[data-callout] > summary {
193
+
@apply font-bold cursor-pointer mb-1;
194
+
}
195
+
196
+
details[data-callout] > .callout-content {
197
+
@apply text-sm leading-snug;
198
+
}
199
+
200
+
/* Note (blue) */
201
+
details[data-callout="note" i] {
202
+
@apply border-blue-400 dark:border-blue-500;
203
+
}
204
+
details[data-callout="note" i] > summary {
205
+
@apply text-blue-700 dark:text-blue-400;
206
+
}
207
+
208
+
/* Important (purple) */
209
+
details[data-callout="important" i] {
210
+
@apply border-purple-400 dark:border-purple-500;
211
+
}
212
+
details[data-callout="important" i] > summary {
213
+
@apply text-purple-700 dark:text-purple-400;
214
+
}
215
+
216
+
/* Warning (yellow) */
217
+
details[data-callout="warning" i] {
218
+
@apply border-yellow-400 dark:border-yellow-500;
219
+
}
220
+
details[data-callout="warning" i] > summary {
221
+
@apply text-yellow-700 dark:text-yellow-400;
222
+
}
223
+
224
+
/* Caution (red) */
225
+
details[data-callout="caution" i] {
226
+
@apply border-red-400 dark:border-red-500;
227
+
}
228
+
details[data-callout="caution" i] > summary {
229
+
@apply text-red-700 dark:text-red-400;
230
+
}
231
+
232
+
/* Tip (green) */
233
+
details[data-callout="tip" i] {
234
+
@apply border-green-400 dark:border-green-500;
235
+
}
236
+
details[data-callout="tip" i] > summary {
237
+
@apply text-green-700 dark:text-green-400;
238
+
}
239
+
240
+
/* Optional: hide the disclosure arrow like GitHub */
241
+
details[data-callout] > summary::-webkit-details-marker {
242
+
display: none;
243
+
}
244
}
245
@layer utilities {
246
.error {
···
287
}
288
/* LineHighlight */
289
.chroma .hl {
290
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
291
}
292
293
/* LineNumbersTable */
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
+5
knotserver/git/branch.go
+5
knotserver/git/branch.go
+11
knotserver/git/git.go
+11
knotserver/git/git.go
···
71
return &g, nil
72
}
73
74
+
// re-open a repository and update references
75
+
func (g *GitRepo) Refresh() error {
76
+
refreshed, err := PlainOpen(g.path)
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
*g = *refreshed
82
+
return nil
83
+
}
84
+
85
func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
86
commits := []*object.Commit{}
87
+150
-37
knotserver/git/merge.go
+150
-37
knotserver/git/merge.go
···
4
"bytes"
5
"crypto/sha256"
6
"fmt"
7
"os"
8
"os/exec"
9
"regexp"
···
12
"github.com/dgraph-io/ristretto"
13
"github.com/go-git/go-git/v5"
14
"github.com/go-git/go-git/v5/plumbing"
15
)
16
17
type MergeCheckCache struct {
···
32
mergeCheckCache = MergeCheckCache{cache}
33
}
34
35
-
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
36
sep := byte(':')
37
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
38
return fmt.Sprintf("%x", hash)
···
49
}
50
}
51
52
-
func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
53
key := m.cacheKey(g, patch, targetBranch)
54
val := m.cacheVal(mergeCheck)
55
m.cache.Set(key, val, 0)
56
}
57
58
-
func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
59
key := m.cacheKey(g, patch, targetBranch)
60
if val, ok := m.cache.Get(key); ok {
61
if val == struct{}{} {
···
104
return fmt.Sprintf("merge failed: %s", e.Message)
105
}
106
107
-
func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
108
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
109
if err != nil {
110
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
111
}
112
113
-
if _, err := tmpFile.Write(patchData); err != nil {
114
tmpFile.Close()
115
os.Remove(tmpFile.Name())
116
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
···
162
return nil
163
}
164
165
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
166
var stderr bytes.Buffer
167
var cmd *exec.Cmd
168
169
// configure default git user before merge
170
-
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171
-
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
173
174
// if patch is a format-patch, apply using 'git am'
175
if opts.FormatPatch {
176
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177
-
} else {
178
-
// else, apply using 'git apply' and commit it manually
179
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180
-
applyCmd.Stderr = &stderr
181
-
if err := applyCmd.Run(); err != nil {
182
-
return fmt.Errorf("patch application failed: %s", stderr.String())
183
-
}
184
185
-
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
186
-
if err := stageCmd.Run(); err != nil {
187
-
return fmt.Errorf("failed to stage changes: %w", err)
188
-
}
189
190
-
commitArgs := []string{"-C", tmpDir, "commit"}
191
192
-
// Set author if provided
193
-
authorName := opts.AuthorName
194
-
authorEmail := opts.AuthorEmail
195
196
-
if authorName != "" && authorEmail != "" {
197
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198
-
}
199
-
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
200
201
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
202
203
-
if opts.CommitBody != "" {
204
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
-
}
206
207
-
cmd = exec.Command("git", commitArgs...)
208
}
209
210
cmd.Stderr = &stderr
211
···
216
return nil
217
}
218
219
-
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
220
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
221
return val
222
}
···
244
return result
245
}
246
247
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
248
patchFile, err := g.createTempFileWithPatch(patchData)
249
if err != nil {
250
return &ErrMerge{
···
263
}
264
defer os.RemoveAll(tmpDir)
265
266
-
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
267
return err
268
}
269
···
4
"bytes"
5
"crypto/sha256"
6
"fmt"
7
+
"log"
8
"os"
9
"os/exec"
10
"regexp"
···
13
"github.com/dgraph-io/ristretto"
14
"github.com/go-git/go-git/v5"
15
"github.com/go-git/go-git/v5/plumbing"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
18
)
19
20
type MergeCheckCache struct {
···
35
mergeCheckCache = MergeCheckCache{cache}
36
}
37
38
+
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
39
sep := byte(':')
40
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
41
return fmt.Sprintf("%x", hash)
···
52
}
53
}
54
55
+
func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
56
key := m.cacheKey(g, patch, targetBranch)
57
val := m.cacheVal(mergeCheck)
58
m.cache.Set(key, val, 0)
59
}
60
61
+
func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
62
key := m.cacheKey(g, patch, targetBranch)
63
if val, ok := m.cache.Get(key); ok {
64
if val == struct{}{} {
···
107
return fmt.Sprintf("merge failed: %s", e.Message)
108
}
109
110
+
func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) {
111
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
112
if err != nil {
113
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
114
}
115
116
+
if _, err := tmpFile.Write([]byte(patchData)); err != nil {
117
tmpFile.Close()
118
os.Remove(tmpFile.Name())
119
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
···
165
return nil
166
}
167
168
+
func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
169
var stderr bytes.Buffer
170
var cmd *exec.Cmd
171
172
// configure default git user before merge
173
+
exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
174
+
exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
175
+
exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
176
177
// if patch is a format-patch, apply using 'git am'
178
if opts.FormatPatch {
179
+
return g.applyMailbox(patchData)
180
+
}
181
182
+
// else, apply using 'git apply' and commit it manually
183
+
applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
184
+
applyCmd.Stderr = &stderr
185
+
if err := applyCmd.Run(); err != nil {
186
+
return fmt.Errorf("patch application failed: %s", stderr.String())
187
+
}
188
189
+
stageCmd := exec.Command("git", "-C", g.path, "add", ".")
190
+
if err := stageCmd.Run(); err != nil {
191
+
return fmt.Errorf("failed to stage changes: %w", err)
192
+
}
193
194
+
commitArgs := []string{"-C", g.path, "commit"}
195
196
+
// Set author if provided
197
+
authorName := opts.AuthorName
198
+
authorEmail := opts.AuthorEmail
199
200
+
if authorName != "" && authorEmail != "" {
201
+
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
202
+
}
203
+
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
204
205
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
206
207
+
if opts.CommitBody != "" {
208
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
209
}
210
+
211
+
cmd = exec.Command("git", commitArgs...)
212
213
cmd.Stderr = &stderr
214
···
219
return nil
220
}
221
222
+
func (g *GitRepo) applyMailbox(patchData string) error {
223
+
fps, err := patchutil.ExtractPatches(patchData)
224
+
if err != nil {
225
+
return fmt.Errorf("failed to extract patches: %w", err)
226
+
}
227
+
228
+
// apply each patch one by one
229
+
// update the newly created commit object to add the change-id header
230
+
total := len(fps)
231
+
for i, p := range fps {
232
+
newCommit, err := g.applySingleMailbox(p)
233
+
if err != nil {
234
+
return err
235
+
}
236
+
237
+
log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
238
+
}
239
+
240
+
return nil
241
+
}
242
+
243
+
func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
244
+
tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw)
245
+
if err != nil {
246
+
return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err)
247
+
}
248
+
249
+
var stderr bytes.Buffer
250
+
cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
251
+
cmd.Stderr = &stderr
252
+
253
+
head, err := g.r.Head()
254
+
if err != nil {
255
+
return plumbing.ZeroHash, err
256
+
}
257
+
log.Println("head before apply", head.Hash().String())
258
+
259
+
if err := cmd.Run(); err != nil {
260
+
return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String())
261
+
}
262
+
263
+
if err := g.Refresh(); err != nil {
264
+
return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
265
+
}
266
+
267
+
head, err = g.r.Head()
268
+
if err != nil {
269
+
return plumbing.ZeroHash, err
270
+
}
271
+
log.Println("head after apply", head.Hash().String())
272
+
273
+
newHash := head.Hash()
274
+
if changeId, err := singlePatch.ChangeId(); err != nil {
275
+
// no change ID
276
+
} else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
277
+
return plumbing.ZeroHash, err
278
+
} else {
279
+
newHash = updatedHash
280
+
}
281
+
282
+
return newHash, nil
283
+
}
284
+
285
+
func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
286
+
log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
287
+
obj, err := g.r.CommitObject(hash)
288
+
if err != nil {
289
+
return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
290
+
}
291
+
292
+
// write the change-id header
293
+
obj.ExtraHeaders["change-id"] = []byte(changeId)
294
+
295
+
// create a new object
296
+
dest := g.r.Storer.NewEncodedObject()
297
+
if err := obj.Encode(dest); err != nil {
298
+
return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
299
+
}
300
+
301
+
// store the new object
302
+
newHash, err := g.r.Storer.SetEncodedObject(dest)
303
+
if err != nil {
304
+
return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
305
+
}
306
+
307
+
log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
308
+
309
+
// find the branch that HEAD is pointing to
310
+
ref, err := g.r.Head()
311
+
if err != nil {
312
+
return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
313
+
}
314
+
315
+
// and update that branch to point to new commit
316
+
if ref.Name().IsBranch() {
317
+
err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
318
+
if err != nil {
319
+
return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
320
+
}
321
+
}
322
+
323
+
// new hash of commit
324
+
return newHash, nil
325
+
}
326
+
327
+
func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error {
328
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
329
return val
330
}
···
352
return result
353
}
354
355
+
func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
356
patchFile, err := g.createTempFileWithPatch(patchData)
357
if err != nil {
358
return &ErrMerge{
···
371
}
372
defer os.RemoveAll(tmpDir)
373
374
+
tmpRepo, err := PlainOpen(tmpDir)
375
+
if err != nil {
376
+
return err
377
+
}
378
+
379
+
if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
380
return err
381
}
382
+46
knotserver/internal.go
+46
knotserver/internal.go
···
13
securejoin "github.com/cyphar/filepath-securejoin"
14
"github.com/go-chi/chi/v5"
15
"github.com/go-chi/chi/v5/middleware"
16
"tangled.org/core/api/tangled"
17
"tangled.org/core/hook"
18
"tangled.org/core/knotserver/config"
19
"tangled.org/core/knotserver/db"
20
"tangled.org/core/knotserver/git"
···
118
// non-fatal
119
}
120
121
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
122
if err != nil {
123
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
···
126
}
127
128
writeJSON(w, resp)
129
}
130
131
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
13
securejoin "github.com/cyphar/filepath-securejoin"
14
"github.com/go-chi/chi/v5"
15
"github.com/go-chi/chi/v5/middleware"
16
+
"github.com/go-git/go-git/v5/plumbing"
17
"tangled.org/core/api/tangled"
18
"tangled.org/core/hook"
19
+
"tangled.org/core/idresolver"
20
"tangled.org/core/knotserver/config"
21
"tangled.org/core/knotserver/db"
22
"tangled.org/core/knotserver/git"
···
120
// non-fatal
121
}
122
123
+
if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
124
+
msg, err := h.replyCompare(line, gitUserDid, gitRelativeDir, repoName, r.Context())
125
+
if err != nil {
126
+
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
127
+
// non-fatal
128
+
} else {
129
+
for msgLine := range msg {
130
+
resp.Messages = append(resp.Messages, msg[msgLine])
131
+
}
132
+
}
133
+
}
134
+
135
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
136
if err != nil {
137
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
···
140
}
141
142
writeJSON(w, resp)
143
+
}
144
+
145
+
func (h *InternalHandle) replyCompare(line git.PostReceiveLine, gitUserDid string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
146
+
l := h.l.With("handler", "replyCompare")
147
+
userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, gitUserDid)
148
+
user := gitUserDid
149
+
if err != nil {
150
+
l.Error("Failed to fetch user identity", "err", err)
151
+
// non-fatal
152
+
} else {
153
+
user = userIdent.Handle.String()
154
+
}
155
+
gr, err := git.PlainOpen(gitRelativeDir)
156
+
if err != nil {
157
+
l.Error("Failed to open git repository", "err", err)
158
+
return []string{}, err
159
+
}
160
+
defaultBranch, err := gr.FindMainBranch()
161
+
if err != nil {
162
+
l.Error("Failed to fetch default branch", "err", err)
163
+
return []string{}, err
164
+
}
165
+
if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
166
+
return []string{}, nil
167
+
}
168
+
ZWS := "\u200B"
169
+
var msg []string
170
+
msg = append(msg, ZWS)
171
+
msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
172
+
msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
173
+
msg = append(msg, ZWS)
174
+
return msg, nil
175
}
176
177
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
+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(actorDid.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())
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
+1
-1
knotserver/xrpc/merge_check.go
+1
-1
knotserver/xrpc/merge_check.go
+1
knotserver/xrpc/xrpc.go
+1
knotserver/xrpc/xrpc.go
···
38
r.Use(x.ServiceAuth.VerifyServiceAuth)
39
40
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
41
+
r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch)
42
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
43
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
44
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+30
lexicons/repo/deleteBranch.json
+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
-11
nix/gomod2nix.toml
+23
-11
nix/gomod2nix.toml
···
40
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
[mod."github.com/bluesky-social/indigo"]
43
-
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
-
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
45
[mod."github.com/bluesky-social/jetstream"]
46
version = "v0.0.0-20241210005130-ea96859b93d1"
47
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
163
[mod."github.com/gogo/protobuf"]
164
version = "v1.3.2"
165
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
166
[mod."github.com/golang-jwt/jwt/v5"]
167
version = "v5.2.3"
168
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
407
[mod."github.com/spaolacci/murmur3"]
408
version = "v1.1.0"
409
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
410
[mod."github.com/stretchr/testify"]
411
version = "v1.10.0"
412
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
432
version = "v0.1.15"
433
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
434
[mod."github.com/yuin/goldmark"]
435
-
version = "v1.7.12"
436
-
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
437
[mod."github.com/yuin/goldmark-highlighting/v2"]
438
version = "v2.0.0-20230729083705-37449abec8cc"
439
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
440
[mod."gitlab.com/yawning/secp256k1-voi"]
441
version = "v0.0.0-20230925100816-f2616030848b"
442
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
···
479
[mod."golang.org/x/exp"]
480
version = "v0.0.0-20250620022241-b7579e27df2b"
481
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
482
[mod."golang.org/x/net"]
483
version = "v0.42.0"
484
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
485
[mod."golang.org/x/sync"]
486
-
version = "v0.16.0"
487
-
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
488
[mod."golang.org/x/sys"]
489
version = "v0.34.0"
490
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
491
[mod."golang.org/x/text"]
492
-
version = "v0.27.0"
493
-
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
494
[mod."golang.org/x/time"]
495
version = "v0.12.0"
496
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
527
[mod."lukechampine.com/blake3"]
528
version = "v1.4.1"
529
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
530
-
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
531
-
version = "v0.0.0-20250724194903-28e660378cb1"
532
-
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
···
40
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
[mod."github.com/bluesky-social/indigo"]
43
+
version = "v0.0.0-20251003000214-3259b215110e"
44
+
hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo="
45
[mod."github.com/bluesky-social/jetstream"]
46
version = "v0.0.0-20241210005130-ea96859b93d1"
47
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
163
[mod."github.com/gogo/protobuf"]
164
version = "v1.3.2"
165
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
166
+
[mod."github.com/goki/freetype"]
167
+
version = "v1.0.5"
168
+
hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs="
169
[mod."github.com/golang-jwt/jwt/v5"]
170
version = "v5.2.3"
171
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
410
[mod."github.com/spaolacci/murmur3"]
411
version = "v1.1.0"
412
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
413
+
[mod."github.com/srwiley/oksvg"]
414
+
version = "v0.0.0-20221011165216-be6e8873101c"
415
+
hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk="
416
+
[mod."github.com/srwiley/rasterx"]
417
+
version = "v0.0.0-20220730225603-2ab79fcdd4ef"
418
+
hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68="
419
[mod."github.com/stretchr/testify"]
420
version = "v1.10.0"
421
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
441
version = "v0.1.15"
442
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
443
[mod."github.com/yuin/goldmark"]
444
+
version = "v1.7.13"
445
+
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
446
[mod."github.com/yuin/goldmark-highlighting/v2"]
447
version = "v2.0.0-20230729083705-37449abec8cc"
448
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
449
+
[mod."gitlab.com/staticnoise/goldmark-callout"]
450
+
version = "v0.0.0-20240609120641-6366b799e4ab"
451
+
hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44="
452
[mod."gitlab.com/yawning/secp256k1-voi"]
453
version = "v0.0.0-20230925100816-f2616030848b"
454
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
···
491
[mod."golang.org/x/exp"]
492
version = "v0.0.0-20250620022241-b7579e27df2b"
493
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
494
+
[mod."golang.org/x/image"]
495
+
version = "v0.31.0"
496
+
hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg="
497
[mod."golang.org/x/net"]
498
version = "v0.42.0"
499
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
500
[mod."golang.org/x/sync"]
501
+
version = "v0.17.0"
502
+
hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0="
503
[mod."golang.org/x/sys"]
504
version = "v0.34.0"
505
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
506
[mod."golang.org/x/text"]
507
+
version = "v0.29.0"
508
+
hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI="
509
[mod."golang.org/x/time"]
510
version = "v0.12.0"
511
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
542
[mod."lukechampine.com/blake3"]
543
version = "v1.4.1"
544
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
+1
nix/pkgs/appview-static-files.nix
+1
nix/pkgs/appview-static-files.nix
···
22
cp -rf ${lucide-src}/*.svg icons/
23
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
26
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
# for whatever reason (produces broken css), so we are doing this instead
···
22
cp -rf ${lucide-src}/*.svg icons/
23
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
+
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
27
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
# for whatever reason (produces broken css), so we are doing this instead