+1
.gitignore
+1
.gitignore
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/test.yml
+1
-1
.tangled/workflows/test.yml
+3
-1
api/tangled/actorprofile.go
+3
-1
api/tangled/actorprofile.go
···
27
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
33
}
+196
-2
api/tangled/cbor_gen.go
+196
-2
api/tangled/cbor_gen.go
···
26
26
}
27
27
28
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 7
29
+
fieldCount := 8
30
30
31
31
if t.Description == nil {
32
32
fieldCount--
···
41
41
}
42
42
43
43
if t.PinnedRepositories == nil {
44
+
fieldCount--
45
+
}
46
+
47
+
if t.Pronouns == nil {
44
48
fieldCount--
45
49
}
46
50
···
186
190
return err
187
191
}
188
192
if _, err := cw.WriteString(string(*t.Location)); err != nil {
193
+
return err
194
+
}
195
+
}
196
+
}
197
+
198
+
// t.Pronouns (string) (string)
199
+
if t.Pronouns != nil {
200
+
201
+
if len("pronouns") > 1000000 {
202
+
return xerrors.Errorf("Value in field \"pronouns\" was too long")
203
+
}
204
+
205
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil {
206
+
return err
207
+
}
208
+
if _, err := cw.WriteString(string("pronouns")); err != nil {
209
+
return err
210
+
}
211
+
212
+
if t.Pronouns == nil {
213
+
if _, err := cw.Write(cbg.CborNull); err != nil {
214
+
return err
215
+
}
216
+
} else {
217
+
if len(*t.Pronouns) > 1000000 {
218
+
return xerrors.Errorf("Value in field t.Pronouns was too long")
219
+
}
220
+
221
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil {
222
+
return err
223
+
}
224
+
if _, err := cw.WriteString(string(*t.Pronouns)); err != nil {
189
225
return err
190
226
}
191
227
}
···
430
466
}
431
467
432
468
t.Location = (*string)(&sval)
469
+
}
470
+
}
471
+
// t.Pronouns (string) (string)
472
+
case "pronouns":
473
+
474
+
{
475
+
b, err := cr.ReadByte()
476
+
if err != nil {
477
+
return err
478
+
}
479
+
if b != cbg.CborNull[0] {
480
+
if err := cr.UnreadByte(); err != nil {
481
+
return err
482
+
}
483
+
484
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
485
+
if err != nil {
486
+
return err
487
+
}
488
+
489
+
t.Pronouns = (*string)(&sval)
433
490
}
434
491
}
435
492
// t.Description (string) (string)
···
5806
5863
}
5807
5864
5808
5865
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5866
+
fieldCount := 10
5810
5867
5811
5868
if t.Description == nil {
5812
5869
fieldCount--
···
5821
5878
}
5822
5879
5823
5880
if t.Spindle == nil {
5881
+
fieldCount--
5882
+
}
5883
+
5884
+
if t.Topics == nil {
5885
+
fieldCount--
5886
+
}
5887
+
5888
+
if t.Website == nil {
5824
5889
fieldCount--
5825
5890
}
5826
5891
···
5961
6026
}
5962
6027
}
5963
6028
6029
+
// t.Topics ([]string) (slice)
6030
+
if t.Topics != nil {
6031
+
6032
+
if len("topics") > 1000000 {
6033
+
return xerrors.Errorf("Value in field \"topics\" was too long")
6034
+
}
6035
+
6036
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil {
6037
+
return err
6038
+
}
6039
+
if _, err := cw.WriteString(string("topics")); err != nil {
6040
+
return err
6041
+
}
6042
+
6043
+
if len(t.Topics) > 8192 {
6044
+
return xerrors.Errorf("Slice value in field t.Topics was too long")
6045
+
}
6046
+
6047
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil {
6048
+
return err
6049
+
}
6050
+
for _, v := range t.Topics {
6051
+
if len(v) > 1000000 {
6052
+
return xerrors.Errorf("Value in field v was too long")
6053
+
}
6054
+
6055
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
6056
+
return err
6057
+
}
6058
+
if _, err := cw.WriteString(string(v)); err != nil {
6059
+
return err
6060
+
}
6061
+
6062
+
}
6063
+
}
6064
+
5964
6065
// t.Spindle (string) (string)
5965
6066
if t.Spindle != nil {
5966
6067
···
5993
6094
}
5994
6095
}
5995
6096
6097
+
// t.Website (string) (string)
6098
+
if t.Website != nil {
6099
+
6100
+
if len("website") > 1000000 {
6101
+
return xerrors.Errorf("Value in field \"website\" was too long")
6102
+
}
6103
+
6104
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil {
6105
+
return err
6106
+
}
6107
+
if _, err := cw.WriteString(string("website")); err != nil {
6108
+
return err
6109
+
}
6110
+
6111
+
if t.Website == nil {
6112
+
if _, err := cw.Write(cbg.CborNull); err != nil {
6113
+
return err
6114
+
}
6115
+
} else {
6116
+
if len(*t.Website) > 1000000 {
6117
+
return xerrors.Errorf("Value in field t.Website was too long")
6118
+
}
6119
+
6120
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil {
6121
+
return err
6122
+
}
6123
+
if _, err := cw.WriteString(string(*t.Website)); err != nil {
6124
+
return err
6125
+
}
6126
+
}
6127
+
}
6128
+
5996
6129
// t.CreatedAt (string) (string)
5997
6130
if len("createdAt") > 1000000 {
5998
6131
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
6318
t.Source = (*string)(&sval)
6186
6319
}
6187
6320
}
6321
+
// t.Topics ([]string) (slice)
6322
+
case "topics":
6323
+
6324
+
maj, extra, err = cr.ReadHeader()
6325
+
if err != nil {
6326
+
return err
6327
+
}
6328
+
6329
+
if extra > 8192 {
6330
+
return fmt.Errorf("t.Topics: array too large (%d)", extra)
6331
+
}
6332
+
6333
+
if maj != cbg.MajArray {
6334
+
return fmt.Errorf("expected cbor array")
6335
+
}
6336
+
6337
+
if extra > 0 {
6338
+
t.Topics = make([]string, extra)
6339
+
}
6340
+
6341
+
for i := 0; i < int(extra); i++ {
6342
+
{
6343
+
var maj byte
6344
+
var extra uint64
6345
+
var err error
6346
+
_ = maj
6347
+
_ = extra
6348
+
_ = err
6349
+
6350
+
{
6351
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6352
+
if err != nil {
6353
+
return err
6354
+
}
6355
+
6356
+
t.Topics[i] = string(sval)
6357
+
}
6358
+
6359
+
}
6360
+
}
6188
6361
// t.Spindle (string) (string)
6189
6362
case "spindle":
6190
6363
···
6204
6377
}
6205
6378
6206
6379
t.Spindle = (*string)(&sval)
6380
+
}
6381
+
}
6382
+
// t.Website (string) (string)
6383
+
case "website":
6384
+
6385
+
{
6386
+
b, err := cr.ReadByte()
6387
+
if err != nil {
6388
+
return err
6389
+
}
6390
+
if b != cbg.CborNull[0] {
6391
+
if err := cr.UnreadByte(); err != nil {
6392
+
return err
6393
+
}
6394
+
6395
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6396
+
if err != nil {
6397
+
return err
6398
+
}
6399
+
6400
+
t.Website = (*string)(&sval)
6207
6401
}
6208
6402
}
6209
6403
// t.CreatedAt (string) (string)
+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
+
}
+10
api/tangled/repotree.go
+10
api/tangled/repotree.go
···
31
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
32
// parent: The parent path in the tree
33
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// readme: Readme for this file tree
35
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
34
36
// ref: The git reference used
35
37
Ref string `json:"ref" cborgen:"ref"`
38
+
}
39
+
40
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41
+
type RepoTree_Readme struct {
42
+
// contents: Contents of the readme file
43
+
Contents string `json:"contents" cborgen:"contents"`
44
+
// filename: Name of the readme file
45
+
Filename string `json:"filename" cborgen:"filename"`
36
46
}
37
47
38
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4
api/tangled/tangledrepo.go
+4
api/tangled/tangledrepo.go
···
30
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
31
// spindle: CI runner to send jobs to and receive results from
32
32
Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"`
33
+
// topics: Topics related to the repo
34
+
Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"`
35
+
// website: Any URI related to the repo
36
+
Website *string `json:"website,omitempty" cborgen:"website,omitempty"`
33
37
}
+19
-4
appview/config/config.go
+19
-4
appview/config/config.go
···
13
13
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
14
DbPath string `env:"DB_PATH, default=appview.db"`
15
15
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"`
17
+
AppviewName string `env:"APPVIEW_Name, default=Tangled"`
17
18
Dev bool `env:"DEV, default=false"`
18
19
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
20
···
25
26
}
26
27
27
28
type OAuthConfig struct {
28
-
Jwks string `env:"JWKS"`
29
+
ClientSecret string `env:"CLIENT_SECRET"`
30
+
ClientKid string `env:"CLIENT_KID"`
31
+
}
32
+
33
+
type PlcConfig struct {
34
+
PLCURL string `env:"URL, default=https://plc.directory"`
29
35
}
30
36
31
37
type JetstreamConfig struct {
···
72
78
}
73
79
74
80
type Cloudflare struct {
75
-
ApiToken string `env:"API_TOKEN"`
76
-
ZoneId string `env:"ZONE_ID"`
81
+
ApiToken string `env:"API_TOKEN"`
82
+
ZoneId string `env:"ZONE_ID"`
83
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
84
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
85
+
}
86
+
87
+
type LabelConfig struct {
88
+
DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=,
89
+
GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"`
77
90
}
78
91
79
92
func (cfg RedisConfig) ToURL() string {
···
101
114
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
102
115
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
103
116
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
104
118
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
105
119
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
106
121
}
107
122
108
123
func LoadConfig(ctx context.Context) (*Config, error) {
-1
appview/db/artifact.go
-1
appview/db/artifact.go
+53
appview/db/collaborators.go
+53
appview/db/collaborators.go
···
3
3
import (
4
4
"fmt"
5
5
"strings"
6
+
"time"
6
7
7
8
"tangled.org/core/appview/models"
8
9
)
···
59
60
60
61
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
61
62
}
63
+
64
+
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
+
var collaborators []models.Collaborator
66
+
var conditions []string
67
+
var args []any
68
+
for _, filter := range filters {
69
+
conditions = append(conditions, filter.Condition())
70
+
args = append(args, filter.Arg()...)
71
+
}
72
+
whereClause := ""
73
+
if conditions != nil {
74
+
whereClause = " where " + strings.Join(conditions, " and ")
75
+
}
76
+
query := fmt.Sprintf(`select
77
+
id,
78
+
did,
79
+
rkey,
80
+
subject_did,
81
+
repo_at,
82
+
created
83
+
from collaborators %s`,
84
+
whereClause,
85
+
)
86
+
rows, err := e.Query(query, args...)
87
+
if err != nil {
88
+
return nil, err
89
+
}
90
+
defer rows.Close()
91
+
for rows.Next() {
92
+
var collaborator models.Collaborator
93
+
var createdAt string
94
+
if err := rows.Scan(
95
+
&collaborator.Id,
96
+
&collaborator.Did,
97
+
&collaborator.Rkey,
98
+
&collaborator.SubjectDid,
99
+
&collaborator.RepoAt,
100
+
&createdAt,
101
+
); err != nil {
102
+
return nil, err
103
+
}
104
+
collaborator.Created, err = time.Parse(time.RFC3339, createdAt)
105
+
if err != nil {
106
+
collaborator.Created = time.Now()
107
+
}
108
+
collaborators = append(collaborators, collaborator)
109
+
}
110
+
if err := rows.Err(); err != nil {
111
+
return nil, err
112
+
}
113
+
return collaborators, nil
114
+
}
+236
-34
appview/db/db.go
+236
-34
appview/db/db.go
···
4
4
"context"
5
5
"database/sql"
6
6
"fmt"
7
-
"log"
7
+
"log/slog"
8
8
"reflect"
9
9
"strings"
10
10
11
11
_ "github.com/mattn/go-sqlite3"
12
+
"tangled.org/core/log"
12
13
)
13
14
14
15
type DB struct {
15
16
*sql.DB
17
+
logger *slog.Logger
16
18
}
17
19
18
20
type Execer interface {
···
26
28
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
27
29
}
28
30
29
-
func Make(dbPath string) (*DB, error) {
31
+
func Make(ctx context.Context, dbPath string) (*DB, error) {
30
32
// https://github.com/mattn/go-sqlite3#connection-string
31
33
opts := []string{
32
34
"_foreign_keys=1",
···
35
37
"_auto_vacuum=incremental",
36
38
}
37
39
40
+
logger := log.FromContext(ctx)
41
+
logger = log.SubLogger(logger, "db")
42
+
38
43
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
39
44
if err != nil {
40
45
return nil, err
41
46
}
42
-
43
-
ctx := context.Background()
44
47
45
48
conn, err := db.Conn(ctx)
46
49
if err != nil {
···
530
533
unique (repo_at, label_at)
531
534
);
532
535
536
+
create table if not exists notifications (
537
+
id integer primary key autoincrement,
538
+
recipient_did text not null,
539
+
actor_did text not null,
540
+
type text not null,
541
+
entity_type text not null,
542
+
entity_id text not null,
543
+
read integer not null default 0,
544
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
545
+
repo_id integer references repos(id),
546
+
issue_id integer references issues(id),
547
+
pull_id integer references pulls(id)
548
+
);
549
+
550
+
create table if not exists notification_preferences (
551
+
id integer primary key autoincrement,
552
+
user_did text not null unique,
553
+
repo_starred integer not null default 1,
554
+
issue_created integer not null default 1,
555
+
issue_commented integer not null default 1,
556
+
pull_created integer not null default 1,
557
+
pull_commented integer not null default 1,
558
+
followed integer not null default 1,
559
+
pull_merged integer not null default 1,
560
+
issue_closed integer not null default 1,
561
+
email_notifications integer not null default 0
562
+
);
563
+
533
564
create table if not exists migrations (
534
565
id integer primary key autoincrement,
535
566
name text unique
536
567
);
537
568
538
-
-- indexes for better star query performance
569
+
-- indexes for better performance
570
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
571
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
539
572
create index if not exists idx_stars_created on stars(created);
540
573
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
541
574
`)
···
544
577
}
545
578
546
579
// run migrations
547
-
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
580
+
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
548
581
tx.Exec(`
549
582
alter table repos add column description text check (length(description) <= 200);
550
583
`)
551
584
return nil
552
585
})
553
586
554
-
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
587
+
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
555
588
// add unconstrained column
556
589
_, err := tx.Exec(`
557
590
alter table public_keys
···
574
607
return nil
575
608
})
576
609
577
-
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
610
+
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
578
611
_, err := tx.Exec(`
579
612
alter table comments drop column comment_at;
580
613
alter table comments add column rkey text;
···
582
615
return err
583
616
})
584
617
585
-
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
618
+
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
586
619
_, err := tx.Exec(`
587
620
alter table comments add column deleted text; -- timestamp
588
621
alter table comments add column edited text; -- timestamp
···
590
623
return err
591
624
})
592
625
593
-
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
626
+
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
594
627
_, err := tx.Exec(`
595
628
alter table pulls add column source_branch text;
596
629
alter table pulls add column source_repo_at text;
···
599
632
return err
600
633
})
601
634
602
-
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
635
+
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
603
636
_, err := tx.Exec(`
604
637
alter table repos add column source text;
605
638
`)
···
611
644
//
612
645
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
613
646
conn.ExecContext(ctx, "pragma foreign_keys = off;")
614
-
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
647
+
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
615
648
_, err := tx.Exec(`
616
649
create table pulls_new (
617
650
-- identifiers
···
668
701
})
669
702
conn.ExecContext(ctx, "pragma foreign_keys = on;")
670
703
671
-
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
704
+
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
672
705
tx.Exec(`
673
706
alter table repos add column spindle text;
674
707
`)
···
678
711
// drop all knot secrets, add unique constraint to knots
679
712
//
680
713
// knots will henceforth use service auth for signed requests
681
-
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
714
+
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
682
715
_, err := tx.Exec(`
683
716
create table registrations_new (
684
717
id integer primary key autoincrement,
···
701
734
})
702
735
703
736
// recreate and add rkey + created columns with default constraint
704
-
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
737
+
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
705
738
// create new table
706
739
// - repo_at instead of repo integer
707
740
// - rkey field
···
755
788
return err
756
789
})
757
790
758
-
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
791
+
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
759
792
_, err := tx.Exec(`
760
793
alter table issues add column rkey text not null default '';
761
794
···
767
800
})
768
801
769
802
// repurpose the read-only column to "needs-upgrade"
770
-
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
803
+
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
771
804
_, err := tx.Exec(`
772
805
alter table registrations rename column read_only to needs_upgrade;
773
806
`)
···
775
808
})
776
809
777
810
// require all knots to upgrade after the release of total xrpc
778
-
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
811
+
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
779
812
_, err := tx.Exec(`
780
813
update registrations set needs_upgrade = 1;
781
814
`)
···
783
816
})
784
817
785
818
// require all knots to upgrade after the release of total xrpc
786
-
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
819
+
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
787
820
_, err := tx.Exec(`
788
821
alter table spindles add column needs_upgrade integer not null default 0;
789
822
`)
790
-
if err != nil {
791
-
return err
792
-
}
793
-
794
-
_, err = tx.Exec(`
795
-
update spindles set needs_upgrade = 1;
796
-
`)
797
823
return err
798
824
})
799
825
···
808
834
//
809
835
// disable foreign-keys for the next migration
810
836
conn.ExecContext(ctx, "pragma foreign_keys = off;")
811
-
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
837
+
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
812
838
_, err := tx.Exec(`
813
839
create table if not exists issues_new (
814
840
-- identifiers
···
878
904
// - new columns
879
905
// * column "reply_to" which can be any other comment
880
906
// * column "at-uri" which is a generated column
881
-
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
907
+
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
882
908
_, err := tx.Exec(`
883
909
create table if not exists issue_comments (
884
910
-- identifiers
···
931
957
return err
932
958
})
933
959
934
-
return &DB{db}, nil
960
+
// add generated at_uri column to pulls table
961
+
//
962
+
// this requires a full table recreation because stored columns
963
+
// cannot be added via alter
964
+
//
965
+
// disable foreign-keys for the next migration
966
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
967
+
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
968
+
_, err := tx.Exec(`
969
+
create table if not exists pulls_new (
970
+
-- identifiers
971
+
id integer primary key autoincrement,
972
+
pull_id integer not null,
973
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
974
+
975
+
-- at identifiers
976
+
repo_at text not null,
977
+
owner_did text not null,
978
+
rkey text not null,
979
+
980
+
-- content
981
+
title text not null,
982
+
body text not null,
983
+
target_branch text not null,
984
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
985
+
986
+
-- source info
987
+
source_branch text,
988
+
source_repo_at text,
989
+
990
+
-- stacking
991
+
stack_id text,
992
+
change_id text,
993
+
parent_change_id text,
994
+
995
+
-- meta
996
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
997
+
998
+
-- constraints
999
+
unique(repo_at, pull_id),
1000
+
unique(at_uri),
1001
+
foreign key (repo_at) references repos(at_uri) on delete cascade
1002
+
);
1003
+
`)
1004
+
if err != nil {
1005
+
return err
1006
+
}
1007
+
1008
+
// transfer data
1009
+
_, err = tx.Exec(`
1010
+
insert into pulls_new (
1011
+
id, pull_id, repo_at, owner_did, rkey,
1012
+
title, body, target_branch, state,
1013
+
source_branch, source_repo_at,
1014
+
stack_id, change_id, parent_change_id,
1015
+
created
1016
+
)
1017
+
select
1018
+
id, pull_id, repo_at, owner_did, rkey,
1019
+
title, body, target_branch, state,
1020
+
source_branch, source_repo_at,
1021
+
stack_id, change_id, parent_change_id,
1022
+
created
1023
+
from pulls;
1024
+
`)
1025
+
if err != nil {
1026
+
return err
1027
+
}
1028
+
1029
+
// drop old table
1030
+
_, err = tx.Exec(`drop table pulls`)
1031
+
if err != nil {
1032
+
return err
1033
+
}
1034
+
1035
+
// rename new table
1036
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1037
+
return err
1038
+
})
1039
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1040
+
1041
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1042
+
//
1043
+
// this requires a full table recreation because stored columns
1044
+
// cannot be added via alter
1045
+
//
1046
+
// disable foreign-keys for the next migration
1047
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1048
+
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1049
+
_, err := tx.Exec(`
1050
+
create table if not exists pull_submissions_new (
1051
+
-- identifiers
1052
+
id integer primary key autoincrement,
1053
+
pull_at text not null,
1054
+
1055
+
-- content, these are immutable, and require a resubmission to update
1056
+
round_number integer not null default 0,
1057
+
patch text,
1058
+
source_rev text,
1059
+
1060
+
-- meta
1061
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1062
+
1063
+
-- constraints
1064
+
unique(pull_at, round_number),
1065
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1066
+
);
1067
+
`)
1068
+
if err != nil {
1069
+
return err
1070
+
}
1071
+
1072
+
// transfer data, constructing pull_at from pulls table
1073
+
_, err = tx.Exec(`
1074
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1075
+
select
1076
+
ps.id,
1077
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1078
+
ps.round_number,
1079
+
ps.patch,
1080
+
ps.created
1081
+
from pull_submissions ps
1082
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1083
+
`)
1084
+
if err != nil {
1085
+
return err
1086
+
}
1087
+
1088
+
// drop old table
1089
+
_, err = tx.Exec(`drop table pull_submissions`)
1090
+
if err != nil {
1091
+
return err
1092
+
}
1093
+
1094
+
// rename new table
1095
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1096
+
return err
1097
+
})
1098
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1099
+
1100
+
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
+
// (but not on the pds record), because calculating the combined patch requires a git index
1102
+
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
+
_, err := tx.Exec(`
1104
+
alter table pull_submissions add column combined text;
1105
+
`)
1106
+
return err
1107
+
})
1108
+
1109
+
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1110
+
_, err := tx.Exec(`
1111
+
alter table profile add column pronouns text;
1112
+
`)
1113
+
return err
1114
+
})
1115
+
1116
+
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1117
+
_, err := tx.Exec(`
1118
+
alter table repos add column website text;
1119
+
alter table repos add column topics text;
1120
+
`)
1121
+
return err
1122
+
})
1123
+
1124
+
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1125
+
_, err := tx.Exec(`
1126
+
alter table notification_preferences add column user_mentioned integer not null default 1;
1127
+
`)
1128
+
return err
1129
+
})
1130
+
1131
+
return &DB{
1132
+
db,
1133
+
logger,
1134
+
}, nil
935
1135
}
936
1136
937
1137
type migrationFn = func(*sql.Tx) error
938
1138
939
-
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
1139
+
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1140
+
logger = logger.With("migration", name)
1141
+
940
1142
tx, err := c.BeginTx(context.Background(), nil)
941
1143
if err != nil {
942
1144
return err
···
953
1155
// run migration
954
1156
err = migrationFn(tx)
955
1157
if err != nil {
956
-
log.Printf("Failed to run migration %s: %v", name, err)
1158
+
logger.Error("failed to run migration", "err", err)
957
1159
return err
958
1160
}
959
1161
960
1162
// mark migration as complete
961
1163
_, err = tx.Exec("insert into migrations (name) values (?)", name)
962
1164
if err != nil {
963
-
log.Printf("Failed to mark migration %s as complete: %v", name, err)
1165
+
logger.Error("failed to mark migration as complete", "err", err)
964
1166
return err
965
1167
}
966
1168
···
969
1171
return err
970
1172
}
971
1173
972
-
log.Printf("migration %s applied successfully", name)
1174
+
logger.Info("migration applied successfully")
973
1175
} else {
974
-
log.Printf("skipped migration %s, already applied", name)
1176
+
logger.Warn("skipped migration, already applied")
975
1177
}
976
1178
977
1179
return nil
+13
-9
appview/db/email.go
+13
-9
appview/db/email.go
···
71
71
return did, nil
72
72
}
73
73
74
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
75
-
if len(ems) == 0 {
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
76
76
return make(map[string]string), nil
77
77
}
78
78
···
80
80
if isVerifiedFilter {
81
81
verifiedFilter = 1
82
82
}
83
+
84
+
assoc := make(map[string]string)
83
85
84
86
// Create placeholders for the IN clause
85
-
placeholders := make([]string, len(ems))
86
-
args := make([]any, len(ems)+1)
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
87
89
88
90
args[0] = verifiedFilter
89
-
for i, em := range ems {
90
-
placeholders[i] = "?"
91
-
args[i+1] = em
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)
92
98
}
93
99
94
100
query := `
···
104
110
return nil, err
105
111
}
106
112
defer rows.Close()
107
-
108
-
assoc := make(map[string]string)
109
113
110
114
for rows.Next() {
111
115
var email, did string
+72
-16
appview/db/issues.go
+72
-16
appview/db/issues.go
···
101
101
pLower := FilterGte("row_num", page.Offset+1)
102
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
103
104
-
args = append(args, pLower.Arg()...)
105
-
args = append(args, pUpper.Arg()...)
106
-
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
104
+
pageClause := ""
105
+
if page.Limit > 0 {
106
+
args = append(args, pLower.Arg()...)
107
+
args = append(args, pUpper.Arg()...)
108
+
pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition()
109
+
}
107
110
108
111
query := fmt.Sprintf(
109
112
`
···
128
131
%s
129
132
`,
130
133
whereClause,
131
-
pagination,
134
+
pageClause,
132
135
)
133
136
134
137
rows, err := e.Query(query, args...)
···
243
246
return issues, nil
244
247
}
245
248
249
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
250
+
issues, err := GetIssuesPaginated(
251
+
e,
252
+
pagination.Page{},
253
+
FilterEq("repo_at", repoAt),
254
+
FilterEq("issue_id", issueId),
255
+
)
256
+
if err != nil {
257
+
return nil, err
258
+
}
259
+
if len(issues) != 1 {
260
+
return nil, sql.ErrNoRows
261
+
}
262
+
263
+
return &issues[0], nil
264
+
}
265
+
246
266
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
-
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
267
+
return GetIssuesPaginated(e, pagination.Page{}, filters...)
248
268
}
249
269
250
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
-
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
-
row := e.QueryRow(query, repoAt, issueId)
270
+
// GetIssueIDs gets list of all existing issue's IDs
271
+
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
272
+
var ids []int64
273
+
274
+
var filters []filter
275
+
openValue := 0
276
+
if opts.IsOpen {
277
+
openValue = 1
278
+
}
279
+
filters = append(filters, FilterEq("open", openValue))
280
+
if opts.RepoAt != "" {
281
+
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
282
+
}
283
+
284
+
var conditions []string
285
+
var args []any
286
+
287
+
for _, filter := range filters {
288
+
conditions = append(conditions, filter.Condition())
289
+
args = append(args, filter.Arg()...)
290
+
}
253
291
254
-
var issue models.Issue
255
-
var createdAt string
256
-
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
292
+
whereClause := ""
293
+
if conditions != nil {
294
+
whereClause = " where " + strings.Join(conditions, " and ")
295
+
}
296
+
query := fmt.Sprintf(
297
+
`
298
+
select
299
+
id
300
+
from
301
+
issues
302
+
%s
303
+
limit ? offset ?`,
304
+
whereClause,
305
+
)
306
+
args = append(args, opts.Page.Limit, opts.Page.Offset)
307
+
rows, err := e.Query(query, args...)
257
308
if err != nil {
258
309
return nil, err
259
310
}
311
+
defer rows.Close()
260
312
261
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
262
-
if err != nil {
263
-
return nil, err
313
+
for rows.Next() {
314
+
var id int64
315
+
err := rows.Scan(&id)
316
+
if err != nil {
317
+
return nil, err
318
+
}
319
+
320
+
ids = append(ids, id)
264
321
}
265
-
issue.Created = createdTime
266
322
267
-
return &issue, nil
323
+
return ids, nil
268
324
}
269
325
270
326
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+34
appview/db/language.go
+34
appview/db/language.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
4
5
"fmt"
5
6
"strings"
6
7
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
9
"tangled.org/core/appview/models"
8
10
)
9
11
···
82
84
83
85
return nil
84
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+499
appview/db/notifications.go
+499
appview/db/notifications.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"strings"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pagination"
14
+
)
15
+
16
+
func CreateNotification(e Execer, notification *models.Notification) error {
17
+
query := `
18
+
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
19
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
20
+
`
21
+
22
+
result, err := e.Exec(query,
23
+
notification.RecipientDid,
24
+
notification.ActorDid,
25
+
string(notification.Type),
26
+
notification.EntityType,
27
+
notification.EntityId,
28
+
notification.Read,
29
+
notification.RepoId,
30
+
notification.IssueId,
31
+
notification.PullId,
32
+
)
33
+
if err != nil {
34
+
return fmt.Errorf("failed to create notification: %w", err)
35
+
}
36
+
37
+
id, err := result.LastInsertId()
38
+
if err != nil {
39
+
return fmt.Errorf("failed to get notification ID: %w", err)
40
+
}
41
+
42
+
notification.ID = id
43
+
return nil
44
+
}
45
+
46
+
// GetNotificationsPaginated retrieves notifications with filters and pagination
47
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
48
+
var conditions []string
49
+
var args []any
50
+
51
+
for _, filter := range filters {
52
+
conditions = append(conditions, filter.Condition())
53
+
args = append(args, filter.Arg()...)
54
+
}
55
+
56
+
whereClause := ""
57
+
if len(conditions) > 0 {
58
+
whereClause = "WHERE " + conditions[0]
59
+
for _, condition := range conditions[1:] {
60
+
whereClause += " AND " + condition
61
+
}
62
+
}
63
+
pageClause := ""
64
+
if page.Limit > 0 {
65
+
pageClause = " limit ? offset ? "
66
+
args = append(args, page.Limit, page.Offset)
67
+
}
68
+
69
+
query := fmt.Sprintf(`
70
+
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
71
+
from notifications
72
+
%s
73
+
order by created desc
74
+
%s
75
+
`, whereClause, pageClause)
76
+
77
+
rows, err := e.QueryContext(context.Background(), query, args...)
78
+
if err != nil {
79
+
return nil, fmt.Errorf("failed to query notifications: %w", err)
80
+
}
81
+
defer rows.Close()
82
+
83
+
var notifications []*models.Notification
84
+
for rows.Next() {
85
+
var n models.Notification
86
+
var typeStr string
87
+
var createdStr string
88
+
err := rows.Scan(
89
+
&n.ID,
90
+
&n.RecipientDid,
91
+
&n.ActorDid,
92
+
&typeStr,
93
+
&n.EntityType,
94
+
&n.EntityId,
95
+
&n.Read,
96
+
&createdStr,
97
+
&n.RepoId,
98
+
&n.IssueId,
99
+
&n.PullId,
100
+
)
101
+
if err != nil {
102
+
return nil, fmt.Errorf("failed to scan notification: %w", err)
103
+
}
104
+
n.Type = models.NotificationType(typeStr)
105
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
106
+
if err != nil {
107
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
108
+
}
109
+
notifications = append(notifications, &n)
110
+
}
111
+
112
+
return notifications, nil
113
+
}
114
+
115
+
// GetNotificationsWithEntities retrieves notifications with their related entities
116
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
117
+
var conditions []string
118
+
var args []any
119
+
120
+
for _, filter := range filters {
121
+
conditions = append(conditions, filter.Condition())
122
+
args = append(args, filter.Arg()...)
123
+
}
124
+
125
+
whereClause := ""
126
+
if len(conditions) > 0 {
127
+
whereClause = "WHERE " + conditions[0]
128
+
for _, condition := range conditions[1:] {
129
+
whereClause += " AND " + condition
130
+
}
131
+
}
132
+
133
+
query := fmt.Sprintf(`
134
+
select
135
+
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
136
+
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
137
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics,
138
+
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
139
+
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
140
+
from notifications n
141
+
left join repos r on n.repo_id = r.id
142
+
left join issues i on n.issue_id = i.id
143
+
left join pulls p on n.pull_id = p.id
144
+
%s
145
+
order by n.created desc
146
+
limit ? offset ?
147
+
`, whereClause)
148
+
149
+
args = append(args, page.Limit, page.Offset)
150
+
151
+
rows, err := e.QueryContext(context.Background(), query, args...)
152
+
if err != nil {
153
+
return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
154
+
}
155
+
defer rows.Close()
156
+
157
+
var notifications []*models.NotificationWithEntity
158
+
for rows.Next() {
159
+
var n models.Notification
160
+
var typeStr string
161
+
var createdStr string
162
+
var repo models.Repo
163
+
var issue models.Issue
164
+
var pull models.Pull
165
+
var rId, iId, pId sql.NullInt64
166
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
167
+
var iDid sql.NullString
168
+
var iIssueId sql.NullInt64
169
+
var iTitle sql.NullString
170
+
var iOpen sql.NullBool
171
+
var pOwnerDid sql.NullString
172
+
var pPullId sql.NullInt64
173
+
var pTitle sql.NullString
174
+
var pState sql.NullInt64
175
+
176
+
err := rows.Scan(
177
+
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
178
+
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
179
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
180
+
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
181
+
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
182
+
)
183
+
if err != nil {
184
+
return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
185
+
}
186
+
187
+
n.Type = models.NotificationType(typeStr)
188
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
189
+
if err != nil {
190
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
191
+
}
192
+
193
+
nwe := &models.NotificationWithEntity{Notification: &n}
194
+
195
+
// populate repo if present
196
+
if rId.Valid {
197
+
repo.Id = rId.Int64
198
+
if rDid.Valid {
199
+
repo.Did = rDid.String
200
+
}
201
+
if rName.Valid {
202
+
repo.Name = rName.String
203
+
}
204
+
if rDescription.Valid {
205
+
repo.Description = rDescription.String
206
+
}
207
+
if rWebsite.Valid {
208
+
repo.Website = rWebsite.String
209
+
}
210
+
if rTopicStr.Valid {
211
+
repo.Topics = strings.Fields(rTopicStr.String)
212
+
}
213
+
nwe.Repo = &repo
214
+
}
215
+
216
+
// populate issue if present
217
+
if iId.Valid {
218
+
issue.Id = iId.Int64
219
+
if iDid.Valid {
220
+
issue.Did = iDid.String
221
+
}
222
+
if iIssueId.Valid {
223
+
issue.IssueId = int(iIssueId.Int64)
224
+
}
225
+
if iTitle.Valid {
226
+
issue.Title = iTitle.String
227
+
}
228
+
if iOpen.Valid {
229
+
issue.Open = iOpen.Bool
230
+
}
231
+
nwe.Issue = &issue
232
+
}
233
+
234
+
// populate pull if present
235
+
if pId.Valid {
236
+
pull.ID = int(pId.Int64)
237
+
if pOwnerDid.Valid {
238
+
pull.OwnerDid = pOwnerDid.String
239
+
}
240
+
if pPullId.Valid {
241
+
pull.PullId = int(pPullId.Int64)
242
+
}
243
+
if pTitle.Valid {
244
+
pull.Title = pTitle.String
245
+
}
246
+
if pState.Valid {
247
+
pull.State = models.PullState(pState.Int64)
248
+
}
249
+
nwe.Pull = &pull
250
+
}
251
+
252
+
notifications = append(notifications, nwe)
253
+
}
254
+
255
+
return notifications, nil
256
+
}
257
+
258
+
// GetNotifications retrieves notifications with filters
259
+
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
260
+
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
261
+
}
262
+
263
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
264
+
var conditions []string
265
+
var args []any
266
+
for _, filter := range filters {
267
+
conditions = append(conditions, filter.Condition())
268
+
args = append(args, filter.Arg()...)
269
+
}
270
+
271
+
whereClause := ""
272
+
if conditions != nil {
273
+
whereClause = " where " + strings.Join(conditions, " and ")
274
+
}
275
+
276
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
277
+
var count int64
278
+
err := e.QueryRow(query, args...).Scan(&count)
279
+
280
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
281
+
return 0, err
282
+
}
283
+
284
+
return count, nil
285
+
}
286
+
287
+
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
288
+
idFilter := FilterEq("id", notificationID)
289
+
recipientFilter := FilterEq("recipient_did", userDID)
290
+
291
+
query := fmt.Sprintf(`
292
+
UPDATE notifications
293
+
SET read = 1
294
+
WHERE %s AND %s
295
+
`, idFilter.Condition(), recipientFilter.Condition())
296
+
297
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
298
+
299
+
result, err := e.Exec(query, args...)
300
+
if err != nil {
301
+
return fmt.Errorf("failed to mark notification as read: %w", err)
302
+
}
303
+
304
+
rowsAffected, err := result.RowsAffected()
305
+
if err != nil {
306
+
return fmt.Errorf("failed to get rows affected: %w", err)
307
+
}
308
+
309
+
if rowsAffected == 0 {
310
+
return fmt.Errorf("notification not found or access denied")
311
+
}
312
+
313
+
return nil
314
+
}
315
+
316
+
func MarkAllNotificationsRead(e Execer, userDID string) error {
317
+
recipientFilter := FilterEq("recipient_did", userDID)
318
+
readFilter := FilterEq("read", 0)
319
+
320
+
query := fmt.Sprintf(`
321
+
UPDATE notifications
322
+
SET read = 1
323
+
WHERE %s AND %s
324
+
`, recipientFilter.Condition(), readFilter.Condition())
325
+
326
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
327
+
328
+
_, err := e.Exec(query, args...)
329
+
if err != nil {
330
+
return fmt.Errorf("failed to mark all notifications as read: %w", err)
331
+
}
332
+
333
+
return nil
334
+
}
335
+
336
+
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
337
+
idFilter := FilterEq("id", notificationID)
338
+
recipientFilter := FilterEq("recipient_did", userDID)
339
+
340
+
query := fmt.Sprintf(`
341
+
DELETE FROM notifications
342
+
WHERE %s AND %s
343
+
`, idFilter.Condition(), recipientFilter.Condition())
344
+
345
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
346
+
347
+
result, err := e.Exec(query, args...)
348
+
if err != nil {
349
+
return fmt.Errorf("failed to delete notification: %w", err)
350
+
}
351
+
352
+
rowsAffected, err := result.RowsAffected()
353
+
if err != nil {
354
+
return fmt.Errorf("failed to get rows affected: %w", err)
355
+
}
356
+
357
+
if rowsAffected == 0 {
358
+
return fmt.Errorf("notification not found or access denied")
359
+
}
360
+
361
+
return nil
362
+
}
363
+
364
+
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
365
+
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
366
+
if err != nil {
367
+
return nil, err
368
+
}
369
+
370
+
p, ok := prefs[syntax.DID(userDid)]
371
+
if !ok {
372
+
return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil
373
+
}
374
+
375
+
return p, nil
376
+
}
377
+
378
+
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
+
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
380
+
381
+
var conditions []string
382
+
var args []any
383
+
for _, filter := range filters {
384
+
conditions = append(conditions, filter.Condition())
385
+
args = append(args, filter.Arg()...)
386
+
}
387
+
388
+
whereClause := ""
389
+
if conditions != nil {
390
+
whereClause = " where " + strings.Join(conditions, " and ")
391
+
}
392
+
393
+
query := fmt.Sprintf(`
394
+
select
395
+
id,
396
+
user_did,
397
+
repo_starred,
398
+
issue_created,
399
+
issue_commented,
400
+
pull_created,
401
+
pull_commented,
402
+
followed,
403
+
user_mentioned,
404
+
pull_merged,
405
+
issue_closed,
406
+
email_notifications
407
+
from
408
+
notification_preferences
409
+
%s
410
+
`, whereClause)
411
+
412
+
rows, err := e.Query(query, args...)
413
+
if err != nil {
414
+
return nil, err
415
+
}
416
+
defer rows.Close()
417
+
418
+
for rows.Next() {
419
+
var prefs models.NotificationPreferences
420
+
if err := rows.Scan(
421
+
&prefs.ID,
422
+
&prefs.UserDid,
423
+
&prefs.RepoStarred,
424
+
&prefs.IssueCreated,
425
+
&prefs.IssueCommented,
426
+
&prefs.PullCreated,
427
+
&prefs.PullCommented,
428
+
&prefs.Followed,
429
+
&prefs.UserMentioned,
430
+
&prefs.PullMerged,
431
+
&prefs.IssueClosed,
432
+
&prefs.EmailNotifications,
433
+
); err != nil {
434
+
return nil, err
435
+
}
436
+
437
+
prefsMap[prefs.UserDid] = &prefs
438
+
}
439
+
440
+
if err := rows.Err(); err != nil {
441
+
return nil, err
442
+
}
443
+
444
+
return prefsMap, nil
445
+
}
446
+
447
+
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
448
+
query := `
449
+
INSERT OR REPLACE INTO notification_preferences
450
+
(user_did, repo_starred, issue_created, issue_commented, pull_created,
451
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
+
email_notifications)
453
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
454
+
`
455
+
456
+
result, err := d.DB.ExecContext(ctx, query,
457
+
prefs.UserDid,
458
+
prefs.RepoStarred,
459
+
prefs.IssueCreated,
460
+
prefs.IssueCommented,
461
+
prefs.PullCreated,
462
+
prefs.PullCommented,
463
+
prefs.Followed,
464
+
prefs.UserMentioned,
465
+
prefs.PullMerged,
466
+
prefs.IssueClosed,
467
+
prefs.EmailNotifications,
468
+
)
469
+
if err != nil {
470
+
return fmt.Errorf("failed to update notification preferences: %w", err)
471
+
}
472
+
473
+
if prefs.ID == 0 {
474
+
id, err := result.LastInsertId()
475
+
if err != nil {
476
+
return fmt.Errorf("failed to get preferences ID: %w", err)
477
+
}
478
+
prefs.ID = id
479
+
}
480
+
481
+
return nil
482
+
}
483
+
484
+
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
485
+
cutoff := time.Now().Add(-olderThan)
486
+
createdFilter := FilterLte("created", cutoff)
487
+
488
+
query := fmt.Sprintf(`
489
+
DELETE FROM notifications
490
+
WHERE %s
491
+
`, createdFilter.Condition())
492
+
493
+
_, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
494
+
if err != nil {
495
+
return fmt.Errorf("failed to cleanup old notifications: %w", err)
496
+
}
497
+
498
+
return nil
499
+
}
+26
-6
appview/db/profile.go
+26
-6
appview/db/profile.go
···
129
129
did,
130
130
description,
131
131
include_bluesky,
132
-
location
132
+
location,
133
+
pronouns
133
134
)
134
-
values (?, ?, ?, ?)`,
135
+
values (?, ?, ?, ?, ?)`,
135
136
profile.Did,
136
137
profile.Description,
137
138
includeBskyValue,
138
139
profile.Location,
140
+
profile.Pronouns,
139
141
)
140
142
141
143
if err != nil {
···
216
218
did,
217
219
description,
218
220
include_bluesky,
219
-
location
221
+
location,
222
+
pronouns
220
223
from
221
224
profile
222
225
%s`,
···
231
234
for rows.Next() {
232
235
var profile models.Profile
233
236
var includeBluesky int
237
+
var pronouns sql.Null[string]
234
238
235
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
239
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
236
240
if err != nil {
237
241
return nil, err
238
242
}
239
243
240
244
if includeBluesky != 0 {
241
245
profile.IncludeBluesky = true
246
+
}
247
+
248
+
if pronouns.Valid {
249
+
profile.Pronouns = pronouns.V
242
250
}
243
251
244
252
profileMap[profile.Did] = &profile
···
302
310
303
311
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
312
var profile models.Profile
313
+
var pronouns sql.Null[string]
314
+
305
315
profile.Did = did
306
316
307
317
includeBluesky := 0
318
+
308
319
err := e.QueryRow(
309
-
`select description, include_bluesky, location from profile where did = ?`,
320
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
310
321
did,
311
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
322
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
312
323
if err == sql.ErrNoRows {
313
324
profile := models.Profile{}
314
325
profile.Did = did
···
321
332
322
333
if includeBluesky != 0 {
323
334
profile.IncludeBluesky = true
335
+
}
336
+
337
+
if pronouns.Valid {
338
+
profile.Pronouns = pronouns.V
324
339
}
325
340
326
341
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
412
427
// ensure description is not too long
413
428
if len(profile.Location) > 40 {
414
429
return fmt.Errorf("Entered location is too long.")
430
+
}
431
+
432
+
// ensure pronouns are not too long
433
+
if len(profile.Pronouns) > 40 {
434
+
return fmt.Errorf("Entered pronouns are too long.")
415
435
}
416
436
417
437
// ensure links are in order
+211
-220
appview/db/pulls.go
+211
-220
appview/db/pulls.go
···
1
1
package db
2
2
3
3
import (
4
+
"cmp"
4
5
"database/sql"
6
+
"errors"
5
7
"fmt"
6
-
"log"
8
+
"maps"
9
+
"slices"
7
10
"sort"
8
11
"strings"
9
12
"time"
···
55
58
parentChangeId = &pull.ParentChangeId
56
59
}
57
60
58
-
_, err = tx.Exec(
61
+
result, err := tx.Exec(
59
62
`
60
63
insert into pulls (
61
64
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
78
81
if err != nil {
79
82
return err
80
83
}
84
+
85
+
// Set the database primary key ID
86
+
id, err := result.LastInsertId()
87
+
if err != nil {
88
+
return err
89
+
}
90
+
pull.ID = int(id)
81
91
82
92
_, err = tx.Exec(`
83
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
93
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
84
94
values (?, ?, ?, ?, ?)
85
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
95
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
86
96
return err
87
97
}
88
98
···
91
101
if err != nil {
92
102
return "", err
93
103
}
94
-
return pull.PullAt(), err
104
+
return pull.AtUri(), err
95
105
}
96
106
97
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
101
111
}
102
112
103
113
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
104
-
pulls := make(map[int]*models.Pull)
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
105
115
106
116
var conditions []string
107
117
var args []any
···
121
131
122
132
query := fmt.Sprintf(`
123
133
select
134
+
id,
124
135
owner_did,
125
136
repo_at,
126
137
pull_id,
···
154
165
var createdAt string
155
166
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
156
167
err := rows.Scan(
168
+
&pull.ID,
157
169
&pull.OwnerDid,
158
170
&pull.RepoAt,
159
171
&pull.PullId,
···
202
214
pull.ParentChangeId = parentChangeId.String
203
215
}
204
216
205
-
pulls[pull.PullId] = &pull
217
+
pulls[pull.AtUri()] = &pull
206
218
}
207
219
208
-
// get latest round no. for each pull
209
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
210
-
submissionsQuery := fmt.Sprintf(`
211
-
select
212
-
id, pull_id, round_number, patch, created, source_rev
213
-
from
214
-
pull_submissions
215
-
where
216
-
repo_at in (%s) and pull_id in (%s)
217
-
`, inClause, inClause)
218
-
219
-
args = make([]any, len(pulls)*2)
220
-
idx := 0
220
+
var pullAts []syntax.ATURI
221
221
for _, p := range pulls {
222
-
args[idx] = p.RepoAt
223
-
idx += 1
222
+
pullAts = append(pullAts, p.AtUri())
224
223
}
225
-
for _, p := range pulls {
226
-
args[idx] = p.PullId
227
-
idx += 1
228
-
}
229
-
submissionsRows, err := e.Query(submissionsQuery, args...)
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
230
225
if err != nil {
231
-
return nil, err
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
232
227
}
233
-
defer submissionsRows.Close()
234
228
235
-
for submissionsRows.Next() {
236
-
var s models.PullSubmission
237
-
var sourceRev sql.NullString
238
-
var createdAt string
239
-
err := submissionsRows.Scan(
240
-
&s.ID,
241
-
&s.PullId,
242
-
&s.RoundNumber,
243
-
&s.Patch,
244
-
&createdAt,
245
-
&sourceRev,
246
-
)
247
-
if err != nil {
248
-
return nil, err
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
249
232
}
233
+
}
250
234
251
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
252
-
if err != nil {
253
-
return nil, err
254
-
}
255
-
s.Created = createdTime
256
-
257
-
if sourceRev.Valid {
258
-
s.SourceRev = sourceRev.String
259
-
}
260
-
261
-
if p, ok := pulls[s.PullId]; ok {
262
-
p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1)
263
-
p.Submissions[s.RoundNumber] = &s
264
-
}
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)
265
239
}
266
-
if err := rows.Err(); err != nil {
267
-
return nil, err
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
243
+
}
268
244
}
269
245
270
-
// get comment count on latest submission on each pull
271
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
272
-
commentsQuery := fmt.Sprintf(`
273
-
select
274
-
count(id), pull_id
275
-
from
276
-
pull_comments
277
-
where
278
-
submission_id in (%s)
279
-
group by
280
-
submission_id
281
-
`, inClause)
282
-
283
-
args = []any{}
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
284
248
for _, p := range pulls {
285
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
+
}
286
252
}
287
-
commentsRows, err := e.Query(commentsQuery, args...)
288
-
if err != nil {
289
-
return nil, err
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)
290
256
}
291
-
defer commentsRows.Close()
292
-
293
-
for commentsRows.Next() {
294
-
var commentCount, pullId int
295
-
err := commentsRows.Scan(
296
-
&commentCount,
297
-
&pullId,
298
-
)
299
-
if err != nil {
300
-
return nil, err
301
-
}
302
-
if p, ok := pulls[pullId]; ok {
303
-
p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount)
304
-
}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
305
260
}
306
-
if err := rows.Err(); err != nil {
307
-
return nil, err
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
+
}
308
267
}
309
268
310
269
orderedByPullId := []*models.Pull{}
···
322
281
return GetPullsWithLimit(e, 0, filters...)
323
282
}
324
283
325
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
326
-
query := `
284
+
func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) {
285
+
var ids []int64
286
+
287
+
var filters []filter
288
+
filters = append(filters, FilterEq("state", opts.State))
289
+
if opts.RepoAt != "" {
290
+
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
291
+
}
292
+
293
+
var conditions []string
294
+
var args []any
295
+
296
+
for _, filter := range filters {
297
+
conditions = append(conditions, filter.Condition())
298
+
args = append(args, filter.Arg()...)
299
+
}
300
+
301
+
whereClause := ""
302
+
if conditions != nil {
303
+
whereClause = " where " + strings.Join(conditions, " and ")
304
+
}
305
+
pageClause := ""
306
+
if opts.Page.Limit != 0 {
307
+
pageClause = fmt.Sprintf(
308
+
" limit %d offset %d ",
309
+
opts.Page.Limit,
310
+
opts.Page.Offset,
311
+
)
312
+
}
313
+
314
+
query := fmt.Sprintf(
315
+
`
327
316
select
328
-
owner_did,
329
-
pull_id,
330
-
created,
331
-
title,
332
-
state,
333
-
target_branch,
334
-
repo_at,
335
-
body,
336
-
rkey,
337
-
source_branch,
338
-
source_repo_at,
339
-
stack_id,
340
-
change_id,
341
-
parent_change_id
317
+
id
342
318
from
343
319
pulls
344
-
where
345
-
repo_at = ? and pull_id = ?
346
-
`
347
-
row := e.QueryRow(query, repoAt, pullId)
348
-
349
-
var pull models.Pull
350
-
var createdAt string
351
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
352
-
err := row.Scan(
353
-
&pull.OwnerDid,
354
-
&pull.PullId,
355
-
&createdAt,
356
-
&pull.Title,
357
-
&pull.State,
358
-
&pull.TargetBranch,
359
-
&pull.RepoAt,
360
-
&pull.Body,
361
-
&pull.Rkey,
362
-
&sourceBranch,
363
-
&sourceRepoAt,
364
-
&stackId,
365
-
&changeId,
366
-
&parentChangeId,
320
+
%s
321
+
%s`,
322
+
whereClause,
323
+
pageClause,
367
324
)
325
+
args = append(args, opts.Page.Limit, opts.Page.Offset)
326
+
rows, err := e.Query(query, args...)
368
327
if err != nil {
369
328
return nil, err
370
329
}
330
+
defer rows.Close()
371
331
372
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
332
+
for rows.Next() {
333
+
var id int64
334
+
err := rows.Scan(&id)
335
+
if err != nil {
336
+
return nil, err
337
+
}
338
+
339
+
ids = append(ids, id)
340
+
}
341
+
342
+
return ids, nil
343
+
}
344
+
345
+
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
346
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
373
347
if err != nil {
374
348
return nil, err
375
349
}
376
-
pull.Created = createdTime
377
-
378
-
// populate source
379
-
if sourceBranch.Valid {
380
-
pull.PullSource = &models.PullSource{
381
-
Branch: sourceBranch.String,
382
-
}
383
-
if sourceRepoAt.Valid {
384
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
385
-
if err != nil {
386
-
return nil, err
387
-
}
388
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
389
-
}
350
+
if len(pulls) == 0 {
351
+
return nil, sql.ErrNoRows
390
352
}
391
353
392
-
if stackId.Valid {
393
-
pull.StackId = stackId.String
394
-
}
395
-
if changeId.Valid {
396
-
pull.ChangeId = changeId.String
354
+
return pulls[0], nil
355
+
}
356
+
357
+
// mapping from pull -> pull submissions
358
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
359
+
var conditions []string
360
+
var args []any
361
+
for _, filter := range filters {
362
+
conditions = append(conditions, filter.Condition())
363
+
args = append(args, filter.Arg()...)
397
364
}
398
-
if parentChangeId.Valid {
399
-
pull.ParentChangeId = parentChangeId.String
365
+
366
+
whereClause := ""
367
+
if conditions != nil {
368
+
whereClause = " where " + strings.Join(conditions, " and ")
400
369
}
401
370
402
-
submissionsQuery := `
371
+
query := fmt.Sprintf(`
403
372
select
404
-
id, pull_id, repo_at, round_number, patch, created, source_rev
373
+
id,
374
+
pull_at,
375
+
round_number,
376
+
patch,
377
+
combined,
378
+
created,
379
+
source_rev
405
380
from
406
381
pull_submissions
407
-
where
408
-
repo_at = ? and pull_id = ?
409
-
`
410
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
382
+
%s
383
+
order by
384
+
round_number asc
385
+
`, whereClause)
386
+
387
+
rows, err := e.Query(query, args...)
411
388
if err != nil {
412
389
return nil, err
413
390
}
414
-
defer submissionsRows.Close()
391
+
defer rows.Close()
415
392
416
-
submissionsMap := make(map[int]*models.PullSubmission)
393
+
submissionMap := make(map[int]*models.PullSubmission)
417
394
418
-
for submissionsRows.Next() {
395
+
for rows.Next() {
419
396
var submission models.PullSubmission
420
397
var submissionCreatedStr string
421
-
var submissionSourceRev sql.NullString
422
-
err := submissionsRows.Scan(
398
+
var submissionSourceRev, submissionCombined sql.NullString
399
+
err := rows.Scan(
423
400
&submission.ID,
424
-
&submission.PullId,
425
-
&submission.RepoAt,
401
+
&submission.PullAt,
426
402
&submission.RoundNumber,
427
403
&submission.Patch,
404
+
&submissionCombined,
428
405
&submissionCreatedStr,
429
406
&submissionSourceRev,
430
407
)
···
432
409
return nil, err
433
410
}
434
411
435
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
436
-
if err != nil {
437
-
return nil, err
412
+
if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil {
413
+
submission.Created = t
438
414
}
439
-
submission.Created = submissionCreatedTime
440
415
441
416
if submissionSourceRev.Valid {
442
417
submission.SourceRev = submissionSourceRev.String
443
418
}
444
419
445
-
submissionsMap[submission.ID] = &submission
420
+
if submissionCombined.Valid {
421
+
submission.Combined = submissionCombined.String
422
+
}
423
+
424
+
submissionMap[submission.ID] = &submission
425
+
}
426
+
427
+
if err := rows.Err(); err != nil {
428
+
return nil, err
446
429
}
447
-
if err = submissionsRows.Close(); err != nil {
430
+
431
+
// Get comments for all submissions using GetPullComments
432
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
433
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
434
+
if err != nil {
448
435
return nil, err
449
436
}
450
-
if len(submissionsMap) == 0 {
451
-
return &pull, nil
437
+
for _, comment := range comments {
438
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
439
+
submission.Comments = append(submission.Comments, comment)
440
+
}
452
441
}
453
442
443
+
// group the submissions by pull_at
444
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
445
+
for _, s := range submissionMap {
446
+
m[s.PullAt] = append(m[s.PullAt], s)
447
+
}
448
+
449
+
// sort each one by round number
450
+
for _, s := range m {
451
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
452
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
453
+
})
454
+
}
455
+
456
+
return m, nil
457
+
}
458
+
459
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
460
+
var conditions []string
454
461
var args []any
455
-
for k := range submissionsMap {
456
-
args = append(args, k)
462
+
for _, filter := range filters {
463
+
conditions = append(conditions, filter.Condition())
464
+
args = append(args, filter.Arg()...)
465
+
}
466
+
467
+
whereClause := ""
468
+
if conditions != nil {
469
+
whereClause = " where " + strings.Join(conditions, " and ")
457
470
}
458
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
459
-
commentsQuery := fmt.Sprintf(`
471
+
472
+
query := fmt.Sprintf(`
460
473
select
461
474
id,
462
475
pull_id,
···
468
481
created
469
482
from
470
483
pull_comments
471
-
where
472
-
submission_id IN (%s)
484
+
%s
473
485
order by
474
486
created asc
475
-
`, inClause)
476
-
commentsRows, err := e.Query(commentsQuery, args...)
487
+
`, whereClause)
488
+
489
+
rows, err := e.Query(query, args...)
477
490
if err != nil {
478
491
return nil, err
479
492
}
480
-
defer commentsRows.Close()
493
+
defer rows.Close()
481
494
482
-
for commentsRows.Next() {
495
+
var comments []models.PullComment
496
+
for rows.Next() {
483
497
var comment models.PullComment
484
-
var commentCreatedStr string
485
-
err := commentsRows.Scan(
498
+
var createdAt string
499
+
err := rows.Scan(
486
500
&comment.ID,
487
501
&comment.PullId,
488
502
&comment.SubmissionId,
···
490
504
&comment.OwnerDid,
491
505
&comment.CommentAt,
492
506
&comment.Body,
493
-
&commentCreatedStr,
507
+
&createdAt,
494
508
)
495
509
if err != nil {
496
510
return nil, err
497
511
}
498
512
499
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
500
-
if err != nil {
501
-
return nil, err
502
-
}
503
-
comment.Created = commentCreatedTime
504
-
505
-
// Add the comment to its submission
506
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
507
-
submission.Comments = append(submission.Comments, comment)
513
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
514
+
comment.Created = t
508
515
}
509
516
517
+
comments = append(comments, comment)
510
518
}
511
-
if err = commentsRows.Err(); err != nil {
519
+
520
+
if err := rows.Err(); err != nil {
512
521
return nil, err
513
522
}
514
523
515
-
var pullSourceRepo *models.Repo
516
-
if pull.PullSource != nil {
517
-
if pull.PullSource.RepoAt != nil {
518
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
519
-
if err != nil {
520
-
log.Printf("failed to get repo by at uri: %v", err)
521
-
} else {
522
-
pull.PullSource.Repo = pullSourceRepo
523
-
}
524
-
}
525
-
}
526
-
527
-
pull.Submissions = make([]*models.PullSubmission, len(submissionsMap))
528
-
for _, submission := range submissionsMap {
529
-
pull.Submissions[submission.RoundNumber] = submission
530
-
}
531
-
532
-
return &pull, nil
524
+
return comments, nil
533
525
}
534
526
535
527
// timeframe here is directly passed into the sql query filter, and any
···
663
655
return err
664
656
}
665
657
666
-
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
667
-
newRoundNumber := len(pull.Submissions)
658
+
func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error {
668
659
_, err := e.Exec(`
669
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
660
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
670
661
values (?, ?, ?, ?, ?)
671
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
662
+
`, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
672
663
673
664
return err
674
665
}
+34
-7
appview/db/reaction.go
+34
-7
appview/db/reaction.go
···
62
62
return count, nil
63
63
}
64
64
65
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
-
countMap := map[models.ReactionKind]int{}
65
+
func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) {
66
+
query := `
67
+
select kind, reacted_by_did,
68
+
row_number() over (partition by kind order by created asc) as rn,
69
+
count(*) over (partition by kind) as total
70
+
from reactions
71
+
where thread_at = ?
72
+
order by kind, created asc`
73
+
74
+
rows, err := e.Query(query, threadAt)
75
+
if err != nil {
76
+
return nil, err
77
+
}
78
+
defer rows.Close()
79
+
80
+
reactionMap := map[models.ReactionKind]models.ReactionDisplayData{}
67
81
for _, kind := range models.OrderedReactionKinds {
68
-
count, err := GetReactionCount(e, threadAt, kind)
69
-
if err != nil {
70
-
return map[models.ReactionKind]int{}, nil
82
+
reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}}
83
+
}
84
+
85
+
for rows.Next() {
86
+
var kind models.ReactionKind
87
+
var did string
88
+
var rn, total int
89
+
if err := rows.Scan(&kind, &did, &rn, &total); err != nil {
90
+
return nil, err
71
91
}
72
-
countMap[kind] = count
92
+
93
+
data := reactionMap[kind]
94
+
data.Count = total
95
+
if userLimit > 0 && rn <= userLimit {
96
+
data.Users = append(data.Users, did)
97
+
}
98
+
reactionMap[kind] = data
73
99
}
74
-
return countMap, nil
100
+
101
+
return reactionMap, rows.Err()
75
102
}
76
103
77
104
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+80
-12
appview/db/repos.go
+80
-12
appview/db/repos.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
13
15
"tangled.org/core/appview/models"
14
16
)
15
17
18
+
type Repo struct {
19
+
Id int64
20
+
Did string
21
+
Name string
22
+
Knot string
23
+
Rkey string
24
+
Created time.Time
25
+
Description string
26
+
Spindle string
27
+
28
+
// optionally, populate this when querying for reverse mappings
29
+
RepoStats *models.RepoStats
30
+
31
+
// optional
32
+
Source string
33
+
}
34
+
35
+
func (r Repo) RepoAt() syntax.ATURI {
36
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
37
+
}
38
+
39
+
func (r Repo) DidSlashRepo() string {
40
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
41
+
return p
42
+
}
43
+
16
44
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
17
45
repoMap := make(map[syntax.ATURI]*models.Repo)
18
46
···
35
63
36
64
repoQuery := fmt.Sprintf(
37
65
`select
66
+
id,
38
67
did,
39
68
name,
40
69
knot,
41
70
rkey,
42
71
created,
43
72
description,
73
+
website,
74
+
topics,
44
75
source,
45
76
spindle
46
77
from
···
60
91
for rows.Next() {
61
92
var repo models.Repo
62
93
var createdAt string
63
-
var description, source, spindle sql.NullString
94
+
var description, website, topicStr, source, spindle sql.NullString
64
95
65
96
err := rows.Scan(
97
+
&repo.Id,
66
98
&repo.Did,
67
99
&repo.Name,
68
100
&repo.Knot,
69
101
&repo.Rkey,
70
102
&createdAt,
71
103
&description,
104
+
&website,
105
+
&topicStr,
72
106
&source,
73
107
&spindle,
74
108
)
···
81
115
}
82
116
if description.Valid {
83
117
repo.Description = description.String
118
+
}
119
+
if website.Valid {
120
+
repo.Website = website.String
121
+
}
122
+
if topicStr.Valid {
123
+
repo.Topics = strings.Fields(topicStr.String)
84
124
}
85
125
if source.Valid {
86
126
repo.Source = source.String
···
326
366
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
327
367
var repo models.Repo
328
368
var nullableDescription sql.NullString
369
+
var nullableWebsite sql.NullString
370
+
var nullableTopicStr sql.NullString
329
371
330
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
372
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
331
373
332
374
var createdAt string
333
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
375
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
334
376
return nil, err
335
377
}
336
378
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
338
380
339
381
if nullableDescription.Valid {
340
382
repo.Description = nullableDescription.String
341
-
} else {
342
-
repo.Description = ""
383
+
}
384
+
if nullableWebsite.Valid {
385
+
repo.Website = nullableWebsite.String
386
+
}
387
+
if nullableTopicStr.Valid {
388
+
repo.Topics = strings.Fields(nullableTopicStr.String)
343
389
}
344
390
345
391
return &repo, nil
346
392
}
347
393
394
+
func PutRepo(tx *sql.Tx, repo models.Repo) error {
395
+
_, err := tx.Exec(
396
+
`update repos
397
+
set knot = ?, description = ?, website = ?, topics = ?
398
+
where did = ? and rkey = ?
399
+
`,
400
+
repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey,
401
+
)
402
+
return err
403
+
}
404
+
348
405
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
349
406
_, err := tx.Exec(
350
407
`insert into repos
351
-
(did, name, knot, rkey, at_uri, description, source)
352
-
values (?, ?, ?, ?, ?, ?, ?)`,
353
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
408
+
(did, name, knot, rkey, at_uri, description, website, topics, source)
409
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
410
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source,
354
411
)
355
412
if err != nil {
356
413
return fmt.Errorf("failed to insert repo: %w", err)
···
386
443
var repos []models.Repo
387
444
388
445
rows, err := e.Query(
389
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
446
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
390
447
from repos r
391
448
left join collaborators c on r.at_uri = c.repo_at
392
449
where (r.did = ? or c.subject_did = ?)
···
404
461
var repo models.Repo
405
462
var createdAt string
406
463
var nullableDescription sql.NullString
464
+
var nullableWebsite sql.NullString
407
465
var nullableSource sql.NullString
408
466
409
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
467
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
410
468
if err != nil {
411
469
return nil, err
412
470
}
···
440
498
var repo models.Repo
441
499
var createdAt string
442
500
var nullableDescription sql.NullString
501
+
var nullableWebsite sql.NullString
502
+
var nullableTopicStr sql.NullString
443
503
var nullableSource sql.NullString
444
504
445
505
row := e.QueryRow(
446
-
`select did, name, knot, rkey, description, created, source
506
+
`select id, did, name, knot, rkey, description, website, topics, created, source
447
507
from repos
448
508
where did = ? and name = ? and source is not null and source != ''`,
449
509
did, name,
450
510
)
451
511
452
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
512
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
453
513
if err != nil {
454
514
return nil, err
455
515
}
456
516
457
517
if nullableDescription.Valid {
458
518
repo.Description = nullableDescription.String
519
+
}
520
+
521
+
if nullableWebsite.Valid {
522
+
repo.Website = nullableWebsite.String
523
+
}
524
+
525
+
if nullableTopicStr.Valid {
526
+
repo.Topics = strings.Fields(nullableTopicStr.String)
459
527
}
460
528
461
529
if nullableSource.Valid {
+38
-10
appview/db/timeline.go
+38
-10
appview/db/timeline.go
···
9
9
10
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
13
13
var events []models.TimelineEvent
14
14
15
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
15
+
var userIsFollowing []string
16
+
if limitToUsersIsFollowing {
17
+
following, err := GetFollowing(e, loggedInUserDid)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
userIsFollowing = make([]string, 0, len(following))
23
+
for _, follow := range following {
24
+
userIsFollowing = append(userIsFollowing, follow.SubjectDid)
25
+
}
26
+
}
27
+
28
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing)
16
29
if err != nil {
17
30
return nil, err
18
31
}
19
32
20
-
stars, err := getTimelineStars(e, limit, loggedInUserDid)
33
+
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
21
34
if err != nil {
22
35
return nil, err
23
36
}
24
37
25
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
38
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
26
39
if err != nil {
27
40
return nil, err
28
41
}
···
70
83
return isStarred, starCount
71
84
}
72
85
73
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
-
repos, err := GetRepos(e, limit)
86
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
+
filters := make([]filter, 0)
88
+
if userIsFollowing != nil {
89
+
filters = append(filters, FilterIn("did", userIsFollowing))
90
+
}
91
+
92
+
repos, err := GetRepos(e, limit, filters...)
75
93
if err != nil {
76
94
return nil, err
77
95
}
···
125
143
return events, nil
126
144
}
127
145
128
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
-
stars, err := GetStars(e, limit)
146
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
+
filters := make([]filter, 0)
148
+
if userIsFollowing != nil {
149
+
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
+
}
151
+
152
+
stars, err := GetStars(e, limit, filters...)
130
153
if err != nil {
131
154
return nil, err
132
155
}
···
166
189
return events, nil
167
190
}
168
191
169
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
-
follows, err := GetFollows(e, limit)
192
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
193
+
filters := make([]filter, 0)
194
+
if userIsFollowing != nil {
195
+
filters = append(filters, FilterIn("user_did", userIsFollowing))
196
+
}
197
+
198
+
follows, err := GetFollows(e, limit, filters...)
171
199
if err != nil {
172
200
return nil, err
173
201
}
+4
-4
appview/dns/cloudflare.go
+4
-4
appview/dns/cloudflare.go
···
30
30
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
31
}
32
32
33
-
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
-
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) {
34
+
result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
35
Type: record.Type,
36
36
Name: record.Name,
37
37
Content: record.Content,
···
39
39
Proxied: &record.Proxied,
40
40
})
41
41
if err != nil {
42
-
return fmt.Errorf("failed to create DNS record: %w", err)
42
+
return "", fmt.Errorf("failed to create DNS record: %w", err)
43
43
}
44
-
return nil
44
+
return result.ID, nil
45
45
}
46
46
47
47
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+20
appview/indexer/base36/base36.go
+20
appview/indexer/base36/base36.go
···
1
+
// mostly copied from gitea/modules/indexer/internal/base32
2
+
3
+
package base36
4
+
5
+
import (
6
+
"fmt"
7
+
"strconv"
8
+
)
9
+
10
+
func Encode(i int64) string {
11
+
return strconv.FormatInt(i, 36)
12
+
}
13
+
14
+
func Decode(s string) (int64, error) {
15
+
i, err := strconv.ParseInt(s, 36, 64)
16
+
if err != nil {
17
+
return 0, fmt.Errorf("invalid base36 integer %q: %w", s, err)
18
+
}
19
+
return i, nil
20
+
}
+58
appview/indexer/bleve/batch.go
+58
appview/indexer/bleve/batch.go
···
1
+
// Copyright 2021 The Gitea Authors. All rights reserved.
2
+
// SPDX-License-Identifier: MIT
3
+
4
+
package bleveutil
5
+
6
+
import (
7
+
"github.com/blevesearch/bleve/v2"
8
+
)
9
+
10
+
// FlushingBatch is a batch of operations that automatically flushes to the
11
+
// underlying index once it reaches a certain size.
12
+
type FlushingBatch struct {
13
+
maxBatchSize int
14
+
batch *bleve.Batch
15
+
index bleve.Index
16
+
}
17
+
18
+
// NewFlushingBatch creates a new flushing batch for the specified index. Once
19
+
// the number of operations in the batch reaches the specified limit, the batch
20
+
// automatically flushes its operations to the index.
21
+
func NewFlushingBatch(index bleve.Index, maxBatchSize int) *FlushingBatch {
22
+
return &FlushingBatch{
23
+
maxBatchSize: maxBatchSize,
24
+
batch: index.NewBatch(),
25
+
index: index,
26
+
}
27
+
}
28
+
29
+
// Index add a new index to batch
30
+
func (b *FlushingBatch) Index(id string, data any) error {
31
+
if err := b.batch.Index(id, data); err != nil {
32
+
return err
33
+
}
34
+
return b.flushIfFull()
35
+
}
36
+
37
+
// Delete add a delete index to batch
38
+
func (b *FlushingBatch) Delete(id string) error {
39
+
b.batch.Delete(id)
40
+
return b.flushIfFull()
41
+
}
42
+
43
+
func (b *FlushingBatch) flushIfFull() error {
44
+
if b.batch.Size() < b.maxBatchSize {
45
+
return nil
46
+
}
47
+
return b.Flush()
48
+
}
49
+
50
+
// Flush submit the batch and create a new one
51
+
func (b *FlushingBatch) Flush() error {
52
+
err := b.index.Batch(b.batch)
53
+
if err != nil {
54
+
return err
55
+
}
56
+
b.batch = b.index.NewBatch()
57
+
return nil
58
+
}
+26
appview/indexer/bleve/query.go
+26
appview/indexer/bleve/query.go
···
1
+
package bleveutil
2
+
3
+
import (
4
+
"github.com/blevesearch/bleve/v2"
5
+
"github.com/blevesearch/bleve/v2/search/query"
6
+
)
7
+
8
+
func MatchAndQuery(field, keyword, analyzer string, fuzziness int) query.Query {
9
+
q := bleve.NewMatchQuery(keyword)
10
+
q.FieldVal = field
11
+
q.Analyzer = analyzer
12
+
q.Fuzziness = fuzziness
13
+
return q
14
+
}
15
+
16
+
func BoolFieldQuery(field string, val bool) query.Query {
17
+
q := bleve.NewBoolFieldQuery(val)
18
+
q.FieldVal = field
19
+
return q
20
+
}
21
+
22
+
func KeywordFieldQuery(field, keyword string) query.Query {
23
+
q := bleve.NewTermQuery(keyword)
24
+
q.FieldVal = field
25
+
return q
26
+
}
+36
appview/indexer/indexer.go
+36
appview/indexer/indexer.go
···
1
+
package indexer
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
7
+
"tangled.org/core/appview/db"
8
+
issues_indexer "tangled.org/core/appview/indexer/issues"
9
+
pulls_indexer "tangled.org/core/appview/indexer/pulls"
10
+
"tangled.org/core/appview/notify"
11
+
tlog "tangled.org/core/log"
12
+
)
13
+
14
+
type Indexer struct {
15
+
Issues *issues_indexer.Indexer
16
+
Pulls *pulls_indexer.Indexer
17
+
logger *slog.Logger
18
+
notify.BaseNotifier
19
+
}
20
+
21
+
func New(logger *slog.Logger) *Indexer {
22
+
return &Indexer{
23
+
issues_indexer.NewIndexer("indexes/issues.bleve"),
24
+
pulls_indexer.NewIndexer("indexes/pulls.bleve"),
25
+
logger,
26
+
notify.BaseNotifier{},
27
+
}
28
+
}
29
+
30
+
// Init initializes all indexers
31
+
func (ix *Indexer) Init(ctx context.Context, db *db.DB) error {
32
+
ctx = tlog.IntoContext(ctx, ix.logger)
33
+
ix.Issues.Init(ctx, db)
34
+
ix.Pulls.Init(ctx, db)
35
+
return nil
36
+
}
+255
appview/indexer/issues/indexer.go
+255
appview/indexer/issues/indexer.go
···
1
+
// heavily inspired by gitea's model (basically copy-pasted)
2
+
package issues_indexer
3
+
4
+
import (
5
+
"context"
6
+
"errors"
7
+
"log"
8
+
"os"
9
+
10
+
"github.com/blevesearch/bleve/v2"
11
+
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
12
+
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
13
+
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
14
+
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
15
+
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
16
+
"github.com/blevesearch/bleve/v2/index/upsidedown"
17
+
"github.com/blevesearch/bleve/v2/mapping"
18
+
"github.com/blevesearch/bleve/v2/search/query"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/indexer/base36"
21
+
"tangled.org/core/appview/indexer/bleve"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/pagination"
24
+
tlog "tangled.org/core/log"
25
+
)
26
+
27
+
const (
28
+
issueIndexerAnalyzer = "issueIndexer"
29
+
issueIndexerDocType = "issueIndexerDocType"
30
+
31
+
unicodeNormalizeName = "uicodeNormalize"
32
+
)
33
+
34
+
type Indexer struct {
35
+
indexer bleve.Index
36
+
path string
37
+
}
38
+
39
+
func NewIndexer(indexDir string) *Indexer {
40
+
return &Indexer{
41
+
path: indexDir,
42
+
}
43
+
}
44
+
45
+
// Init initializes the indexer
46
+
func (ix *Indexer) Init(ctx context.Context, e db.Execer) {
47
+
l := tlog.FromContext(ctx)
48
+
existed, err := ix.intialize(ctx)
49
+
if err != nil {
50
+
log.Fatalln("failed to initialize issue indexer", err)
51
+
}
52
+
if !existed {
53
+
l.Debug("Populating the issue indexer")
54
+
err := PopulateIndexer(ctx, ix, e)
55
+
if err != nil {
56
+
log.Fatalln("failed to populate issue indexer", err)
57
+
}
58
+
}
59
+
l.Info("Initialized the issue indexer")
60
+
}
61
+
62
+
func generateIssueIndexMapping() (mapping.IndexMapping, error) {
63
+
mapping := bleve.NewIndexMapping()
64
+
docMapping := bleve.NewDocumentMapping()
65
+
66
+
textFieldMapping := bleve.NewTextFieldMapping()
67
+
textFieldMapping.Store = false
68
+
textFieldMapping.IncludeInAll = false
69
+
70
+
boolFieldMapping := bleve.NewBooleanFieldMapping()
71
+
boolFieldMapping.Store = false
72
+
boolFieldMapping.IncludeInAll = false
73
+
74
+
keywordFieldMapping := bleve.NewKeywordFieldMapping()
75
+
keywordFieldMapping.Store = false
76
+
keywordFieldMapping.IncludeInAll = false
77
+
78
+
// numericFieldMapping := bleve.NewNumericFieldMapping()
79
+
80
+
docMapping.AddFieldMappingsAt("title", textFieldMapping)
81
+
docMapping.AddFieldMappingsAt("body", textFieldMapping)
82
+
83
+
docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping)
84
+
docMapping.AddFieldMappingsAt("is_open", boolFieldMapping)
85
+
86
+
err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
87
+
"type": unicodenorm.Name,
88
+
"form": unicodenorm.NFC,
89
+
})
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
94
+
err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{
95
+
"type": custom.Name,
96
+
"char_filters": []string{},
97
+
"tokenizer": unicode.Name,
98
+
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
99
+
})
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
mapping.DefaultAnalyzer = issueIndexerAnalyzer
105
+
mapping.AddDocumentMapping(issueIndexerDocType, docMapping)
106
+
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
107
+
mapping.DefaultMapping = bleve.NewDocumentDisabledMapping()
108
+
109
+
return mapping, nil
110
+
}
111
+
112
+
func (ix *Indexer) intialize(ctx context.Context) (bool, error) {
113
+
if ix.indexer != nil {
114
+
return false, errors.New("indexer is already initialized")
115
+
}
116
+
117
+
indexer, err := openIndexer(ctx, ix.path)
118
+
if err != nil {
119
+
return false, err
120
+
}
121
+
if indexer != nil {
122
+
ix.indexer = indexer
123
+
return true, nil
124
+
}
125
+
126
+
mapping, err := generateIssueIndexMapping()
127
+
if err != nil {
128
+
return false, err
129
+
}
130
+
indexer, err = bleve.New(ix.path, mapping)
131
+
if err != nil {
132
+
return false, err
133
+
}
134
+
135
+
ix.indexer = indexer
136
+
137
+
return false, nil
138
+
}
139
+
140
+
func openIndexer(ctx context.Context, path string) (bleve.Index, error) {
141
+
l := tlog.FromContext(ctx)
142
+
indexer, err := bleve.Open(path)
143
+
if err != nil {
144
+
if errors.Is(err, upsidedown.IncompatibleVersion) {
145
+
l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding")
146
+
return nil, os.RemoveAll(path)
147
+
}
148
+
return nil, nil
149
+
}
150
+
return indexer, nil
151
+
}
152
+
153
+
func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error {
154
+
l := tlog.FromContext(ctx)
155
+
count := 0
156
+
err := pagination.IterateAll(
157
+
func(page pagination.Page) ([]models.Issue, error) {
158
+
return db.GetIssuesPaginated(e, page)
159
+
},
160
+
func(issues []models.Issue) error {
161
+
count += len(issues)
162
+
return ix.Index(ctx, issues...)
163
+
},
164
+
)
165
+
l.Info("issues indexed", "count", count)
166
+
return err
167
+
}
168
+
169
+
// issueData data stored and will be indexed
170
+
type issueData struct {
171
+
ID int64 `json:"id"`
172
+
RepoAt string `json:"repo_at"`
173
+
IssueID int `json:"issue_id"`
174
+
Title string `json:"title"`
175
+
Body string `json:"body"`
176
+
177
+
IsOpen bool `json:"is_open"`
178
+
Comments []IssueCommentData `json:"comments"`
179
+
}
180
+
181
+
func makeIssueData(issue *models.Issue) *issueData {
182
+
return &issueData{
183
+
ID: issue.Id,
184
+
RepoAt: issue.RepoAt.String(),
185
+
IssueID: issue.IssueId,
186
+
Title: issue.Title,
187
+
Body: issue.Body,
188
+
IsOpen: issue.Open,
189
+
}
190
+
}
191
+
192
+
// Type returns the document type, for bleve's mapping.Classifier interface.
193
+
func (i *issueData) Type() string {
194
+
return issueIndexerDocType
195
+
}
196
+
197
+
type IssueCommentData struct {
198
+
Body string `json:"body"`
199
+
}
200
+
201
+
type SearchResult struct {
202
+
Hits []int64
203
+
Total uint64
204
+
}
205
+
206
+
const maxBatchSize = 20
207
+
208
+
func (ix *Indexer) Index(ctx context.Context, issues ...models.Issue) error {
209
+
batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize)
210
+
for _, issue := range issues {
211
+
issueData := makeIssueData(&issue)
212
+
if err := batch.Index(base36.Encode(issue.Id), issueData); err != nil {
213
+
return err
214
+
}
215
+
}
216
+
return batch.Flush()
217
+
}
218
+
219
+
func (ix *Indexer) Delete(ctx context.Context, issueId int64) error {
220
+
return ix.indexer.Delete(base36.Encode(issueId))
221
+
}
222
+
223
+
// Search searches for issues
224
+
func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) {
225
+
var queries []query.Query
226
+
227
+
if opts.Keyword != "" {
228
+
queries = append(queries, bleve.NewDisjunctionQuery(
229
+
bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0),
230
+
bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0),
231
+
))
232
+
}
233
+
queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt))
234
+
queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen))
235
+
// TODO: append more queries
236
+
237
+
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
238
+
searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false)
239
+
res, err := ix.indexer.SearchInContext(ctx, searchReq)
240
+
if err != nil {
241
+
return nil, nil
242
+
}
243
+
ret := &SearchResult{
244
+
Total: res.Total,
245
+
Hits: make([]int64, len(res.Hits)),
246
+
}
247
+
for i, hit := range res.Hits {
248
+
id, err := base36.Decode(hit.ID)
249
+
if err != nil {
250
+
return nil, err
251
+
}
252
+
ret.Hits[i] = id
253
+
}
254
+
return ret, nil
255
+
}
+57
appview/indexer/notifier.go
+57
appview/indexer/notifier.go
···
1
+
package indexer
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
8
+
"tangled.org/core/appview/notify"
9
+
"tangled.org/core/log"
10
+
)
11
+
12
+
var _ notify.Notifier = &Indexer{}
13
+
14
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
15
+
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
16
+
l.Debug("indexing new issue")
17
+
err := ix.Issues.Index(ctx, *issue)
18
+
if err != nil {
19
+
l.Error("failed to index an issue", "err", err)
20
+
}
21
+
}
22
+
23
+
func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
24
+
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
25
+
l.Debug("updating an issue")
26
+
err := ix.Issues.Index(ctx, *issue)
27
+
if err != nil {
28
+
l.Error("failed to index an issue", "err", err)
29
+
}
30
+
}
31
+
32
+
func (ix *Indexer) DeleteIssue(ctx context.Context, issue *models.Issue) {
33
+
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
34
+
l.Debug("deleting an issue")
35
+
err := ix.Issues.Delete(ctx, issue.Id)
36
+
if err != nil {
37
+
l.Error("failed to delete an issue", "err", err)
38
+
}
39
+
}
40
+
41
+
func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) {
42
+
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
43
+
l.Debug("indexing new pr")
44
+
err := ix.Pulls.Index(ctx, pull)
45
+
if err != nil {
46
+
l.Error("failed to index a pr", "err", err)
47
+
}
48
+
}
49
+
50
+
func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
51
+
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
52
+
l.Debug("updating a pr")
53
+
err := ix.Pulls.Index(ctx, pull)
54
+
if err != nil {
55
+
l.Error("failed to index a pr", "err", err)
56
+
}
57
+
}
+255
appview/indexer/pulls/indexer.go
+255
appview/indexer/pulls/indexer.go
···
1
+
// heavily inspired by gitea's model (basically copy-pasted)
2
+
package pulls_indexer
3
+
4
+
import (
5
+
"context"
6
+
"errors"
7
+
"log"
8
+
"os"
9
+
10
+
"github.com/blevesearch/bleve/v2"
11
+
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
12
+
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
13
+
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
14
+
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
15
+
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
16
+
"github.com/blevesearch/bleve/v2/index/upsidedown"
17
+
"github.com/blevesearch/bleve/v2/mapping"
18
+
"github.com/blevesearch/bleve/v2/search/query"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/indexer/base36"
21
+
"tangled.org/core/appview/indexer/bleve"
22
+
"tangled.org/core/appview/models"
23
+
tlog "tangled.org/core/log"
24
+
)
25
+
26
+
const (
27
+
pullIndexerAnalyzer = "pullIndexer"
28
+
pullIndexerDocType = "pullIndexerDocType"
29
+
30
+
unicodeNormalizeName = "uicodeNormalize"
31
+
)
32
+
33
+
type Indexer struct {
34
+
indexer bleve.Index
35
+
path string
36
+
}
37
+
38
+
func NewIndexer(indexDir string) *Indexer {
39
+
return &Indexer{
40
+
path: indexDir,
41
+
}
42
+
}
43
+
44
+
// Init initializes the indexer
45
+
func (ix *Indexer) Init(ctx context.Context, e db.Execer) {
46
+
l := tlog.FromContext(ctx)
47
+
existed, err := ix.intialize(ctx)
48
+
if err != nil {
49
+
log.Fatalln("failed to initialize pull indexer", err)
50
+
}
51
+
if !existed {
52
+
l.Debug("Populating the pull indexer")
53
+
err := PopulateIndexer(ctx, ix, e)
54
+
if err != nil {
55
+
log.Fatalln("failed to populate pull indexer", err)
56
+
}
57
+
}
58
+
l.Info("Initialized the pull indexer")
59
+
}
60
+
61
+
func generatePullIndexMapping() (mapping.IndexMapping, error) {
62
+
mapping := bleve.NewIndexMapping()
63
+
docMapping := bleve.NewDocumentMapping()
64
+
65
+
textFieldMapping := bleve.NewTextFieldMapping()
66
+
textFieldMapping.Store = false
67
+
textFieldMapping.IncludeInAll = false
68
+
69
+
keywordFieldMapping := bleve.NewKeywordFieldMapping()
70
+
keywordFieldMapping.Store = false
71
+
keywordFieldMapping.IncludeInAll = false
72
+
73
+
// numericFieldMapping := bleve.NewNumericFieldMapping()
74
+
75
+
docMapping.AddFieldMappingsAt("title", textFieldMapping)
76
+
docMapping.AddFieldMappingsAt("body", textFieldMapping)
77
+
78
+
docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping)
79
+
docMapping.AddFieldMappingsAt("state", keywordFieldMapping)
80
+
81
+
err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
82
+
"type": unicodenorm.Name,
83
+
"form": unicodenorm.NFC,
84
+
})
85
+
if err != nil {
86
+
return nil, err
87
+
}
88
+
89
+
err = mapping.AddCustomAnalyzer(pullIndexerAnalyzer, map[string]any{
90
+
"type": custom.Name,
91
+
"char_filters": []string{},
92
+
"tokenizer": unicode.Name,
93
+
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
94
+
})
95
+
if err != nil {
96
+
return nil, err
97
+
}
98
+
99
+
mapping.DefaultAnalyzer = pullIndexerAnalyzer
100
+
mapping.AddDocumentMapping(pullIndexerDocType, docMapping)
101
+
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
102
+
mapping.DefaultMapping = bleve.NewDocumentDisabledMapping()
103
+
104
+
return mapping, nil
105
+
}
106
+
107
+
func (ix *Indexer) intialize(ctx context.Context) (bool, error) {
108
+
if ix.indexer != nil {
109
+
return false, errors.New("indexer is already initialized")
110
+
}
111
+
112
+
indexer, err := openIndexer(ctx, ix.path)
113
+
if err != nil {
114
+
return false, err
115
+
}
116
+
if indexer != nil {
117
+
ix.indexer = indexer
118
+
return true, nil
119
+
}
120
+
121
+
mapping, err := generatePullIndexMapping()
122
+
if err != nil {
123
+
return false, err
124
+
}
125
+
indexer, err = bleve.New(ix.path, mapping)
126
+
if err != nil {
127
+
return false, err
128
+
}
129
+
130
+
ix.indexer = indexer
131
+
132
+
return false, nil
133
+
}
134
+
135
+
func openIndexer(ctx context.Context, path string) (bleve.Index, error) {
136
+
l := tlog.FromContext(ctx)
137
+
indexer, err := bleve.Open(path)
138
+
if err != nil {
139
+
if errors.Is(err, upsidedown.IncompatibleVersion) {
140
+
l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding")
141
+
return nil, os.RemoveAll(path)
142
+
}
143
+
return nil, nil
144
+
}
145
+
return indexer, nil
146
+
}
147
+
148
+
func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error {
149
+
l := tlog.FromContext(ctx)
150
+
151
+
pulls, err := db.GetPulls(e)
152
+
if err != nil {
153
+
return err
154
+
}
155
+
count := len(pulls)
156
+
err = ix.Index(ctx, pulls...)
157
+
if err != nil {
158
+
return err
159
+
}
160
+
l.Info("pulls indexed", "count", count)
161
+
return err
162
+
}
163
+
164
+
// pullData data stored and will be indexed
165
+
type pullData struct {
166
+
ID int64 `json:"id"`
167
+
RepoAt string `json:"repo_at"`
168
+
PullID int `json:"pull_id"`
169
+
Title string `json:"title"`
170
+
Body string `json:"body"`
171
+
State string `json:"state"`
172
+
173
+
Comments []pullCommentData `json:"comments"`
174
+
}
175
+
176
+
func makePullData(pull *models.Pull) *pullData {
177
+
return &pullData{
178
+
ID: int64(pull.ID),
179
+
RepoAt: pull.RepoAt.String(),
180
+
PullID: pull.PullId,
181
+
Title: pull.Title,
182
+
Body: pull.Body,
183
+
State: pull.State.String(),
184
+
}
185
+
}
186
+
187
+
// Type returns the document type, for bleve's mapping.Classifier interface.
188
+
func (i *pullData) Type() string {
189
+
return pullIndexerDocType
190
+
}
191
+
192
+
type pullCommentData struct {
193
+
Body string `json:"body"`
194
+
}
195
+
196
+
type searchResult struct {
197
+
Hits []int64
198
+
Total uint64
199
+
}
200
+
201
+
const maxBatchSize = 20
202
+
203
+
func (ix *Indexer) Index(ctx context.Context, pulls ...*models.Pull) error {
204
+
batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize)
205
+
for _, pull := range pulls {
206
+
pullData := makePullData(pull)
207
+
if err := batch.Index(base36.Encode(pullData.ID), pullData); err != nil {
208
+
return err
209
+
}
210
+
}
211
+
return batch.Flush()
212
+
}
213
+
214
+
func (ix *Indexer) Delete(ctx context.Context, pullID int64) error {
215
+
return ix.indexer.Delete(base36.Encode(pullID))
216
+
}
217
+
218
+
// Search searches for pulls
219
+
func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) {
220
+
var queries []query.Query
221
+
222
+
// TODO(boltless): remove this after implementing pulls page pagination
223
+
limit := opts.Page.Limit
224
+
if limit == 0 {
225
+
limit = 500
226
+
}
227
+
228
+
if opts.Keyword != "" {
229
+
queries = append(queries, bleve.NewDisjunctionQuery(
230
+
bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0),
231
+
bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0),
232
+
))
233
+
}
234
+
queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt))
235
+
queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String()))
236
+
237
+
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
238
+
searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false)
239
+
res, err := ix.indexer.SearchInContext(ctx, searchReq)
240
+
if err != nil {
241
+
return nil, nil
242
+
}
243
+
ret := &searchResult{
244
+
Total: res.Total,
245
+
Hits: make([]int64, len(res.Hits)),
246
+
}
247
+
for i, hit := range res.Hits {
248
+
id, err := base36.Decode(hit.ID)
249
+
if err != nil {
250
+
return nil, err
251
+
}
252
+
ret.Hits[i] = id
253
+
}
254
+
return ret, nil
255
+
}
+8
-2
appview/ingester.go
+8
-2
appview/ingester.go
···
89
89
}
90
90
91
91
if err != nil {
92
-
l.Debug("error ingesting record", "err", err)
92
+
l.Warn("refused to ingest record", "err", err)
93
93
}
94
94
95
95
return nil
···
291
291
292
292
includeBluesky := record.Bluesky
293
293
294
+
pronouns := ""
295
+
if record.Pronouns != nil {
296
+
pronouns = *record.Pronouns
297
+
}
298
+
294
299
location := ""
295
300
if record.Location != nil {
296
301
location = *record.Location
···
325
330
Links: links,
326
331
Stats: stats,
327
332
PinnedRepos: pinned,
333
+
Pronouns: pronouns,
328
334
}
329
335
330
336
ddb, ok := i.Db.Execer.(*db.DB)
···
1008
1014
if !ok {
1009
1015
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010
1016
}
1011
-
if err := i.Validator.ValidateLabelOp(def, &o); err != nil {
1017
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012
1018
return fmt.Errorf("failed to validate labelop: %w", err)
1013
1019
}
1014
1020
}
+119
-52
appview/issues/issues.go
+119
-52
appview/issues/issues.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"slices"
12
11
"time"
13
12
14
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
15
15
"github.com/bluesky-social/indigo/atproto/syntax"
16
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
17
"github.com/go-chi/chi/v5"
···
19
19
"tangled.org/core/api/tangled"
20
20
"tangled.org/core/appview/config"
21
21
"tangled.org/core/appview/db"
22
+
issues_indexer "tangled.org/core/appview/indexer/issues"
22
23
"tangled.org/core/appview/models"
23
24
"tangled.org/core/appview/notify"
24
25
"tangled.org/core/appview/oauth"
25
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/markup"
26
28
"tangled.org/core/appview/pagination"
27
29
"tangled.org/core/appview/reporesolver"
28
30
"tangled.org/core/appview/validator"
29
-
"tangled.org/core/appview/xrpcclient"
30
31
"tangled.org/core/idresolver"
31
-
tlog "tangled.org/core/log"
32
32
"tangled.org/core/tid"
33
33
)
34
34
···
42
42
notifier notify.Notifier
43
43
logger *slog.Logger
44
44
validator *validator.Validator
45
+
indexer *issues_indexer.Indexer
45
46
}
46
47
47
48
func New(
···
53
54
config *config.Config,
54
55
notifier notify.Notifier,
55
56
validator *validator.Validator,
57
+
indexer *issues_indexer.Indexer,
58
+
logger *slog.Logger,
56
59
) *Issues {
57
60
return &Issues{
58
61
oauth: oauth,
···
62
65
db: db,
63
66
config: config,
64
67
notifier: notifier,
65
-
logger: tlog.New("issues"),
68
+
logger: logger,
66
69
validator: validator,
70
+
indexer: indexer,
67
71
}
68
72
}
69
73
···
72
76
user := rp.oauth.GetUser(r)
73
77
f, err := rp.repoResolver.Resolve(r)
74
78
if err != nil {
75
-
log.Println("failed to get repo and knot", err)
79
+
l.Error("failed to get repo and knot", "err", err)
76
80
return
77
81
}
78
82
···
83
87
return
84
88
}
85
89
86
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
90
+
reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
87
91
if err != nil {
88
92
l.Error("failed to get issue reactions", "err", err)
89
93
}
···
99
103
db.FilterContains("scope", tangled.RepoIssueNSID),
100
104
)
101
105
if err != nil {
102
-
log.Println("failed to fetch labels", err)
106
+
l.Error("failed to fetch labels", "err", err)
103
107
rp.pages.Error503(w)
104
108
return
105
109
}
···
115
119
Issue: issue,
116
120
CommentList: issue.CommentList(),
117
121
OrderedReactionKinds: models.OrderedReactionKinds,
118
-
Reactions: reactionCountMap,
122
+
Reactions: reactionMap,
119
123
UserReacted: userReactions,
120
124
LabelDefs: defs,
121
125
})
···
126
130
user := rp.oauth.GetUser(r)
127
131
f, err := rp.repoResolver.Resolve(r)
128
132
if err != nil {
129
-
log.Println("failed to get repo and knot", err)
133
+
l.Error("failed to get repo and knot", "err", err)
130
134
return
131
135
}
132
136
···
166
170
return
167
171
}
168
172
169
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
173
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
170
174
if err != nil {
171
175
l.Error("failed to get record", "err", err)
172
176
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
173
177
return
174
178
}
175
179
176
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
180
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
177
181
Collection: tangled.RepoIssueNSID,
178
182
Repo: user.Did,
179
183
Rkey: newIssue.Rkey,
···
199
203
200
204
err = db.PutIssue(tx, newIssue)
201
205
if err != nil {
202
-
log.Println("failed to edit issue", err)
206
+
l.Error("failed to edit issue", "err", err)
203
207
rp.pages.Notice(w, "issues", "Failed to edit issue.")
204
208
return
205
209
}
···
237
241
// delete from PDS
238
242
client, err := rp.oauth.AuthorizedClient(r)
239
243
if err != nil {
240
-
log.Println("failed to get authorized client", err)
244
+
l.Error("failed to get authorized client", "err", err)
241
245
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
242
246
return
243
247
}
244
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
248
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
245
249
Collection: tangled.RepoIssueNSID,
246
250
Repo: issue.Did,
247
251
Rkey: issue.Rkey,
···
260
264
return
261
265
}
262
266
267
+
rp.notifier.DeleteIssue(r.Context(), issue)
268
+
263
269
// return to all issues page
264
270
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
265
271
}
···
282
288
283
289
collaborators, err := f.Collaborators(r.Context())
284
290
if err != nil {
285
-
log.Println("failed to fetch repo collaborators: %w", err)
291
+
l.Error("failed to fetch repo collaborators", "err", err)
286
292
}
287
293
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
288
294
return user.Did == collab.Did
···
296
302
db.FilterEq("id", issue.Id),
297
303
)
298
304
if err != nil {
299
-
log.Println("failed to close issue", err)
305
+
l.Error("failed to close issue", "err", err)
300
306
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
301
307
return
302
308
}
309
+
// change the issue state (this will pass down to the notifiers)
310
+
issue.Open = false
311
+
312
+
// notify about the issue closure
313
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
303
314
304
315
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
305
316
return
306
317
} else {
307
-
log.Println("user is not permitted to close issue")
318
+
l.Error("user is not permitted to close issue")
308
319
http.Error(w, "for biden", http.StatusUnauthorized)
309
320
return
310
321
}
···
315
326
user := rp.oauth.GetUser(r)
316
327
f, err := rp.repoResolver.Resolve(r)
317
328
if err != nil {
318
-
log.Println("failed to get repo and knot", err)
329
+
l.Error("failed to get repo and knot", "err", err)
319
330
return
320
331
}
321
332
···
328
339
329
340
collaborators, err := f.Collaborators(r.Context())
330
341
if err != nil {
331
-
log.Println("failed to fetch repo collaborators: %w", err)
342
+
l.Error("failed to fetch repo collaborators", "err", err)
332
343
}
333
344
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
334
345
return user.Did == collab.Did
···
341
352
db.FilterEq("id", issue.Id),
342
353
)
343
354
if err != nil {
344
-
log.Println("failed to reopen issue", err)
355
+
l.Error("failed to reopen issue", "err", err)
345
356
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
346
357
return
347
358
}
359
+
// change the issue state (this will pass down to the notifiers)
360
+
issue.Open = true
361
+
362
+
// notify about the issue reopen
363
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
364
+
348
365
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
349
366
return
350
367
} else {
351
-
log.Println("user is not the owner of the repo")
368
+
l.Error("user is not the owner of the repo")
352
369
http.Error(w, "forbidden", http.StatusUnauthorized)
353
370
return
354
371
}
···
405
422
}
406
423
407
424
// create a record first
408
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
425
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
409
426
Collection: tangled.RepoIssueCommentNSID,
410
427
Repo: comment.Did,
411
428
Rkey: comment.Rkey,
···
434
451
435
452
// reset atUri to make rollback a no-op
436
453
atUri = ""
454
+
455
+
// notify about the new comment
456
+
comment.Id = commentId
457
+
458
+
rawMentions := markup.FindUserMentions(comment.Body)
459
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
460
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
461
+
var mentions []syntax.DID
462
+
for _, ident := range idents {
463
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
464
+
mentions = append(mentions, ident.DID)
465
+
}
466
+
}
467
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
468
+
437
469
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
438
470
}
439
471
···
530
562
newBody := r.FormValue("body")
531
563
client, err := rp.oauth.AuthorizedClient(r)
532
564
if err != nil {
533
-
log.Println("failed to get authorized client", err)
565
+
l.Error("failed to get authorized client", "err", err)
534
566
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
535
567
return
536
568
}
···
543
575
544
576
_, err = db.AddIssueComment(rp.db, newComment)
545
577
if err != nil {
546
-
log.Println("failed to perferom update-description query", err)
578
+
l.Error("failed to perferom update-description query", "err", err)
547
579
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
548
580
return
549
581
}
···
551
583
// rkey is optional, it was introduced later
552
584
if newComment.Rkey != "" {
553
585
// update the record on pds
554
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
586
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
555
587
if err != nil {
556
-
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
588
+
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
557
589
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
558
590
return
559
591
}
560
592
561
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
593
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
562
594
Collection: tangled.RepoIssueCommentNSID,
563
595
Repo: user.Did,
564
596
Rkey: newComment.Rkey,
···
721
753
if comment.Rkey != "" {
722
754
client, err := rp.oauth.AuthorizedClient(r)
723
755
if err != nil {
724
-
log.Println("failed to get authorized client", err)
756
+
l.Error("failed to get authorized client", "err", err)
725
757
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
726
758
return
727
759
}
728
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
760
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
729
761
Collection: tangled.RepoIssueCommentNSID,
730
762
Repo: user.Did,
731
763
Rkey: comment.Rkey,
732
764
})
733
765
if err != nil {
734
-
log.Println(err)
766
+
l.Error("failed to delete from PDS", "err", err)
735
767
}
736
768
}
737
769
···
749
781
}
750
782
751
783
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
784
+
l := rp.logger.With("handler", "RepoIssues")
785
+
752
786
params := r.URL.Query()
753
787
state := params.Get("state")
754
788
isOpen := true
···
761
795
isOpen = true
762
796
}
763
797
764
-
page, ok := r.Context().Value("page").(pagination.Page)
765
-
if !ok {
766
-
log.Println("failed to get page")
767
-
page = pagination.FirstPage()
768
-
}
798
+
page := pagination.FromContext(r.Context())
769
799
770
800
user := rp.oauth.GetUser(r)
771
801
f, err := rp.repoResolver.Resolve(r)
772
802
if err != nil {
773
-
log.Println("failed to get repo and knot", err)
803
+
l.Error("failed to get repo and knot", "err", err)
774
804
return
775
805
}
776
806
777
-
openVal := 0
778
-
if isOpen {
779
-
openVal = 1
807
+
keyword := params.Get("q")
808
+
809
+
var ids []int64
810
+
searchOpts := models.IssueSearchOptions{
811
+
Keyword: keyword,
812
+
RepoAt: f.RepoAt().String(),
813
+
IsOpen: isOpen,
814
+
Page: page,
815
+
}
816
+
if keyword != "" {
817
+
res, err := rp.indexer.Search(r.Context(), searchOpts)
818
+
if err != nil {
819
+
l.Error("failed to search for issues", "err", err)
820
+
return
821
+
}
822
+
ids = res.Hits
823
+
l.Debug("searched issues with indexer", "count", len(ids))
824
+
} else {
825
+
ids, err = db.GetIssueIDs(rp.db, searchOpts)
826
+
if err != nil {
827
+
l.Error("failed to search for issues", "err", err)
828
+
return
829
+
}
830
+
l.Debug("indexed all issues from the db", "count", len(ids))
780
831
}
781
-
issues, err := db.GetIssuesPaginated(
832
+
833
+
issues, err := db.GetIssues(
782
834
rp.db,
783
-
page,
784
-
db.FilterEq("repo_at", f.RepoAt()),
785
-
db.FilterEq("open", openVal),
835
+
db.FilterIn("id", ids),
786
836
)
787
837
if err != nil {
788
-
log.Println("failed to get issues", err)
838
+
l.Error("failed to get issues", "err", err)
789
839
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
790
840
return
791
841
}
792
842
793
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
843
+
labelDefs, err := db.GetLabelDefinitions(
844
+
rp.db,
845
+
db.FilterIn("at_uri", f.Repo.Labels),
846
+
db.FilterContains("scope", tangled.RepoIssueNSID),
847
+
)
794
848
if err != nil {
795
-
log.Println("failed to fetch labels", err)
849
+
l.Error("failed to fetch labels", "err", err)
796
850
rp.pages.Error503(w)
797
851
return
798
852
}
···
808
862
Issues: issues,
809
863
LabelDefs: defs,
810
864
FilteringByOpen: isOpen,
865
+
FilterQuery: keyword,
811
866
Page: page,
812
867
})
813
868
}
···
834
889
Rkey: tid.TID(),
835
890
Title: r.FormValue("title"),
836
891
Body: r.FormValue("body"),
892
+
Open: true,
837
893
Did: user.Did,
838
894
Created: time.Now(),
895
+
Repo: &f.Repo,
839
896
}
840
897
841
898
if err := rp.validator.ValidateIssue(issue); err != nil {
···
853
910
rp.pages.Notice(w, "issues", "Failed to create issue.")
854
911
return
855
912
}
856
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
913
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
857
914
Collection: tangled.RepoIssueNSID,
858
915
Repo: user.Did,
859
916
Rkey: issue.Rkey,
···
889
946
890
947
err = db.PutIssue(tx, issue)
891
948
if err != nil {
892
-
log.Println("failed to create issue", err)
949
+
l.Error("failed to create issue", "err", err)
893
950
rp.pages.Notice(w, "issues", "Failed to create issue.")
894
951
return
895
952
}
896
953
897
954
if err = tx.Commit(); err != nil {
898
-
log.Println("failed to create issue", err)
955
+
l.Error("failed to create issue", "err", err)
899
956
rp.pages.Notice(w, "issues", "Failed to create issue.")
900
957
return
901
958
}
902
959
903
960
// everything is successful, do not rollback the atproto record
904
961
atUri = ""
905
-
rp.notifier.NewIssue(r.Context(), issue)
962
+
963
+
rawMentions := markup.FindUserMentions(issue.Body)
964
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
965
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
966
+
var mentions []syntax.DID
967
+
for _, ident := range idents {
968
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
969
+
mentions = append(mentions, ident.DID)
970
+
}
971
+
}
972
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
906
973
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
907
974
return
908
975
}
···
911
978
// this is used to rollback changes made to the PDS
912
979
//
913
980
// it is a no-op if the provided ATURI is empty
914
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
981
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
915
982
if aturi == "" {
916
983
return nil
917
984
}
···
922
989
repo := parsed.Authority().String()
923
990
rkey := parsed.RecordKey().String()
924
991
925
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
992
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
926
993
Collection: collection,
927
994
Repo: repo,
928
995
Rkey: rkey,
+267
appview/issues/opengraph.go
+267
appview/issues/opengraph.go
···
1
+
package issues
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/ogcard"
15
+
)
16
+
17
+
func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
18
+
width, height := ogcard.DefaultSize()
19
+
mainCard, err := ogcard.NewCard(width, height)
20
+
if err != nil {
21
+
return nil, err
22
+
}
23
+
24
+
// Split: content area (75%) and status/stats area (25%)
25
+
contentCard, statsArea := mainCard.Split(false, 75)
26
+
27
+
// Add padding to content
28
+
contentCard.SetMargin(50)
29
+
30
+
// Split content horizontally: main content (80%) and avatar area (20%)
31
+
mainContent, avatarArea := contentCard.Split(true, 80)
32
+
33
+
// Add margin to main content like repo card
34
+
mainContent.SetMargin(10)
35
+
36
+
// Use full main content area for repo name and title
37
+
bounds := mainContent.Img.Bounds()
38
+
startX := bounds.Min.X + mainContent.Margin
39
+
startY := bounds.Min.Y + mainContent.Margin
40
+
41
+
// Draw full repository name at top (owner/repo format)
42
+
var repoOwner string
43
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
44
+
if err != nil {
45
+
repoOwner = repo.Did
46
+
} else {
47
+
repoOwner = "@" + owner.Handle.String()
48
+
}
49
+
50
+
fullRepoName := repoOwner + " / " + repo.Name
51
+
if len(fullRepoName) > 60 {
52
+
fullRepoName = fullRepoName[:60] + "…"
53
+
}
54
+
55
+
grayColor := color.RGBA{88, 96, 105, 255}
56
+
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
57
+
if err != nil {
58
+
return nil, err
59
+
}
60
+
61
+
// Draw issue title below repo name with wrapping
62
+
titleY := startY + 60
63
+
titleX := startX
64
+
65
+
// Truncate title if too long
66
+
issueTitle := issue.Title
67
+
maxTitleLength := 80
68
+
if len(issueTitle) > maxTitleLength {
69
+
issueTitle = issueTitle[:maxTitleLength] + "…"
70
+
}
71
+
72
+
// Create a temporary card for the title area to enable wrapping
73
+
titleBounds := mainContent.Img.Bounds()
74
+
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
75
+
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
76
+
77
+
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
78
+
titleCard := &ogcard.Card{
79
+
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
80
+
Font: mainContent.Font,
81
+
Margin: 0,
82
+
}
83
+
84
+
// Draw wrapped title
85
+
lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
86
+
if err != nil {
87
+
return nil, err
88
+
}
89
+
90
+
// Calculate where title ends (number of lines * line height)
91
+
lineHeight := 60 // Approximate line height for 54pt font
92
+
titleEndY := titleY + (len(lines) * lineHeight) + 10
93
+
94
+
// Draw issue ID in gray below the title
95
+
issueIdText := fmt.Sprintf("#%d", issue.IssueId)
96
+
err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
97
+
if err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
// Get issue author handle (needed for avatar and metadata)
102
+
var authorHandle string
103
+
author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
104
+
if err != nil {
105
+
authorHandle = issue.Did
106
+
} else {
107
+
authorHandle = "@" + author.Handle.String()
108
+
}
109
+
110
+
// Draw avatar circle on the right side
111
+
avatarBounds := avatarArea.Img.Bounds()
112
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
113
+
if avatarSize > 220 {
114
+
avatarSize = 220
115
+
}
116
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
117
+
avatarY := avatarBounds.Min.Y + 20
118
+
119
+
// Get avatar URL for issue author
120
+
avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
121
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
122
+
if err != nil {
123
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
124
+
}
125
+
126
+
// Split stats area: left side for status/comments (80%), right side for dolly (20%)
127
+
statusCommentsArea, dollyArea := statsArea.Split(true, 80)
128
+
129
+
// Draw status and comment count in status/comments area
130
+
statsBounds := statusCommentsArea.Img.Bounds()
131
+
statsX := statsBounds.Min.X + 60 // left padding
132
+
statsY := statsBounds.Min.Y
133
+
134
+
iconColor := color.RGBA{88, 96, 105, 255}
135
+
iconSize := 36
136
+
textSize := 36.0
137
+
labelSize := 28.0
138
+
iconBaselineOffset := int(textSize) / 2
139
+
140
+
// Draw status (open/closed) with colored icon and text
141
+
var statusIcon string
142
+
var statusText string
143
+
var statusBgColor color.RGBA
144
+
145
+
if issue.Open {
146
+
statusIcon = "circle-dot"
147
+
statusText = "open"
148
+
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
+
} else {
150
+
statusIcon = "ban"
151
+
statusText = "closed"
152
+
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
+
}
154
+
155
+
badgeIconSize := 36
156
+
157
+
// Draw icon with status color (no background)
158
+
err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
+
if err != nil {
160
+
log.Printf("failed to draw status icon: %v", err)
161
+
}
162
+
163
+
// Draw text with status color (no background)
164
+
textX := statsX + badgeIconSize + 12
165
+
badgeTextSize := 32.0
166
+
err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
167
+
if err != nil {
168
+
log.Printf("failed to draw status text: %v", err)
169
+
}
170
+
171
+
statusTextWidth := len(statusText) * 20
172
+
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
+
174
+
// Draw comment count
175
+
err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
+
if err != nil {
177
+
log.Printf("failed to draw comment icon: %v", err)
178
+
}
179
+
180
+
currentX += iconSize + 15
181
+
commentText := fmt.Sprintf("%d comments", commentCount)
182
+
if commentCount == 1 {
183
+
commentText = "1 comment"
184
+
}
185
+
err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
186
+
if err != nil {
187
+
log.Printf("failed to draw comment text: %v", err)
188
+
}
189
+
190
+
// Draw dolly logo on the right side
191
+
dollyBounds := dollyArea.Img.Bounds()
192
+
dollySize := 90
193
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
+
if err != nil {
198
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
+
}
200
+
201
+
// Draw "opened by @author" and date at the bottom with more spacing
202
+
labelY := statsY + iconSize + 30
203
+
204
+
// Format the opened date
205
+
openedDate := issue.Created.Format("Jan 2, 2006")
206
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
207
+
208
+
err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
209
+
if err != nil {
210
+
log.Printf("failed to draw metadata: %v", err)
211
+
}
212
+
213
+
return mainCard, nil
214
+
}
215
+
216
+
func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
217
+
f, err := rp.repoResolver.Resolve(r)
218
+
if err != nil {
219
+
log.Println("failed to get repo and knot", err)
220
+
return
221
+
}
222
+
223
+
issue, ok := r.Context().Value("issue").(*models.Issue)
224
+
if !ok {
225
+
log.Println("issue not found in context")
226
+
http.Error(w, "issue not found", http.StatusNotFound)
227
+
return
228
+
}
229
+
230
+
// Get comment count
231
+
commentCount := len(issue.Comments)
232
+
233
+
// Get owner handle for avatar
234
+
var ownerHandle string
235
+
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
236
+
if err != nil {
237
+
ownerHandle = f.Repo.Did
238
+
} else {
239
+
ownerHandle = "@" + owner.Handle.String()
240
+
}
241
+
242
+
card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
243
+
if err != nil {
244
+
log.Println("failed to draw issue summary card", err)
245
+
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
246
+
return
247
+
}
248
+
249
+
var imageBuffer bytes.Buffer
250
+
err = png.Encode(&imageBuffer, card.Img)
251
+
if err != nil {
252
+
log.Println("failed to encode issue summary card", err)
253
+
http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
254
+
return
255
+
}
256
+
257
+
imageBytes := imageBuffer.Bytes()
258
+
259
+
w.Header().Set("Content-Type", "image/png")
260
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
261
+
w.WriteHeader(http.StatusOK)
262
+
_, err = w.Write(imageBytes)
263
+
if err != nil {
264
+
log.Println("failed to write issue summary card", err)
265
+
return
266
+
}
267
+
}
+1
appview/issues/router.go
+1
appview/issues/router.go
+15
-6
appview/knots/knots.go
+15
-6
appview/knots/knots.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
145
146
}
146
147
147
148
domain := r.FormValue("domain")
149
+
// Strip protocol, trailing slashes, and whitespace
150
+
// Rkey cannot contain slashes
151
+
domain = strings.TrimSpace(domain)
152
+
domain = strings.TrimPrefix(domain, "https://")
153
+
domain = strings.TrimPrefix(domain, "http://")
154
+
domain = strings.TrimSuffix(domain, "/")
148
155
if domain == "" {
149
156
k.Pages.Notice(w, noticeId, "Incomplete form.")
150
157
return
···
185
192
return
186
193
}
187
194
188
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
195
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
189
196
var exCid *string
190
197
if ex != nil {
191
198
exCid = ex.Cid
192
199
}
193
200
194
201
// re-announce by registering under same rkey
195
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
202
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
196
203
Collection: tangled.KnotNSID,
197
204
Repo: user.Did,
198
205
Rkey: domain,
···
323
330
return
324
331
}
325
332
326
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
333
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
327
334
Collection: tangled.KnotNSID,
328
335
Repo: user.Did,
329
336
Rkey: domain,
···
431
438
return
432
439
}
433
440
434
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
441
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
435
442
var exCid *string
436
443
if ex != nil {
437
444
exCid = ex.Cid
438
445
}
439
446
440
447
// ignore the error here
441
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
448
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
442
449
Collection: tangled.KnotNSID,
443
450
Repo: user.Did,
444
451
Rkey: domain,
···
526
533
}
527
534
528
535
member := r.FormValue("member")
536
+
member = strings.TrimPrefix(member, "@")
529
537
if member == "" {
530
538
l.Error("empty member")
531
539
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
555
563
556
564
rkey := tid.TID()
557
565
558
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
566
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
559
567
Collection: tangled.KnotMemberNSID,
560
568
Repo: user.Did,
561
569
Rkey: rkey,
···
626
634
}
627
635
628
636
member := r.FormValue("member")
637
+
member = strings.TrimPrefix(member, "@")
629
638
if member == "" {
630
639
l.Error("empty member")
631
640
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+25
-17
appview/labels/labels.go
+25
-17
appview/labels/labels.go
···
9
9
"net/http"
10
10
"time"
11
11
12
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
"github.com/go-chi/chi/v5"
16
-
17
12
"tangled.org/core/api/tangled"
18
13
"tangled.org/core/appview/db"
19
14
"tangled.org/core/appview/middleware"
···
21
16
"tangled.org/core/appview/oauth"
22
17
"tangled.org/core/appview/pages"
23
18
"tangled.org/core/appview/validator"
24
-
"tangled.org/core/appview/xrpcclient"
25
-
"tangled.org/core/log"
19
+
"tangled.org/core/rbac"
26
20
"tangled.org/core/tid"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
24
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
+
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
"github.com/go-chi/chi/v5"
27
27
)
28
28
29
29
type Labels struct {
···
32
32
db *db.DB
33
33
logger *slog.Logger
34
34
validator *validator.Validator
35
+
enforcer *rbac.Enforcer
35
36
}
36
37
37
38
func New(
···
39
40
pages *pages.Pages,
40
41
db *db.DB,
41
42
validator *validator.Validator,
43
+
enforcer *rbac.Enforcer,
44
+
logger *slog.Logger,
42
45
) *Labels {
43
-
logger := log.New("labels")
44
-
45
46
return &Labels{
46
47
oauth: oauth,
47
48
pages: pages,
48
49
db: db,
49
50
logger: logger,
50
51
validator: validator,
52
+
enforcer: enforcer,
51
53
}
52
54
}
53
55
54
-
func (l *Labels) Router(mw *middleware.Middleware) http.Handler {
56
+
func (l *Labels) Router() http.Handler {
55
57
r := chi.NewRouter()
56
58
57
59
r.Use(middleware.AuthMiddleware(l.oauth))
···
85
87
indexedAt := time.Now()
86
88
repoAt := r.Form.Get("repo")
87
89
subjectUri := r.Form.Get("subject")
90
+
91
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
92
+
if err != nil {
93
+
fail("Failed to get repository.", err)
94
+
return
95
+
}
88
96
89
97
// find all the labels that this repo subscribes to
90
98
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
···
152
160
}
153
161
}
154
162
155
-
// reduce the opset
156
-
labelOps = models.ReduceLabelOps(labelOps)
157
-
158
163
for i := range labelOps {
159
164
def := actx.Defs[labelOps[i].OperandKey]
160
-
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
165
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
161
166
fail(fmt.Sprintf("Invalid form data: %s", err), err)
162
167
return
163
168
}
164
169
}
170
+
171
+
// reduce the opset
172
+
labelOps = models.ReduceLabelOps(labelOps)
165
173
166
174
// next, apply all ops introduced in this request and filter out ones that are no-ops
167
175
validLabelOps := labelOps[:0]
···
186
194
return
187
195
}
188
196
189
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
197
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
190
198
Collection: tangled.LabelOpNSID,
191
199
Repo: did,
192
200
Rkey: rkey,
···
242
250
// this is used to rollback changes made to the PDS
243
251
//
244
252
// it is a no-op if the provided ATURI is empty
245
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
253
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
246
254
if aturi == "" {
247
255
return nil
248
256
}
···
253
261
repo := parsed.Authority().String()
254
262
rkey := parsed.RecordKey().String()
255
263
256
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
264
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
257
265
Collection: collection,
258
266
Repo: repo,
259
267
Rkey: rkey,
+16
-21
appview/middleware/middleware.go
+16
-21
appview/middleware/middleware.go
···
43
43
44
44
type middlewareFunc func(http.Handler) http.Handler
45
45
46
-
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
46
+
func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
47
47
return func(next http.Handler) http.Handler {
48
48
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
49
returnURL := "/"
···
63
63
}
64
64
}
65
65
66
-
_, auth, err := a.GetSession(r)
66
+
sess, err := o.ResumeSession(r)
67
67
if err != nil {
68
-
log.Println("not logged in, redirecting", "err", err)
68
+
log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String())
69
69
redirectFunc(w, r)
70
70
return
71
71
}
72
72
73
-
if !auth {
74
-
log.Printf("not logged in, redirecting")
73
+
if sess == nil {
74
+
log.Printf("session is nil, redirecting...")
75
75
redirectFunc(w, r)
76
76
return
77
77
}
···
105
105
}
106
106
}
107
107
108
-
ctx := context.WithValue(r.Context(), "page", page)
108
+
ctx := pagination.IntoContext(r.Context(), page)
109
109
next.ServeHTTP(w, r.WithContext(ctx))
110
110
})
111
111
}
···
180
180
return func(next http.Handler) http.Handler {
181
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
182
didOrHandle := chi.URLParam(req, "user")
183
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
184
+
183
185
if slices.Contains(excluded, didOrHandle) {
184
186
next.ServeHTTP(w, req)
185
187
return
186
188
}
187
-
188
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
189
190
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
191
if err != nil {
···
206
206
return func(next http.Handler) http.Handler {
207
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
209
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
212
if !ok {
211
213
log.Println("malformed middleware")
···
244
246
prId := chi.URLParam(r, "pull")
245
247
prIdInt, err := strconv.Atoi(prId)
246
248
if err != nil {
247
-
http.Error(w, "bad pr id", http.StatusBadRequest)
248
249
log.Println("failed to parse pr id", err)
250
+
mw.pages.Error404(w)
249
251
return
250
252
}
251
253
252
254
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
253
255
if err != nil {
254
256
log.Println("failed to get pull and comments", err)
257
+
mw.pages.Error404(w)
255
258
return
256
259
}
257
260
···
292
295
issueId, err := strconv.Atoi(issueIdStr)
293
296
if err != nil {
294
297
log.Println("failed to fully resolve issue ID", err)
295
-
mw.pages.ErrorKnot404(w)
298
+
mw.pages.Error404(w)
296
299
return
297
300
}
298
301
299
-
issues, err := db.GetIssues(
300
-
mw.db,
301
-
db.FilterEq("repo_at", f.RepoAt()),
302
-
db.FilterEq("issue_id", issueId),
303
-
)
302
+
issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId)
304
303
if err != nil {
305
304
log.Println("failed to get issues", "err", err)
306
-
return
307
-
}
308
-
if len(issues) != 1 {
309
-
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
305
+
mw.pages.Error404(w)
310
306
return
311
307
}
312
-
issue := issues[0]
313
308
314
-
ctx := context.WithValue(r.Context(), "issue", &issue)
309
+
ctx := context.WithValue(r.Context(), "issue", issue)
315
310
next.ServeHTTP(w, r.WithContext(ctx))
316
311
})
317
312
}
+24
appview/models/issue.go
+24
appview/models/issue.go
···
54
54
Replies []*IssueComment
55
55
}
56
56
57
+
func (it *CommentListItem) Participants() []syntax.DID {
58
+
participantSet := make(map[syntax.DID]struct{})
59
+
participants := []syntax.DID{}
60
+
61
+
addParticipant := func(did syntax.DID) {
62
+
if _, exists := participantSet[did]; !exists {
63
+
participantSet[did] = struct{}{}
64
+
participants = append(participants, did)
65
+
}
66
+
}
67
+
68
+
addParticipant(syntax.DID(it.Self.Did))
69
+
70
+
for _, c := range it.Replies {
71
+
addParticipant(syntax.DID(c.Did))
72
+
}
73
+
74
+
return participants
75
+
}
76
+
57
77
func (i *Issue) CommentList() []CommentListItem {
58
78
// Create a map to quickly find comments by their aturi
59
79
toplevel := make(map[string]*CommentListItem)
···
167
187
168
188
func (i *IssueComment) IsTopLevel() bool {
169
189
return i.ReplyTo == nil
190
+
}
191
+
192
+
func (i *IssueComment) IsReply() bool {
193
+
return i.ReplyTo != nil
170
194
}
171
195
172
196
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+30
-46
appview/models/label.go
+30
-46
appview/models/label.go
···
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"tangled.org/core/api/tangled"
17
-
"tangled.org/core/consts"
18
17
"tangled.org/core/idresolver"
19
18
)
20
19
···
232
231
}
233
232
234
233
var ops []LabelOp
235
-
for _, o := range record.Add {
234
+
// deletes first, then additions
235
+
for _, o := range record.Delete {
236
236
if o != nil {
237
237
op := mkOp(o)
238
-
op.Operation = LabelOperationAdd
238
+
op.Operation = LabelOperationDel
239
239
ops = append(ops, op)
240
240
}
241
241
}
242
-
for _, o := range record.Delete {
242
+
for _, o := range record.Add {
243
243
if o != nil {
244
244
op := mkOp(o)
245
-
op.Operation = LabelOperationDel
245
+
op.Operation = LabelOperationAdd
246
246
ops = append(ops, op)
247
247
}
248
248
}
···
460
460
return result
461
461
}
462
462
463
-
func DefaultLabelDefs() []string {
464
-
rkeys := []string{
465
-
"wontfix",
466
-
"duplicate",
467
-
"assignee",
468
-
"good-first-issue",
469
-
"documentation",
470
-
}
471
-
472
-
defs := make([]string, len(rkeys))
473
-
for i, r := range rkeys {
474
-
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
475
-
}
476
-
477
-
return defs
478
-
}
479
-
480
-
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
481
-
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
482
-
if err != nil {
483
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
484
-
}
485
-
pdsEndpoint := resolved.PDSEndpoint()
486
-
if pdsEndpoint == "" {
487
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
488
-
}
489
-
client := &xrpc.Client{
490
-
Host: pdsEndpoint,
491
-
}
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
+
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
492
466
493
-
var labelDefs []LabelDefinition
467
+
for _, dl := range aturis {
468
+
atUri, err := syntax.ParseATURI(dl)
469
+
if err != nil {
470
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err)
471
+
}
472
+
if atUri.Collection() != tangled.LabelDefinitionNSID {
473
+
return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri)
474
+
}
494
475
495
-
for _, dl := range DefaultLabelDefs() {
496
-
atUri := syntax.ATURI(dl)
497
-
parsedUri, err := syntax.ParseATURI(string(atUri))
476
+
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
498
477
if err != nil {
499
-
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
478
+
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
500
479
}
480
+
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
483
+
}
484
+
501
485
record, err := atproto.RepoGetRecord(
502
-
context.Background(),
503
-
client,
486
+
ctx,
487
+
&xrpcc,
504
488
"",
505
-
parsedUri.Collection().String(),
506
-
parsedUri.Authority().String(),
507
-
parsedUri.RecordKey().String(),
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
508
492
)
509
493
if err != nil {
510
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
524
508
}
525
509
526
510
labelDef, err := LabelDefinitionFromRecord(
527
-
parsedUri.Authority().String(),
528
-
parsedUri.RecordKey().String(),
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
529
513
labelRecord,
530
514
)
531
515
if err != nil {
+141
appview/models/notifications.go
+141
appview/models/notifications.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type NotificationType string
10
+
11
+
const (
12
+
NotificationTypeRepoStarred NotificationType = "repo_starred"
13
+
NotificationTypeIssueCreated NotificationType = "issue_created"
14
+
NotificationTypeIssueCommented NotificationType = "issue_commented"
15
+
NotificationTypePullCreated NotificationType = "pull_created"
16
+
NotificationTypePullCommented NotificationType = "pull_commented"
17
+
NotificationTypeFollowed NotificationType = "followed"
18
+
NotificationTypePullMerged NotificationType = "pull_merged"
19
+
NotificationTypeIssueClosed NotificationType = "issue_closed"
20
+
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
+
NotificationTypePullClosed NotificationType = "pull_closed"
22
+
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
24
+
)
25
+
26
+
type Notification struct {
27
+
ID int64
28
+
RecipientDid string
29
+
ActorDid string
30
+
Type NotificationType
31
+
EntityType string
32
+
EntityId string
33
+
Read bool
34
+
Created time.Time
35
+
36
+
// foreign key references
37
+
RepoId *int64
38
+
IssueId *int64
39
+
PullId *int64
40
+
}
41
+
42
+
// lucide icon that represents this notification
43
+
func (n *Notification) Icon() string {
44
+
switch n.Type {
45
+
case NotificationTypeRepoStarred:
46
+
return "star"
47
+
case NotificationTypeIssueCreated:
48
+
return "circle-dot"
49
+
case NotificationTypeIssueCommented:
50
+
return "message-square"
51
+
case NotificationTypeIssueClosed:
52
+
return "ban"
53
+
case NotificationTypeIssueReopen:
54
+
return "circle-dot"
55
+
case NotificationTypePullCreated:
56
+
return "git-pull-request-create"
57
+
case NotificationTypePullCommented:
58
+
return "message-square"
59
+
case NotificationTypePullMerged:
60
+
return "git-merge"
61
+
case NotificationTypePullClosed:
62
+
return "git-pull-request-closed"
63
+
case NotificationTypePullReopen:
64
+
return "git-pull-request-create"
65
+
case NotificationTypeFollowed:
66
+
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
69
+
default:
70
+
return ""
71
+
}
72
+
}
73
+
74
+
type NotificationWithEntity struct {
75
+
*Notification
76
+
Repo *Repo
77
+
Issue *Issue
78
+
Pull *Pull
79
+
}
80
+
81
+
type NotificationPreferences struct {
82
+
ID int64
83
+
UserDid syntax.DID
84
+
RepoStarred bool
85
+
IssueCreated bool
86
+
IssueCommented bool
87
+
PullCreated bool
88
+
PullCommented bool
89
+
Followed bool
90
+
UserMentioned bool
91
+
PullMerged bool
92
+
IssueClosed bool
93
+
EmailNotifications bool
94
+
}
95
+
96
+
func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool {
97
+
switch t {
98
+
case NotificationTypeRepoStarred:
99
+
return prefs.RepoStarred
100
+
case NotificationTypeIssueCreated:
101
+
return prefs.IssueCreated
102
+
case NotificationTypeIssueCommented:
103
+
return prefs.IssueCommented
104
+
case NotificationTypeIssueClosed:
105
+
return prefs.IssueClosed
106
+
case NotificationTypeIssueReopen:
107
+
return prefs.IssueCreated // smae pref for now
108
+
case NotificationTypePullCreated:
109
+
return prefs.PullCreated
110
+
case NotificationTypePullCommented:
111
+
return prefs.PullCommented
112
+
case NotificationTypePullMerged:
113
+
return prefs.PullMerged
114
+
case NotificationTypePullClosed:
115
+
return prefs.PullMerged // same pref for now
116
+
case NotificationTypePullReopen:
117
+
return prefs.PullCreated // same pref for now
118
+
case NotificationTypeFollowed:
119
+
return prefs.Followed
120
+
case NotificationTypeUserMentioned:
121
+
return prefs.UserMentioned
122
+
default:
123
+
return false
124
+
}
125
+
}
126
+
127
+
func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences {
128
+
return &NotificationPreferences{
129
+
UserDid: user,
130
+
RepoStarred: true,
131
+
IssueCreated: true,
132
+
IssueCommented: true,
133
+
PullCreated: true,
134
+
PullCommented: true,
135
+
Followed: true,
136
+
UserMentioned: true,
137
+
PullMerged: true,
138
+
IssueClosed: true,
139
+
EmailNotifications: false,
140
+
}
141
+
}
+1
appview/models/profile.go
+1
appview/models/profile.go
+77
-28
appview/models/pull.go
+77
-28
appview/models/pull.go
···
77
77
PullSource *PullSource
78
78
79
79
// optionally, populate this when querying for reverse mappings
80
-
Repo *Repo
80
+
Labels LabelState
81
+
Repo *Repo
81
82
}
82
83
83
84
func (p Pull) AsRecord() tangled.RepoPull {
84
85
var source *tangled.RepoPull_Source
85
86
if p.PullSource != nil {
86
-
s := p.PullSource.AsRecord()
87
-
source = &s
87
+
source = &tangled.RepoPull_Source{}
88
+
source.Branch = p.PullSource.Branch
88
89
source.Sha = p.LatestSha()
90
+
if p.PullSource.RepoAt != nil {
91
+
s := p.PullSource.RepoAt.String()
92
+
source.Repo = &s
93
+
}
89
94
}
90
95
91
96
record := tangled.RepoPull{
···
110
115
Repo *Repo
111
116
}
112
117
113
-
func (p PullSource) AsRecord() tangled.RepoPull_Source {
114
-
var repoAt *string
115
-
if p.RepoAt != nil {
116
-
s := p.RepoAt.String()
117
-
repoAt = &s
118
-
}
119
-
record := tangled.RepoPull_Source{
120
-
Branch: p.Branch,
121
-
Repo: repoAt,
122
-
}
123
-
return record
124
-
}
125
-
126
118
type PullSubmission struct {
127
119
// ids
128
-
ID int
129
-
PullId int
120
+
ID int
130
121
131
122
// at ids
132
-
RepoAt syntax.ATURI
123
+
PullAt syntax.ATURI
133
124
134
125
// content
135
126
RoundNumber int
136
127
Patch string
128
+
Combined string
137
129
Comments []PullComment
138
130
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
139
131
···
159
151
Created time.Time
160
152
}
161
153
154
+
func (p *Pull) LastRoundNumber() int {
155
+
return len(p.Submissions) - 1
156
+
}
157
+
158
+
func (p *Pull) LatestSubmission() *PullSubmission {
159
+
return p.Submissions[p.LastRoundNumber()]
160
+
}
161
+
162
162
func (p *Pull) LatestPatch() string {
163
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
-
return latestSubmission.Patch
163
+
return p.LatestSubmission().Patch
165
164
}
166
165
167
166
func (p *Pull) LatestSha() string {
168
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
169
-
return latestSubmission.SourceRev
167
+
return p.LatestSubmission().SourceRev
170
168
}
171
169
172
-
func (p *Pull) PullAt() syntax.ATURI {
170
+
func (p *Pull) AtUri() syntax.ATURI {
173
171
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
174
-
}
175
-
176
-
func (p *Pull) LastRoundNumber() int {
177
-
return len(p.Submissions) - 1
178
172
}
179
173
180
174
func (p *Pull) IsPatchBased() bool {
···
207
201
return p.StackId != ""
208
202
}
209
203
204
+
func (p *Pull) Participants() []string {
205
+
participantSet := make(map[string]struct{})
206
+
participants := []string{}
207
+
208
+
addParticipant := func(did string) {
209
+
if _, exists := participantSet[did]; !exists {
210
+
participantSet[did] = struct{}{}
211
+
participants = append(participants, did)
212
+
}
213
+
}
214
+
215
+
addParticipant(p.OwnerDid)
216
+
217
+
for _, s := range p.Submissions {
218
+
for _, sp := range s.Participants() {
219
+
addParticipant(sp)
220
+
}
221
+
}
222
+
223
+
return participants
224
+
}
225
+
210
226
func (s PullSubmission) IsFormatPatch() bool {
211
227
return patchutil.IsFormatPatch(s.Patch)
212
228
}
···
219
235
}
220
236
221
237
return patches
238
+
}
239
+
240
+
func (s *PullSubmission) Participants() []string {
241
+
participantSet := make(map[string]struct{})
242
+
participants := []string{}
243
+
244
+
addParticipant := func(did string) {
245
+
if _, exists := participantSet[did]; !exists {
246
+
participantSet[did] = struct{}{}
247
+
participants = append(participants, did)
248
+
}
249
+
}
250
+
251
+
addParticipant(s.PullAt.Authority().String())
252
+
253
+
for _, c := range s.Comments {
254
+
addParticipant(c.OwnerDid)
255
+
}
256
+
257
+
return participants
258
+
}
259
+
260
+
func (s PullSubmission) CombinedPatch() string {
261
+
if s.Combined == "" {
262
+
return s.Patch
263
+
}
264
+
265
+
return s.Combined
222
266
}
223
267
224
268
type Stack []*Pull
···
308
352
309
353
return mergeable
310
354
}
355
+
356
+
type BranchDeleteStatus struct {
357
+
Repo *Repo
358
+
Branch string
359
+
}
+5
appview/models/reaction.go
+5
appview/models/reaction.go
+20
-1
appview/models/repo.go
+20
-1
appview/models/repo.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"strings"
5
6
"time"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
10
11
)
11
12
12
13
type Repo struct {
14
+
Id int64
13
15
Did string
14
16
Name string
15
17
Knot string
16
18
Rkey string
17
19
Created time.Time
18
20
Description string
21
+
Website string
22
+
Topics []string
19
23
Spindle string
20
24
Labels []string
21
25
···
27
31
}
28
32
29
33
func (r *Repo) AsRecord() tangled.Repo {
30
-
var source, spindle, description *string
34
+
var source, spindle, description, website *string
31
35
32
36
if r.Source != "" {
33
37
source = &r.Source
···
41
45
description = &r.Description
42
46
}
43
47
48
+
if r.Website != "" {
49
+
website = &r.Website
50
+
}
51
+
44
52
return tangled.Repo{
45
53
Knot: r.Knot,
46
54
Name: r.Name,
47
55
Description: description,
56
+
Website: website,
57
+
Topics: r.Topics,
48
58
CreatedAt: r.Created.Format(time.RFC3339),
49
59
Source: source,
50
60
Spindle: spindle,
···
59
69
func (r Repo) DidSlashRepo() string {
60
70
p, _ := securejoin.SecureJoin(r.Did, r.Name)
61
71
return p
72
+
}
73
+
74
+
func (r Repo) TopicStr() string {
75
+
return strings.Join(r.Topics, " ")
62
76
}
63
77
64
78
type RepoStats struct {
···
85
99
RepoAt syntax.ATURI
86
100
LabelAt syntax.ATURI
87
101
}
102
+
103
+
type RepoGroup struct {
104
+
Repo *Repo
105
+
Issues []Issue
106
+
}
+31
appview/models/search.go
+31
appview/models/search.go
···
1
+
package models
2
+
3
+
import "tangled.org/core/appview/pagination"
4
+
5
+
type IssueSearchOptions struct {
6
+
Keyword string
7
+
RepoAt string
8
+
IsOpen bool
9
+
10
+
Page pagination.Page
11
+
}
12
+
13
+
type PullSearchOptions struct {
14
+
Keyword string
15
+
RepoAt string
16
+
State PullState
17
+
18
+
Page pagination.Page
19
+
}
20
+
21
+
// func (so *SearchOptions) ToFilters() []filter {
22
+
// var filters []filter
23
+
// if so.IsOpen != nil {
24
+
// openValue := 0
25
+
// if *so.IsOpen {
26
+
// openValue = 1
27
+
// }
28
+
// filters = append(filters, FilterEq("open", openValue))
29
+
// }
30
+
// return filters
31
+
// }
+165
appview/notifications/notifications.go
+165
appview/notifications/notifications.go
···
1
+
package notifications
2
+
3
+
import (
4
+
"log/slog"
5
+
"net/http"
6
+
"strconv"
7
+
8
+
"github.com/go-chi/chi/v5"
9
+
"tangled.org/core/appview/db"
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 {
17
+
db *db.DB
18
+
oauth *oauth.OAuth
19
+
pages *pages.Pages
20
+
logger *slog.Logger
21
+
}
22
+
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications {
24
+
return &Notifications{
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
logger: logger,
29
+
}
30
+
}
31
+
32
+
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
33
+
r := chi.NewRouter()
34
+
35
+
r.Get("/count", n.getUnreadCount)
36
+
37
+
r.Group(func(r chi.Router) {
38
+
r.Use(middleware.AuthMiddleware(n.oauth))
39
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
40
+
r.Post("/{id}/read", n.markRead)
41
+
r.Post("/read-all", n.markAllRead)
42
+
r.Delete("/{id}", n.deleteNotification)
43
+
})
44
+
45
+
return r
46
+
}
47
+
48
+
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
49
+
l := n.logger.With("handler", "notificationsPage")
50
+
user := n.oauth.GetUser(r)
51
+
52
+
page := pagination.FromContext(r.Context())
53
+
54
+
total, err := db.CountNotifications(
55
+
n.db,
56
+
db.FilterEq("recipient_did", user.Did),
57
+
)
58
+
if err != nil {
59
+
l.Error("failed to get total notifications", "err", err)
60
+
n.pages.Error500(w)
61
+
return
62
+
}
63
+
64
+
notifications, err := db.GetNotificationsWithEntities(
65
+
n.db,
66
+
page,
67
+
db.FilterEq("recipient_did", user.Did),
68
+
)
69
+
if err != nil {
70
+
l.Error("failed to get notifications", "err", err)
71
+
n.pages.Error500(w)
72
+
return
73
+
}
74
+
75
+
err = db.MarkAllNotificationsRead(n.db, user.Did)
76
+
if err != nil {
77
+
l.Error("failed to mark notifications as read", "err", err)
78
+
}
79
+
80
+
unreadCount := 0
81
+
82
+
n.pages.Notifications(w, pages.NotificationsParams{
83
+
LoggedInUser: user,
84
+
Notifications: notifications,
85
+
UnreadCount: unreadCount,
86
+
Page: page,
87
+
Total: total,
88
+
})
89
+
}
90
+
91
+
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
92
+
user := n.oauth.GetUser(r)
93
+
if user == nil {
94
+
return
95
+
}
96
+
97
+
count, err := db.CountNotifications(
98
+
n.db,
99
+
db.FilterEq("recipient_did", user.Did),
100
+
db.FilterEq("read", 0),
101
+
)
102
+
if err != nil {
103
+
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
104
+
return
105
+
}
106
+
107
+
params := pages.NotificationCountParams{
108
+
Count: count,
109
+
}
110
+
err = n.pages.NotificationCount(w, params)
111
+
if err != nil {
112
+
http.Error(w, "Failed to render count", http.StatusInternalServerError)
113
+
return
114
+
}
115
+
}
116
+
117
+
func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
118
+
userDid := n.oauth.GetDid(r)
119
+
120
+
idStr := chi.URLParam(r, "id")
121
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
122
+
if err != nil {
123
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
124
+
return
125
+
}
126
+
127
+
err = db.MarkNotificationRead(n.db, notificationID, userDid)
128
+
if err != nil {
129
+
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
130
+
return
131
+
}
132
+
133
+
w.WriteHeader(http.StatusNoContent)
134
+
}
135
+
136
+
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
137
+
userDid := n.oauth.GetDid(r)
138
+
139
+
err := db.MarkAllNotificationsRead(n.db, userDid)
140
+
if err != nil {
141
+
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
142
+
return
143
+
}
144
+
145
+
http.Redirect(w, r, "/notifications", http.StatusSeeOther)
146
+
}
147
+
148
+
func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
149
+
userDid := n.oauth.GetDid(r)
150
+
151
+
idStr := chi.URLParam(r, "id")
152
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
153
+
if err != nil {
154
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
155
+
return
156
+
}
157
+
158
+
err = db.DeleteNotification(n.db, notificationID, userDid)
159
+
if err != nil {
160
+
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
161
+
return
162
+
}
163
+
164
+
w.WriteHeader(http.StatusOK)
165
+
}
+489
appview/notify/db/db.go
+489
appview/notify/db/db.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
"maps"
7
+
"slices"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/models"
12
+
"tangled.org/core/appview/notify"
13
+
"tangled.org/core/idresolver"
14
+
)
15
+
16
+
const (
17
+
maxMentions = 5
18
+
)
19
+
20
+
type databaseNotifier struct {
21
+
db *db.DB
22
+
res *idresolver.Resolver
23
+
}
24
+
25
+
func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
26
+
return &databaseNotifier{
27
+
db: database,
28
+
res: resolver,
29
+
}
30
+
}
31
+
32
+
var _ notify.Notifier = &databaseNotifier{}
33
+
34
+
func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
35
+
// no-op for now
36
+
}
37
+
38
+
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
39
+
var err error
40
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
41
+
if err != nil {
42
+
log.Printf("NewStar: failed to get repos: %v", err)
43
+
return
44
+
}
45
+
46
+
actorDid := syntax.DID(star.StarredByDid)
47
+
recipients := []syntax.DID{syntax.DID(repo.Did)}
48
+
eventType := models.NotificationTypeRepoStarred
49
+
entityType := "repo"
50
+
entityId := star.RepoAt.String()
51
+
repoId := &repo.Id
52
+
var issueId *int64
53
+
var pullId *int64
54
+
55
+
n.notifyEvent(
56
+
actorDid,
57
+
recipients,
58
+
eventType,
59
+
entityType,
60
+
entityId,
61
+
repoId,
62
+
issueId,
63
+
pullId,
64
+
)
65
+
}
66
+
67
+
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
68
+
// no-op
69
+
}
70
+
71
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
72
+
73
+
// build the recipients list
74
+
// - owner of the repo
75
+
// - collaborators in the repo
76
+
var recipients []syntax.DID
77
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
78
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
79
+
if err != nil {
80
+
log.Printf("failed to fetch collaborators: %v", err)
81
+
return
82
+
}
83
+
for _, c := range collaborators {
84
+
recipients = append(recipients, c.SubjectDid)
85
+
}
86
+
87
+
actorDid := syntax.DID(issue.Did)
88
+
entityType := "issue"
89
+
entityId := issue.AtUri().String()
90
+
repoId := &issue.Repo.Id
91
+
issueId := &issue.Id
92
+
var pullId *int64
93
+
94
+
n.notifyEvent(
95
+
actorDid,
96
+
recipients,
97
+
models.NotificationTypeIssueCreated,
98
+
entityType,
99
+
entityId,
100
+
repoId,
101
+
issueId,
102
+
pullId,
103
+
)
104
+
n.notifyEvent(
105
+
actorDid,
106
+
mentions,
107
+
models.NotificationTypeUserMentioned,
108
+
entityType,
109
+
entityId,
110
+
repoId,
111
+
issueId,
112
+
pullId,
113
+
)
114
+
}
115
+
116
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
117
+
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
118
+
if err != nil {
119
+
log.Printf("NewIssueComment: failed to get issues: %v", err)
120
+
return
121
+
}
122
+
if len(issues) == 0 {
123
+
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
124
+
return
125
+
}
126
+
issue := issues[0]
127
+
128
+
var recipients []syntax.DID
129
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
130
+
131
+
if comment.IsReply() {
132
+
// if this comment is a reply, then notify everybody in that thread
133
+
parentAtUri := *comment.ReplyTo
134
+
allThreads := issue.CommentList()
135
+
136
+
// find the parent thread, and add all DIDs from here to the recipient list
137
+
for _, t := range allThreads {
138
+
if t.Self.AtUri().String() == parentAtUri {
139
+
recipients = append(recipients, t.Participants()...)
140
+
}
141
+
}
142
+
} else {
143
+
// not a reply, notify just the issue author
144
+
recipients = append(recipients, syntax.DID(issue.Did))
145
+
}
146
+
147
+
actorDid := syntax.DID(comment.Did)
148
+
entityType := "issue"
149
+
entityId := issue.AtUri().String()
150
+
repoId := &issue.Repo.Id
151
+
issueId := &issue.Id
152
+
var pullId *int64
153
+
154
+
n.notifyEvent(
155
+
actorDid,
156
+
recipients,
157
+
models.NotificationTypeIssueCommented,
158
+
entityType,
159
+
entityId,
160
+
repoId,
161
+
issueId,
162
+
pullId,
163
+
)
164
+
n.notifyEvent(
165
+
actorDid,
166
+
mentions,
167
+
models.NotificationTypeUserMentioned,
168
+
entityType,
169
+
entityId,
170
+
repoId,
171
+
issueId,
172
+
pullId,
173
+
)
174
+
}
175
+
176
+
func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
177
+
// no-op for now
178
+
}
179
+
180
+
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
181
+
actorDid := syntax.DID(follow.UserDid)
182
+
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
183
+
eventType := models.NotificationTypeFollowed
184
+
entityType := "follow"
185
+
entityId := follow.UserDid
186
+
var repoId, issueId, pullId *int64
187
+
188
+
n.notifyEvent(
189
+
actorDid,
190
+
recipients,
191
+
eventType,
192
+
entityType,
193
+
entityId,
194
+
repoId,
195
+
issueId,
196
+
pullId,
197
+
)
198
+
}
199
+
200
+
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
201
+
// no-op
202
+
}
203
+
204
+
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
205
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
206
+
if err != nil {
207
+
log.Printf("NewPull: failed to get repos: %v", err)
208
+
return
209
+
}
210
+
211
+
// build the recipients list
212
+
// - owner of the repo
213
+
// - collaborators in the repo
214
+
var recipients []syntax.DID
215
+
recipients = append(recipients, syntax.DID(repo.Did))
216
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
217
+
if err != nil {
218
+
log.Printf("failed to fetch collaborators: %v", err)
219
+
return
220
+
}
221
+
for _, c := range collaborators {
222
+
recipients = append(recipients, c.SubjectDid)
223
+
}
224
+
225
+
actorDid := syntax.DID(pull.OwnerDid)
226
+
eventType := models.NotificationTypePullCreated
227
+
entityType := "pull"
228
+
entityId := pull.AtUri().String()
229
+
repoId := &repo.Id
230
+
var issueId *int64
231
+
p := int64(pull.ID)
232
+
pullId := &p
233
+
234
+
n.notifyEvent(
235
+
actorDid,
236
+
recipients,
237
+
eventType,
238
+
entityType,
239
+
entityId,
240
+
repoId,
241
+
issueId,
242
+
pullId,
243
+
)
244
+
}
245
+
246
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
247
+
pull, err := db.GetPull(n.db,
248
+
syntax.ATURI(comment.RepoAt),
249
+
comment.PullId,
250
+
)
251
+
if err != nil {
252
+
log.Printf("NewPullComment: failed to get pulls: %v", err)
253
+
return
254
+
}
255
+
256
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
257
+
if err != nil {
258
+
log.Printf("NewPullComment: failed to get repos: %v", err)
259
+
return
260
+
}
261
+
262
+
// build up the recipients list:
263
+
// - repo owner
264
+
// - all pull participants
265
+
var recipients []syntax.DID
266
+
recipients = append(recipients, syntax.DID(repo.Did))
267
+
for _, p := range pull.Participants() {
268
+
recipients = append(recipients, syntax.DID(p))
269
+
}
270
+
271
+
actorDid := syntax.DID(comment.OwnerDid)
272
+
eventType := models.NotificationTypePullCommented
273
+
entityType := "pull"
274
+
entityId := pull.AtUri().String()
275
+
repoId := &repo.Id
276
+
var issueId *int64
277
+
p := int64(pull.ID)
278
+
pullId := &p
279
+
280
+
n.notifyEvent(
281
+
actorDid,
282
+
recipients,
283
+
eventType,
284
+
entityType,
285
+
entityId,
286
+
repoId,
287
+
issueId,
288
+
pullId,
289
+
)
290
+
n.notifyEvent(
291
+
actorDid,
292
+
mentions,
293
+
models.NotificationTypeUserMentioned,
294
+
entityType,
295
+
entityId,
296
+
repoId,
297
+
issueId,
298
+
pullId,
299
+
)
300
+
}
301
+
302
+
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
303
+
// no-op
304
+
}
305
+
306
+
func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
307
+
// no-op
308
+
}
309
+
310
+
func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
311
+
// no-op
312
+
}
313
+
314
+
func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
315
+
// no-op
316
+
}
317
+
318
+
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
319
+
// build up the recipients list:
320
+
// - repo owner
321
+
// - repo collaborators
322
+
// - all issue participants
323
+
var recipients []syntax.DID
324
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
325
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
326
+
if err != nil {
327
+
log.Printf("failed to fetch collaborators: %v", err)
328
+
return
329
+
}
330
+
for _, c := range collaborators {
331
+
recipients = append(recipients, c.SubjectDid)
332
+
}
333
+
for _, p := range issue.Participants() {
334
+
recipients = append(recipients, syntax.DID(p))
335
+
}
336
+
337
+
entityType := "pull"
338
+
entityId := issue.AtUri().String()
339
+
repoId := &issue.Repo.Id
340
+
issueId := &issue.Id
341
+
var pullId *int64
342
+
var eventType models.NotificationType
343
+
344
+
if issue.Open {
345
+
eventType = models.NotificationTypeIssueReopen
346
+
} else {
347
+
eventType = models.NotificationTypeIssueClosed
348
+
}
349
+
350
+
n.notifyEvent(
351
+
actor,
352
+
recipients,
353
+
eventType,
354
+
entityType,
355
+
entityId,
356
+
repoId,
357
+
issueId,
358
+
pullId,
359
+
)
360
+
}
361
+
362
+
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
363
+
// Get repo details
364
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
365
+
if err != nil {
366
+
log.Printf("NewPullState: failed to get repos: %v", err)
367
+
return
368
+
}
369
+
370
+
// build up the recipients list:
371
+
// - repo owner
372
+
// - all pull participants
373
+
var recipients []syntax.DID
374
+
recipients = append(recipients, syntax.DID(repo.Did))
375
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
376
+
if err != nil {
377
+
log.Printf("failed to fetch collaborators: %v", err)
378
+
return
379
+
}
380
+
for _, c := range collaborators {
381
+
recipients = append(recipients, c.SubjectDid)
382
+
}
383
+
for _, p := range pull.Participants() {
384
+
recipients = append(recipients, syntax.DID(p))
385
+
}
386
+
387
+
entityType := "pull"
388
+
entityId := pull.AtUri().String()
389
+
repoId := &repo.Id
390
+
var issueId *int64
391
+
var eventType models.NotificationType
392
+
switch pull.State {
393
+
case models.PullClosed:
394
+
eventType = models.NotificationTypePullClosed
395
+
case models.PullOpen:
396
+
eventType = models.NotificationTypePullReopen
397
+
case models.PullMerged:
398
+
eventType = models.NotificationTypePullMerged
399
+
default:
400
+
log.Println("NewPullState: unexpected new PR state:", pull.State)
401
+
return
402
+
}
403
+
p := int64(pull.ID)
404
+
pullId := &p
405
+
406
+
n.notifyEvent(
407
+
actor,
408
+
recipients,
409
+
eventType,
410
+
entityType,
411
+
entityId,
412
+
repoId,
413
+
issueId,
414
+
pullId,
415
+
)
416
+
}
417
+
418
+
func (n *databaseNotifier) notifyEvent(
419
+
actorDid syntax.DID,
420
+
recipients []syntax.DID,
421
+
eventType models.NotificationType,
422
+
entityType string,
423
+
entityId string,
424
+
repoId *int64,
425
+
issueId *int64,
426
+
pullId *int64,
427
+
) {
428
+
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
429
+
recipients = recipients[:maxMentions]
430
+
}
431
+
recipientSet := make(map[syntax.DID]struct{})
432
+
for _, did := range recipients {
433
+
// everybody except actor themselves
434
+
if did != actorDid {
435
+
recipientSet[did] = struct{}{}
436
+
}
437
+
}
438
+
439
+
prefMap, err := db.GetNotificationPreferences(
440
+
n.db,
441
+
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
442
+
)
443
+
if err != nil {
444
+
// failed to get prefs for users
445
+
return
446
+
}
447
+
448
+
// create a transaction for bulk notification storage
449
+
tx, err := n.db.Begin()
450
+
if err != nil {
451
+
// failed to start tx
452
+
return
453
+
}
454
+
defer tx.Rollback()
455
+
456
+
// filter based on preferences
457
+
for recipientDid := range recipientSet {
458
+
prefs, ok := prefMap[recipientDid]
459
+
if !ok {
460
+
prefs = models.DefaultNotificationPreferences(recipientDid)
461
+
}
462
+
463
+
// skip users who don’t want this type
464
+
if !prefs.ShouldNotify(eventType) {
465
+
continue
466
+
}
467
+
468
+
// create notification
469
+
notif := &models.Notification{
470
+
RecipientDid: recipientDid.String(),
471
+
ActorDid: actorDid.String(),
472
+
Type: eventType,
473
+
EntityType: entityType,
474
+
EntityId: entityId,
475
+
RepoId: repoId,
476
+
IssueId: issueId,
477
+
PullId: pullId,
478
+
}
479
+
480
+
if err := db.CreateNotification(tx, notif); err != nil {
481
+
log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err)
482
+
}
483
+
}
484
+
485
+
if err := tx.Commit(); err != nil {
486
+
// failed to commit
487
+
return
488
+
}
489
+
}
+63
-42
appview/notify/merged_notifier.go
+63
-42
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
6
+
"reflect"
7
+
"sync"
5
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/log"
7
12
)
8
13
9
14
type mergedNotifier struct {
10
15
notifiers []Notifier
16
+
logger *slog.Logger
11
17
}
12
18
13
-
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
-
return &mergedNotifier{notifiers}
19
+
func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier {
20
+
return &mergedNotifier{notifiers, logger}
15
21
}
16
22
17
23
var _ Notifier = &mergedNotifier{}
18
24
25
+
// fanout calls the same method on all notifiers concurrently
26
+
func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) {
27
+
ctx = log.IntoContext(ctx, m.logger.With("method", method))
28
+
var wg sync.WaitGroup
29
+
for _, n := range m.notifiers {
30
+
wg.Add(1)
31
+
go func(notifier Notifier) {
32
+
defer wg.Done()
33
+
v := reflect.ValueOf(notifier).MethodByName(method)
34
+
in := make([]reflect.Value, len(args)+1)
35
+
in[0] = reflect.ValueOf(ctx)
36
+
for i, arg := range args {
37
+
in[i+1] = reflect.ValueOf(arg)
38
+
}
39
+
v.Call(in)
40
+
}(n)
41
+
}
42
+
wg.Wait()
43
+
}
44
+
19
45
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
22
-
}
46
+
m.fanout("NewRepo", ctx, repo)
23
47
}
24
48
25
49
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
50
+
m.fanout("NewStar", ctx, star)
29
51
}
52
+
30
53
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
54
+
m.fanout("DeleteStar", ctx, star)
55
+
}
56
+
57
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
+
m.fanout("NewIssue", ctx, issue, mentions)
59
+
}
60
+
61
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
+
m.fanout("NewIssueComment", ctx, comment, mentions)
63
+
}
64
+
65
+
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
66
+
m.fanout("NewIssueState", ctx, actor, issue)
34
67
}
35
68
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
69
+
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
70
+
m.fanout("DeleteIssue", ctx, issue)
40
71
}
41
72
42
73
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
43
-
for _, notifier := range m.notifiers {
44
-
notifier.NewFollow(ctx, follow)
45
-
}
74
+
m.fanout("NewFollow", ctx, follow)
46
75
}
76
+
47
77
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.DeleteFollow(ctx, follow)
50
-
}
78
+
m.fanout("DeleteFollow", ctx, follow)
51
79
}
52
80
53
81
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewPull(ctx, pull)
56
-
}
82
+
m.fanout("NewPull", ctx, pull)
57
83
}
58
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.NewPullComment(ctx, comment)
61
-
}
84
+
85
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
+
m.fanout("NewPullComment", ctx, comment, mentions)
87
+
}
88
+
89
+
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
90
+
m.fanout("NewPullState", ctx, actor, pull)
62
91
}
63
92
64
93
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
65
-
for _, notifier := range m.notifiers {
66
-
notifier.UpdateProfile(ctx, profile)
67
-
}
94
+
m.fanout("UpdateProfile", ctx, profile)
68
95
}
69
96
70
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
71
-
for _, notifier := range m.notifiers {
72
-
notifier.NewString(ctx, string)
73
-
}
97
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
98
+
m.fanout("NewString", ctx, s)
74
99
}
75
100
76
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
77
-
for _, notifier := range m.notifiers {
78
-
notifier.EditString(ctx, string)
79
-
}
101
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
102
+
m.fanout("EditString", ctx, s)
80
103
}
81
104
82
105
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
83
-
for _, notifier := range m.notifiers {
84
-
notifier.DeleteString(ctx, did, rkey)
85
-
}
106
+
m.fanout("DeleteString", ctx, did, rkey)
86
107
}
+16
-5
appview/notify/notifier.go
+16
-5
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
7
"tangled.org/core/appview/models"
7
8
)
8
9
···
12
13
NewStar(ctx context.Context, star *models.Star)
13
14
DeleteStar(ctx context.Context, star *models.Star)
14
15
15
-
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
+
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
+
DeleteIssue(ctx context.Context, issue *models.Issue)
16
20
17
21
NewFollow(ctx context.Context, follow *models.Follow)
18
22
DeleteFollow(ctx context.Context, follow *models.Follow)
19
23
20
24
NewPull(ctx context.Context, pull *models.Pull)
21
-
NewPullComment(ctx context.Context, comment *models.PullComment)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
+
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
22
27
23
28
UpdateProfile(ctx context.Context, profile *models.Profile)
24
29
···
37
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
38
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
39
44
40
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
46
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
47
+
}
48
+
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
49
+
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
41
50
42
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
43
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
44
53
45
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
46
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
54
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
55
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) {
56
+
}
57
+
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
47
58
48
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
49
60
+243
appview/notify/posthog/notifier.go
+243
appview/notify/posthog/notifier.go
···
1
+
package posthog
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/posthog/posthog-go"
9
+
"tangled.org/core/appview/models"
10
+
"tangled.org/core/appview/notify"
11
+
)
12
+
13
+
type posthogNotifier struct {
14
+
client posthog.Client
15
+
notify.BaseNotifier
16
+
}
17
+
18
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
19
+
return &posthogNotifier{
20
+
client,
21
+
notify.BaseNotifier{},
22
+
}
23
+
}
24
+
25
+
var _ notify.Notifier = &posthogNotifier{}
26
+
27
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
28
+
err := n.client.Enqueue(posthog.Capture{
29
+
DistinctId: repo.Did,
30
+
Event: "new_repo",
31
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
32
+
})
33
+
if err != nil {
34
+
log.Println("failed to enqueue posthog event:", err)
35
+
}
36
+
}
37
+
38
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
39
+
err := n.client.Enqueue(posthog.Capture{
40
+
DistinctId: star.StarredByDid,
41
+
Event: "star",
42
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
43
+
})
44
+
if err != nil {
45
+
log.Println("failed to enqueue posthog event:", err)
46
+
}
47
+
}
48
+
49
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
50
+
err := n.client.Enqueue(posthog.Capture{
51
+
DistinctId: star.StarredByDid,
52
+
Event: "unstar",
53
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
54
+
})
55
+
if err != nil {
56
+
log.Println("failed to enqueue posthog event:", err)
57
+
}
58
+
}
59
+
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
61
+
err := n.client.Enqueue(posthog.Capture{
62
+
DistinctId: issue.Did,
63
+
Event: "new_issue",
64
+
Properties: posthog.Properties{
65
+
"repo_at": issue.RepoAt.String(),
66
+
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
68
+
},
69
+
})
70
+
if err != nil {
71
+
log.Println("failed to enqueue posthog event:", err)
72
+
}
73
+
}
74
+
75
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
76
+
err := n.client.Enqueue(posthog.Capture{
77
+
DistinctId: pull.OwnerDid,
78
+
Event: "new_pull",
79
+
Properties: posthog.Properties{
80
+
"repo_at": pull.RepoAt,
81
+
"pull_id": pull.PullId,
82
+
},
83
+
})
84
+
if err != nil {
85
+
log.Println("failed to enqueue posthog event:", err)
86
+
}
87
+
}
88
+
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
90
+
err := n.client.Enqueue(posthog.Capture{
91
+
DistinctId: comment.OwnerDid,
92
+
Event: "new_pull_comment",
93
+
Properties: posthog.Properties{
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
97
+
},
98
+
})
99
+
if err != nil {
100
+
log.Println("failed to enqueue posthog event:", err)
101
+
}
102
+
}
103
+
104
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
105
+
err := n.client.Enqueue(posthog.Capture{
106
+
DistinctId: pull.OwnerDid,
107
+
Event: "pull_closed",
108
+
Properties: posthog.Properties{
109
+
"repo_at": pull.RepoAt,
110
+
"pull_id": pull.PullId,
111
+
},
112
+
})
113
+
if err != nil {
114
+
log.Println("failed to enqueue posthog event:", err)
115
+
}
116
+
}
117
+
118
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
119
+
err := n.client.Enqueue(posthog.Capture{
120
+
DistinctId: follow.UserDid,
121
+
Event: "follow",
122
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
123
+
})
124
+
if err != nil {
125
+
log.Println("failed to enqueue posthog event:", err)
126
+
}
127
+
}
128
+
129
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
130
+
err := n.client.Enqueue(posthog.Capture{
131
+
DistinctId: follow.UserDid,
132
+
Event: "unfollow",
133
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
134
+
})
135
+
if err != nil {
136
+
log.Println("failed to enqueue posthog event:", err)
137
+
}
138
+
}
139
+
140
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
141
+
err := n.client.Enqueue(posthog.Capture{
142
+
DistinctId: profile.Did,
143
+
Event: "edit_profile",
144
+
})
145
+
if err != nil {
146
+
log.Println("failed to enqueue posthog event:", err)
147
+
}
148
+
}
149
+
150
+
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
151
+
err := n.client.Enqueue(posthog.Capture{
152
+
DistinctId: did,
153
+
Event: "delete_string",
154
+
Properties: posthog.Properties{"rkey": rkey},
155
+
})
156
+
if err != nil {
157
+
log.Println("failed to enqueue posthog event:", err)
158
+
}
159
+
}
160
+
161
+
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
162
+
err := n.client.Enqueue(posthog.Capture{
163
+
DistinctId: string.Did.String(),
164
+
Event: "edit_string",
165
+
Properties: posthog.Properties{"rkey": string.Rkey},
166
+
})
167
+
if err != nil {
168
+
log.Println("failed to enqueue posthog event:", err)
169
+
}
170
+
}
171
+
172
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
173
+
err := n.client.Enqueue(posthog.Capture{
174
+
DistinctId: string.Did.String(),
175
+
Event: "new_string",
176
+
Properties: posthog.Properties{"rkey": string.Rkey},
177
+
})
178
+
if err != nil {
179
+
log.Println("failed to enqueue posthog event:", err)
180
+
}
181
+
}
182
+
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
184
+
err := n.client.Enqueue(posthog.Capture{
185
+
DistinctId: comment.Did,
186
+
Event: "new_issue_comment",
187
+
Properties: posthog.Properties{
188
+
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
190
+
},
191
+
})
192
+
if err != nil {
193
+
log.Println("failed to enqueue posthog event:", err)
194
+
}
195
+
}
196
+
197
+
func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
198
+
var event string
199
+
if issue.Open {
200
+
event = "issue_reopen"
201
+
} else {
202
+
event = "issue_closed"
203
+
}
204
+
err := n.client.Enqueue(posthog.Capture{
205
+
DistinctId: issue.Did,
206
+
Event: event,
207
+
Properties: posthog.Properties{
208
+
"repo_at": issue.RepoAt.String(),
209
+
"actor": actor,
210
+
"issue_id": issue.IssueId,
211
+
},
212
+
})
213
+
if err != nil {
214
+
log.Println("failed to enqueue posthog event:", err)
215
+
}
216
+
}
217
+
218
+
func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
219
+
var event string
220
+
switch pull.State {
221
+
case models.PullClosed:
222
+
event = "pull_closed"
223
+
case models.PullOpen:
224
+
event = "pull_reopen"
225
+
case models.PullMerged:
226
+
event = "pull_merged"
227
+
default:
228
+
log.Println("posthog: unexpected new PR state:", pull.State)
229
+
return
230
+
}
231
+
err := n.client.Enqueue(posthog.Capture{
232
+
DistinctId: pull.OwnerDid,
233
+
Event: event,
234
+
Properties: posthog.Properties{
235
+
"repo_at": pull.RepoAt,
236
+
"pull_id": pull.PullId,
237
+
"actor": actor,
238
+
},
239
+
})
240
+
if err != nil {
241
+
log.Println("failed to enqueue posthog event:", err)
242
+
}
243
+
}
-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
-
}
+278
appview/oauth/handler.go
+278
appview/oauth/handler.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"errors"
8
+
"fmt"
9
+
"net/http"
10
+
"slices"
11
+
"time"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
14
+
"github.com/go-chi/chi/v5"
15
+
"github.com/posthog/posthog-go"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/consts"
19
+
"tangled.org/core/tid"
20
+
)
21
+
22
+
func (o *OAuth) Router() http.Handler {
23
+
r := chi.NewRouter()
24
+
25
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
26
+
r.Get("/oauth/jwks.json", o.jwks)
27
+
r.Get("/oauth/callback", o.callback)
28
+
return r
29
+
}
30
+
31
+
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
32
+
doc := o.ClientApp.Config.ClientMetadata()
33
+
doc.JWKSURI = &o.JwksUri
34
+
doc.ClientName = &o.ClientName
35
+
doc.ClientURI = &o.ClientUri
36
+
37
+
w.Header().Set("Content-Type", "application/json")
38
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
39
+
http.Error(w, err.Error(), http.StatusInternalServerError)
40
+
return
41
+
}
42
+
}
43
+
44
+
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
45
+
w.Header().Set("Content-Type", "application/json")
46
+
body := o.ClientApp.Config.PublicJWKS()
47
+
if err := json.NewEncoder(w).Encode(body); err != nil {
48
+
http.Error(w, err.Error(), http.StatusInternalServerError)
49
+
return
50
+
}
51
+
}
52
+
53
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
54
+
ctx := r.Context()
55
+
l := o.Logger.With("query", r.URL.Query())
56
+
57
+
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
58
+
if err != nil {
59
+
var callbackErr *oauth.AuthRequestCallbackError
60
+
if errors.As(err, &callbackErr) {
61
+
l.Debug("callback error", "err", callbackErr)
62
+
http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
63
+
return
64
+
}
65
+
l.Error("failed to process callback", "err", err)
66
+
http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
67
+
return
68
+
}
69
+
70
+
if err := o.SaveSession(w, r, sessData); err != nil {
71
+
l.Error("failed to save session", "data", sessData, "err", err)
72
+
http.Redirect(w, r, "/login?error=session", http.StatusFound)
73
+
return
74
+
}
75
+
76
+
o.Logger.Debug("session saved successfully")
77
+
go o.addToDefaultKnot(sessData.AccountDID.String())
78
+
go o.addToDefaultSpindle(sessData.AccountDID.String())
79
+
80
+
if !o.Config.Core.Dev {
81
+
err = o.Posthog.Enqueue(posthog.Capture{
82
+
DistinctId: sessData.AccountDID.String(),
83
+
Event: "signin",
84
+
})
85
+
if err != nil {
86
+
o.Logger.Error("failed to enqueue posthog event", "err", err)
87
+
}
88
+
}
89
+
90
+
http.Redirect(w, r, "/", http.StatusFound)
91
+
}
92
+
93
+
func (o *OAuth) addToDefaultSpindle(did string) {
94
+
l := o.Logger.With("subject", did)
95
+
96
+
// use the tangled.sh app password to get an accessJwt
97
+
// and create an sh.tangled.spindle.member record with that
98
+
spindleMembers, err := db.GetSpindleMembers(
99
+
o.Db,
100
+
db.FilterEq("instance", "spindle.tangled.sh"),
101
+
db.FilterEq("subject", did),
102
+
)
103
+
if err != nil {
104
+
l.Error("failed to get spindle members", "err", err)
105
+
return
106
+
}
107
+
108
+
if len(spindleMembers) != 0 {
109
+
l.Warn("already a member of the default spindle")
110
+
return
111
+
}
112
+
113
+
l.Debug("adding to default spindle")
114
+
session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid)
115
+
if err != nil {
116
+
l.Error("failed to create session", "err", err)
117
+
return
118
+
}
119
+
120
+
record := tangled.SpindleMember{
121
+
LexiconTypeID: "sh.tangled.spindle.member",
122
+
Subject: did,
123
+
Instance: consts.DefaultSpindle,
124
+
CreatedAt: time.Now().Format(time.RFC3339),
125
+
}
126
+
127
+
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
128
+
l.Error("failed to add to default spindle", "err", err)
129
+
return
130
+
}
131
+
132
+
l.Debug("successfully added to default spindle", "did", did)
133
+
}
134
+
135
+
func (o *OAuth) addToDefaultKnot(did string) {
136
+
l := o.Logger.With("subject", did)
137
+
138
+
// use the tangled.sh app password to get an accessJwt
139
+
// and create an sh.tangled.spindle.member record with that
140
+
141
+
allKnots, err := o.Enforcer.GetKnotsForUser(did)
142
+
if err != nil {
143
+
l.Error("failed to get knot members for did", "err", err)
144
+
return
145
+
}
146
+
147
+
if slices.Contains(allKnots, consts.DefaultKnot) {
148
+
l.Warn("already a member of the default knot")
149
+
return
150
+
}
151
+
152
+
l.Debug("addings to default knot")
153
+
session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid)
154
+
if err != nil {
155
+
l.Error("failed to create session", "err", err)
156
+
return
157
+
}
158
+
159
+
record := tangled.KnotMember{
160
+
LexiconTypeID: "sh.tangled.knot.member",
161
+
Subject: did,
162
+
Domain: consts.DefaultKnot,
163
+
CreatedAt: time.Now().Format(time.RFC3339),
164
+
}
165
+
166
+
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
167
+
l.Error("failed to add to default knot", "err", err)
168
+
return
169
+
}
170
+
171
+
if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
172
+
l.Error("failed to set up enforcer rules", "err", err)
173
+
return
174
+
}
175
+
176
+
l.Debug("successfully addeds to default Knot")
177
+
}
178
+
179
+
// create a session using apppasswords
180
+
type session struct {
181
+
AccessJwt string `json:"accessJwt"`
182
+
PdsEndpoint string
183
+
Did string
184
+
}
185
+
186
+
func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) {
187
+
if appPassword == "" {
188
+
return nil, fmt.Errorf("no app password configured, skipping member addition")
189
+
}
190
+
191
+
resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
192
+
if err != nil {
193
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
194
+
}
195
+
196
+
pdsEndpoint := resolved.PDSEndpoint()
197
+
if pdsEndpoint == "" {
198
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
199
+
}
200
+
201
+
sessionPayload := map[string]string{
202
+
"identifier": did,
203
+
"password": appPassword,
204
+
}
205
+
sessionBytes, err := json.Marshal(sessionPayload)
206
+
if err != nil {
207
+
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
208
+
}
209
+
210
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
211
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
212
+
if err != nil {
213
+
return nil, fmt.Errorf("failed to create session request: %v", err)
214
+
}
215
+
sessionReq.Header.Set("Content-Type", "application/json")
216
+
217
+
client := &http.Client{Timeout: 30 * time.Second}
218
+
sessionResp, err := client.Do(sessionReq)
219
+
if err != nil {
220
+
return nil, fmt.Errorf("failed to create session: %v", err)
221
+
}
222
+
defer sessionResp.Body.Close()
223
+
224
+
if sessionResp.StatusCode != http.StatusOK {
225
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
226
+
}
227
+
228
+
var session session
229
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
230
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
231
+
}
232
+
233
+
session.PdsEndpoint = pdsEndpoint
234
+
session.Did = did
235
+
236
+
return &session, nil
237
+
}
238
+
239
+
func (s *session) putRecord(record any, collection string) error {
240
+
recordBytes, err := json.Marshal(record)
241
+
if err != nil {
242
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
243
+
}
244
+
245
+
payload := map[string]any{
246
+
"repo": s.Did,
247
+
"collection": collection,
248
+
"rkey": tid.TID(),
249
+
"record": json.RawMessage(recordBytes),
250
+
}
251
+
252
+
payloadBytes, err := json.Marshal(payload)
253
+
if err != nil {
254
+
return fmt.Errorf("failed to marshal request payload: %w", err)
255
+
}
256
+
257
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
258
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
259
+
if err != nil {
260
+
return fmt.Errorf("failed to create HTTP request: %w", err)
261
+
}
262
+
263
+
req.Header.Set("Content-Type", "application/json")
264
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
265
+
266
+
client := &http.Client{Timeout: 30 * time.Second}
267
+
resp, err := client.Do(req)
268
+
if err != nil {
269
+
return fmt.Errorf("failed to add user to default service: %w", err)
270
+
}
271
+
defer resp.Body.Close()
272
+
273
+
if resp.StatusCode != http.StatusOK {
274
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
275
+
}
276
+
277
+
return nil
278
+
}
+136
-203
appview/oauth/oauth.go
+136
-203
appview/oauth/oauth.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
-
"log"
6
+
"log/slog"
6
7
"net/http"
7
-
"net/url"
8
8
"time"
9
9
10
-
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
10
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
13
+
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
xrpc "github.com/bluesky-social/indigo/xrpc"
11
16
"github.com/gorilla/sessions"
12
-
sessioncache "tangled.org/core/appview/cache/session"
17
+
"github.com/posthog/posthog-go"
13
18
"tangled.org/core/appview/config"
14
-
"tangled.org/core/appview/oauth/client"
15
-
xrpc "tangled.org/core/appview/xrpcclient"
16
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
17
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
18
22
)
19
23
20
24
type OAuth struct {
21
-
store *sessions.CookieStore
22
-
config *config.Config
23
-
sess *sessioncache.SessionStore
25
+
ClientApp *oauth.ClientApp
26
+
SessStore *sessions.CookieStore
27
+
Config *config.Config
28
+
JwksUri string
29
+
ClientName string
30
+
ClientUri string
31
+
Posthog posthog.Client
32
+
Db *db.DB
33
+
Enforcer *rbac.Enforcer
34
+
IdResolver *idresolver.Resolver
35
+
Logger *slog.Logger
24
36
}
25
37
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,
38
+
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
39
+
var oauthConfig oauth.ClientConfig
40
+
var clientUri string
41
+
if config.Core.Dev {
42
+
clientUri = "http://127.0.0.1:3000"
43
+
callbackUri := clientUri + "/oauth/callback"
44
+
oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"})
45
+
} else {
46
+
clientUri = config.Core.AppviewHost
47
+
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
48
+
callbackUri := clientUri + "/oauth/callback"
49
+
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
31
50
}
32
-
}
33
51
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)
52
+
// configure client secret
53
+
priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
41
54
if err != nil {
42
-
return err
55
+
return nil, err
56
+
}
57
+
if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
58
+
return nil, err
43
59
}
44
60
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)
61
+
jwksUri := clientUri + "/oauth/jwks.json"
62
+
63
+
authStore, err := NewRedisStore(&RedisStoreConfig{
64
+
RedisURL: config.Redis.ToURL(),
65
+
SessionExpiryDuration: time.Hour * 24 * 90,
66
+
SessionInactivityDuration: time.Hour * 24 * 14,
67
+
AuthRequestExpiryDuration: time.Minute * 30,
68
+
})
50
69
if err != nil {
51
-
return fmt.Errorf("error saving user session: %w", err)
70
+
return nil, err
52
71
}
53
72
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),
73
+
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
74
+
75
+
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
76
+
clientApp.Dir = res.Directory()
77
+
// allow non-public transports in dev mode
78
+
if config.Core.Dev {
79
+
clientApp.Resolver.Client.Transport = http.DefaultTransport
65
80
}
66
81
67
-
return o.sess.SaveSession(r.Context(), session)
82
+
clientName := config.Core.AppviewName
83
+
84
+
logger.Info("oauth setup successfully", "IsConfidential", clientApp.Config.IsConfidential())
85
+
return &OAuth{
86
+
ClientApp: clientApp,
87
+
Config: config,
88
+
SessStore: sessStore,
89
+
JwksUri: jwksUri,
90
+
ClientName: clientName,
91
+
ClientUri: clientUri,
92
+
Posthog: ph,
93
+
Db: db,
94
+
Enforcer: enforcer,
95
+
IdResolver: res,
96
+
Logger: logger,
97
+
}, nil
68
98
}
69
99
70
-
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
71
-
userSession, err := o.store.Get(r, SessionName)
72
-
if err != nil || userSession.IsNew {
73
-
return fmt.Errorf("error getting user session (or new session?): %w", err)
100
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
101
+
// first we save the did in the user session
102
+
userSession, err := o.SessStore.Get(r, SessionName)
103
+
if err != nil {
104
+
return err
74
105
}
75
106
76
-
did := userSession.Values[SessionDid].(string)
107
+
userSession.Values[SessionDid] = sessData.AccountDID.String()
108
+
userSession.Values[SessionPds] = sessData.HostURL
109
+
userSession.Values[SessionId] = sessData.SessionID
110
+
userSession.Values[SessionAuthenticated] = true
111
+
return userSession.Save(r, w)
112
+
}
77
113
78
-
err = o.sess.DeleteSession(r.Context(), did)
114
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
115
+
userSession, err := o.SessStore.Get(r, SessionName)
79
116
if err != nil {
80
-
return fmt.Errorf("error deleting oauth session: %w", err)
117
+
return nil, fmt.Errorf("error getting user session: %w", err)
118
+
}
119
+
if userSession.IsNew {
120
+
return nil, fmt.Errorf("no session available for user")
81
121
}
82
122
83
-
userSession.Options.MaxAge = -1
123
+
d := userSession.Values[SessionDid].(string)
124
+
sessDid, err := syntax.ParseDID(d)
125
+
if err != nil {
126
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
127
+
}
84
128
85
-
return userSession.Save(r, w)
86
-
}
129
+
sessId := userSession.Values[SessionId].(string)
87
130
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)
131
+
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
132
+
if err != nil {
133
+
return nil, fmt.Errorf("failed to resume session: %w", err)
92
134
}
93
135
94
-
did := userSession.Values[SessionDid].(string)
95
-
auth := userSession.Values[SessionAuthenticated].(bool)
136
+
return clientSess, nil
137
+
}
96
138
97
-
session, err := o.sess.GetSession(r.Context(), did)
139
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
140
+
userSession, err := o.SessStore.Get(r, SessionName)
98
141
if err != nil {
99
-
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
142
+
return fmt.Errorf("error getting user session: %w", err)
143
+
}
144
+
if userSession.IsNew {
145
+
return fmt.Errorf("no session available for user")
100
146
}
101
147
102
-
expiry, err := time.Parse(time.RFC3339, session.Expiry)
148
+
d := userSession.Values[SessionDid].(string)
149
+
sessDid, err := syntax.ParseDID(d)
103
150
if err != nil {
104
-
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
151
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
105
152
}
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
153
112
-
self := o.ClientMetadata()
154
+
sessId := userSession.Values[SessionId].(string)
113
155
114
-
oauthClient, err := client.NewClient(
115
-
self.ClientID,
116
-
o.config.OAuth.Jwks,
117
-
self.RedirectURIs[0],
118
-
)
156
+
// delete the session
157
+
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
119
158
120
-
if err != nil {
121
-
return nil, false, err
122
-
}
159
+
// remove the cookie
160
+
userSession.Options.MaxAge = -1
161
+
err2 := o.SessStore.Save(r, w, userSession)
123
162
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
163
+
return errors.Join(err1, err2)
143
164
}
144
165
145
166
type User struct {
146
-
Handle string
147
-
Did string
148
-
Pds string
167
+
Did string
168
+
Pds string
149
169
}
150
170
151
-
func (a *OAuth) GetUser(r *http.Request) *User {
152
-
clientSession, err := a.store.Get(r, SessionName)
153
-
154
-
if err != nil || clientSession.IsNew {
171
+
func (o *OAuth) GetUser(r *http.Request) *User {
172
+
sess, err := o.ResumeSession(r)
173
+
if err != nil {
155
174
return nil
156
175
}
157
176
158
177
return &User{
159
-
Handle: clientSession.Values[SessionHandle].(string),
160
-
Did: clientSession.Values[SessionDid].(string),
161
-
Pds: clientSession.Values[SessionPds].(string),
178
+
Did: sess.Data.AccountDID.String(),
179
+
Pds: sess.Data.HostURL,
162
180
}
163
181
}
164
182
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 ""
183
+
func (o *OAuth) GetDid(r *http.Request) string {
184
+
if u := o.GetUser(r); u != nil {
185
+
return u.Did
170
186
}
171
187
172
-
return clientSession.Values[SessionDid].(string)
188
+
return ""
173
189
}
174
190
175
-
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
176
-
session, auth, err := o.GetSession(r)
191
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
192
+
session, err := o.ResumeSession(r)
177
193
if err != nil {
178
194
return nil, fmt.Errorf("error getting session: %w", err)
179
195
}
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
196
+
return session.APIClient(), nil
208
197
}
209
198
210
-
// use this to create a client to communicate with knots or spindles
211
-
//
212
199
// this is a higher level abstraction on ServerGetServiceAuth
213
200
type ServiceClientOpts struct {
214
201
service string
···
259
246
return scheme + s.service
260
247
}
261
248
262
-
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
249
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
263
250
opts := ServiceClientOpts{}
264
251
for _, o := range os {
265
252
o(&opts)
266
253
}
267
254
268
-
authorizedClient, err := o.AuthorizedClient(r)
255
+
client, err := o.AuthorizedClient(r)
269
256
if err != nil {
270
257
return nil, err
271
258
}
···
276
263
opts.exp = sixty
277
264
}
278
265
279
-
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
266
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
280
267
if err != nil {
281
268
return nil, err
282
269
}
283
270
284
-
return &indigo_xrpc.Client{
285
-
Auth: &indigo_xrpc.AuthInfo{
271
+
return &xrpc.Client{
272
+
Auth: &xrpc.AuthInfo{
286
273
AccessJwt: resp.Token,
287
274
},
288
275
Host: opts.Host(),
···
291
278
},
292
279
}, nil
293
280
}
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
-
}
+246
appview/oauth/store.go
+246
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
+
type RedisStoreConfig struct {
15
+
RedisURL string
16
+
17
+
// The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely.
18
+
// The durations here should be *at least as long as* the expected duration of the oauth session itself.
19
+
SessionExpiryDuration time.Duration // duration since session creation (max TTL)
20
+
SessionInactivityDuration time.Duration // duration since last session update
21
+
AuthRequestExpiryDuration time.Duration // duration since auth request creation
22
+
}
23
+
24
+
// redis-backed implementation of ClientAuthStore.
25
+
type RedisStore struct {
26
+
client *redis.Client
27
+
cfg *RedisStoreConfig
28
+
}
29
+
30
+
var _ oauth.ClientAuthStore = &RedisStore{}
31
+
32
+
type sessionMetadata struct {
33
+
CreatedAt time.Time `json:"created_at"`
34
+
UpdatedAt time.Time `json:"updated_at"`
35
+
}
36
+
37
+
func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) {
38
+
if cfg == nil {
39
+
return nil, fmt.Errorf("missing cfg")
40
+
}
41
+
if cfg.RedisURL == "" {
42
+
return nil, fmt.Errorf("missing RedisURL")
43
+
}
44
+
if cfg.SessionExpiryDuration == 0 {
45
+
return nil, fmt.Errorf("missing SessionExpiryDuration")
46
+
}
47
+
if cfg.SessionInactivityDuration == 0 {
48
+
return nil, fmt.Errorf("missing SessionInactivityDuration")
49
+
}
50
+
if cfg.AuthRequestExpiryDuration == 0 {
51
+
return nil, fmt.Errorf("missing AuthRequestExpiryDuration")
52
+
}
53
+
54
+
opts, err := redis.ParseURL(cfg.RedisURL)
55
+
if err != nil {
56
+
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
57
+
}
58
+
59
+
client := redis.NewClient(opts)
60
+
61
+
// test the connection
62
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
63
+
defer cancel()
64
+
65
+
if err := client.Ping(ctx).Err(); err != nil {
66
+
return nil, fmt.Errorf("failed to connect to redis: %w", err)
67
+
}
68
+
69
+
return &RedisStore{
70
+
client: client,
71
+
cfg: cfg,
72
+
}, nil
73
+
}
74
+
75
+
func (r *RedisStore) Close() error {
76
+
return r.client.Close()
77
+
}
78
+
79
+
func sessionKey(did syntax.DID, sessionID string) string {
80
+
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
81
+
}
82
+
83
+
func sessionMetadataKey(did syntax.DID, sessionID string) string {
84
+
return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID)
85
+
}
86
+
87
+
func authRequestKey(state string) string {
88
+
return fmt.Sprintf("oauth:auth_request:%s", state)
89
+
}
90
+
91
+
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
92
+
key := sessionKey(did, sessionID)
93
+
metaKey := sessionMetadataKey(did, sessionID)
94
+
95
+
// Check metadata for inactivity expiry
96
+
metaData, err := r.client.Get(ctx, metaKey).Bytes()
97
+
if err == redis.Nil {
98
+
return nil, fmt.Errorf("session not found: %s", did)
99
+
}
100
+
if err != nil {
101
+
return nil, fmt.Errorf("failed to get session metadata: %w", err)
102
+
}
103
+
104
+
var meta sessionMetadata
105
+
if err := json.Unmarshal(metaData, &meta); err != nil {
106
+
return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err)
107
+
}
108
+
109
+
// Check if session has been inactive for too long
110
+
inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration)
111
+
if meta.UpdatedAt.Before(inactiveThreshold) {
112
+
// Session is inactive, delete it
113
+
r.client.Del(ctx, key, metaKey)
114
+
return nil, fmt.Errorf("session expired due to inactivity: %s", did)
115
+
}
116
+
117
+
// Get the actual session data
118
+
data, err := r.client.Get(ctx, key).Bytes()
119
+
if err == redis.Nil {
120
+
return nil, fmt.Errorf("session not found: %s", did)
121
+
}
122
+
if err != nil {
123
+
return nil, fmt.Errorf("failed to get session: %w", err)
124
+
}
125
+
126
+
var sess oauth.ClientSessionData
127
+
if err := json.Unmarshal(data, &sess); err != nil {
128
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
129
+
}
130
+
131
+
return &sess, nil
132
+
}
133
+
134
+
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
135
+
key := sessionKey(sess.AccountDID, sess.SessionID)
136
+
metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID)
137
+
138
+
data, err := json.Marshal(sess)
139
+
if err != nil {
140
+
return fmt.Errorf("failed to marshal session: %w", err)
141
+
}
142
+
143
+
// Check if session already exists to preserve CreatedAt
144
+
var meta sessionMetadata
145
+
existingMetaData, err := r.client.Get(ctx, metaKey).Bytes()
146
+
if err == redis.Nil {
147
+
// New session
148
+
meta = sessionMetadata{
149
+
CreatedAt: time.Now(),
150
+
UpdatedAt: time.Now(),
151
+
}
152
+
} else if err != nil {
153
+
return fmt.Errorf("failed to check existing session metadata: %w", err)
154
+
} else {
155
+
// Existing session - preserve CreatedAt, update UpdatedAt
156
+
if err := json.Unmarshal(existingMetaData, &meta); err != nil {
157
+
return fmt.Errorf("failed to unmarshal existing session metadata: %w", err)
158
+
}
159
+
meta.UpdatedAt = time.Now()
160
+
}
161
+
162
+
// Calculate remaining TTL based on creation time
163
+
remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt)
164
+
if remainingTTL <= 0 {
165
+
return fmt.Errorf("session has expired")
166
+
}
167
+
168
+
// Use the shorter of: remaining TTL or inactivity duration
169
+
ttl := min(r.cfg.SessionInactivityDuration, remainingTTL)
170
+
171
+
// Save session data
172
+
if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil {
173
+
return fmt.Errorf("failed to save session: %w", err)
174
+
}
175
+
176
+
// Save metadata
177
+
metaData, err := json.Marshal(meta)
178
+
if err != nil {
179
+
return fmt.Errorf("failed to marshal session metadata: %w", err)
180
+
}
181
+
if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil {
182
+
return fmt.Errorf("failed to save session metadata: %w", err)
183
+
}
184
+
185
+
return nil
186
+
}
187
+
188
+
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
189
+
key := sessionKey(did, sessionID)
190
+
metaKey := sessionMetadataKey(did, sessionID)
191
+
192
+
if err := r.client.Del(ctx, key, metaKey).Err(); err != nil {
193
+
return fmt.Errorf("failed to delete session: %w", err)
194
+
}
195
+
return nil
196
+
}
197
+
198
+
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
199
+
key := authRequestKey(state)
200
+
data, err := r.client.Get(ctx, key).Bytes()
201
+
if err == redis.Nil {
202
+
return nil, fmt.Errorf("request info not found: %s", state)
203
+
}
204
+
if err != nil {
205
+
return nil, fmt.Errorf("failed to get auth request: %w", err)
206
+
}
207
+
208
+
var req oauth.AuthRequestData
209
+
if err := json.Unmarshal(data, &req); err != nil {
210
+
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
211
+
}
212
+
213
+
return &req, nil
214
+
}
215
+
216
+
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
217
+
key := authRequestKey(info.State)
218
+
219
+
// check if already exists (to match MemStore behavior)
220
+
exists, err := r.client.Exists(ctx, key).Result()
221
+
if err != nil {
222
+
return fmt.Errorf("failed to check auth request existence: %w", err)
223
+
}
224
+
if exists > 0 {
225
+
return fmt.Errorf("auth request already saved for state %s", info.State)
226
+
}
227
+
228
+
data, err := json.Marshal(info)
229
+
if err != nil {
230
+
return fmt.Errorf("failed to marshal auth request: %w", err)
231
+
}
232
+
233
+
if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil {
234
+
return fmt.Errorf("failed to save auth request: %w", err)
235
+
}
236
+
237
+
return nil
238
+
}
239
+
240
+
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
241
+
key := authRequestKey(state)
242
+
if err := r.client.Del(ctx, key).Err(); err != nil {
243
+
return fmt.Errorf("failed to delete auth request: %w", err)
244
+
}
245
+
return nil
246
+
}
+584
appview/ogcard/card.go
+584
appview/ogcard/card.go
···
1
+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
+
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
+
// SPDX-License-Identifier: MIT
4
+
5
+
package ogcard
6
+
7
+
import (
8
+
"bytes"
9
+
"fmt"
10
+
"html/template"
11
+
"image"
12
+
"image/color"
13
+
"io"
14
+
"log"
15
+
"math"
16
+
"net/http"
17
+
"strings"
18
+
"sync"
19
+
"time"
20
+
21
+
"github.com/goki/freetype"
22
+
"github.com/goki/freetype/truetype"
23
+
"github.com/srwiley/oksvg"
24
+
"github.com/srwiley/rasterx"
25
+
"golang.org/x/image/draw"
26
+
"golang.org/x/image/font"
27
+
"tangled.org/core/appview/pages"
28
+
29
+
_ "golang.org/x/image/webp" // for processing webp images
30
+
)
31
+
32
+
type Card struct {
33
+
Img *image.RGBA
34
+
Font *truetype.Font
35
+
Margin int
36
+
Width int
37
+
Height int
38
+
}
39
+
40
+
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
41
+
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
42
+
if err != nil {
43
+
return nil, err
44
+
}
45
+
return truetype.Parse(interVar)
46
+
})
47
+
48
+
// DefaultSize returns the default size for a card
49
+
func DefaultSize() (int, int) {
50
+
return 1200, 630
51
+
}
52
+
53
+
// NewCard creates a new card with the given dimensions in pixels
54
+
func NewCard(width, height int) (*Card, error) {
55
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
56
+
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
57
+
58
+
font, err := fontCache()
59
+
if err != nil {
60
+
return nil, err
61
+
}
62
+
63
+
return &Card{
64
+
Img: img,
65
+
Font: font,
66
+
Margin: 0,
67
+
Width: width,
68
+
Height: height,
69
+
}, nil
70
+
}
71
+
72
+
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
73
+
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
74
+
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
75
+
bounds := c.Img.Bounds()
76
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
77
+
if vertical {
78
+
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
79
+
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
80
+
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
81
+
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
82
+
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
83
+
}
84
+
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
85
+
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
86
+
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
87
+
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
88
+
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
89
+
}
90
+
91
+
// SetMargin sets the margins for the card
92
+
func (c *Card) SetMargin(margin int) {
93
+
c.Margin = margin
94
+
}
95
+
96
+
type (
97
+
VAlign int64
98
+
HAlign int64
99
+
)
100
+
101
+
const (
102
+
Top VAlign = iota
103
+
Middle
104
+
Bottom
105
+
)
106
+
107
+
const (
108
+
Left HAlign = iota
109
+
Center
110
+
Right
111
+
)
112
+
113
+
// DrawText draws text within the card, respecting margins and alignment
114
+
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
115
+
ft := freetype.NewContext()
116
+
ft.SetDPI(72)
117
+
ft.SetFont(c.Font)
118
+
ft.SetFontSize(sizePt)
119
+
ft.SetClip(c.Img.Bounds())
120
+
ft.SetDst(c.Img)
121
+
ft.SetSrc(image.NewUniform(textColor))
122
+
123
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
124
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
125
+
126
+
bounds := c.Img.Bounds()
127
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
128
+
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
129
+
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
130
+
131
+
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
132
+
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
133
+
// knowing the total height, which is related to how many lines we'll have.
134
+
lines := make([]string, 0)
135
+
textWords := strings.Split(text, " ")
136
+
currentLine := ""
137
+
heightTotal := 0
138
+
139
+
for {
140
+
if len(textWords) == 0 {
141
+
// Ran out of words.
142
+
if currentLine != "" {
143
+
heightTotal += fontHeight
144
+
lines = append(lines, currentLine)
145
+
}
146
+
break
147
+
}
148
+
149
+
nextWord := textWords[0]
150
+
proposedLine := currentLine
151
+
if proposedLine != "" {
152
+
proposedLine += " "
153
+
}
154
+
proposedLine += nextWord
155
+
156
+
proposedLineWidth := font.MeasureString(face, proposedLine)
157
+
if proposedLineWidth.Ceil() > boxWidth {
158
+
// no, proposed line is too big; we'll use the last "currentLine"
159
+
heightTotal += fontHeight
160
+
if currentLine != "" {
161
+
lines = append(lines, currentLine)
162
+
currentLine = ""
163
+
// leave nextWord in textWords and keep going
164
+
} else {
165
+
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
166
+
// regardless as a line by itself. It will be clipped by the drawing routine.
167
+
lines = append(lines, nextWord)
168
+
textWords = textWords[1:]
169
+
}
170
+
} else {
171
+
// yes, it will fit
172
+
currentLine = proposedLine
173
+
textWords = textWords[1:]
174
+
}
175
+
}
176
+
177
+
textY := 0
178
+
switch valign {
179
+
case Top:
180
+
textY = fontHeight
181
+
case Bottom:
182
+
textY = boxHeight - heightTotal + fontHeight
183
+
case Middle:
184
+
textY = ((boxHeight - heightTotal) / 2) + fontHeight
185
+
}
186
+
187
+
for _, line := range lines {
188
+
lineWidth := font.MeasureString(face, line)
189
+
190
+
textX := 0
191
+
switch halign {
192
+
case Left:
193
+
textX = 0
194
+
case Right:
195
+
textX = boxWidth - lineWidth.Ceil()
196
+
case Center:
197
+
textX = (boxWidth - lineWidth.Ceil()) / 2
198
+
}
199
+
200
+
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
201
+
_, err := ft.DrawString(line, pt)
202
+
if err != nil {
203
+
return nil, err
204
+
}
205
+
206
+
textY += fontHeight
207
+
}
208
+
209
+
return lines, nil
210
+
}
211
+
212
+
// DrawTextAt draws text at a specific position with the given alignment
213
+
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
214
+
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
215
+
return err
216
+
}
217
+
218
+
// DrawTextAtWithWidth draws text at a specific position and returns the text width
219
+
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
220
+
ft := freetype.NewContext()
221
+
ft.SetDPI(72)
222
+
ft.SetFont(c.Font)
223
+
ft.SetFontSize(sizePt)
224
+
ft.SetClip(c.Img.Bounds())
225
+
ft.SetDst(c.Img)
226
+
ft.SetSrc(image.NewUniform(textColor))
227
+
228
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
229
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
230
+
lineWidth := font.MeasureString(face, text)
231
+
textWidth := lineWidth.Ceil()
232
+
233
+
// Adjust position based on alignment
234
+
adjustedX := x
235
+
adjustedY := y
236
+
237
+
switch halign {
238
+
case Left:
239
+
// x is already at the left position
240
+
case Right:
241
+
adjustedX = x - textWidth
242
+
case Center:
243
+
adjustedX = x - textWidth/2
244
+
}
245
+
246
+
switch valign {
247
+
case Top:
248
+
adjustedY = y + fontHeight
249
+
case Bottom:
250
+
adjustedY = y
251
+
case Middle:
252
+
adjustedY = y + fontHeight/2
253
+
}
254
+
255
+
pt := freetype.Pt(adjustedX, adjustedY)
256
+
_, err := ft.DrawString(text, pt)
257
+
return textWidth, err
258
+
}
259
+
260
+
// DrawBoldText draws bold text by rendering multiple times with slight offsets
261
+
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
262
+
// Draw the text multiple times with slight offsets to create bold effect
263
+
offsets := []struct{ dx, dy int }{
264
+
{0, 0}, // original
265
+
{1, 0}, // right
266
+
{0, 1}, // down
267
+
{1, 1}, // diagonal
268
+
}
269
+
270
+
var width int
271
+
for _, offset := range offsets {
272
+
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
273
+
if err != nil {
274
+
return 0, err
275
+
}
276
+
if width == 0 {
277
+
width = w
278
+
}
279
+
}
280
+
return width, nil
281
+
}
282
+
283
+
func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) {
284
+
// Convert color to hex string for SVG
285
+
rgba, isRGBA := iconColor.(color.RGBA)
286
+
if !isRGBA {
287
+
r, g, b, a := iconColor.RGBA()
288
+
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
289
+
}
290
+
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
291
+
292
+
// Replace currentColor with our desired color in the SVG
293
+
svgString := string(svgData)
294
+
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
295
+
296
+
// Make the stroke thicker
297
+
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
298
+
299
+
// Parse SVG
300
+
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
301
+
if err != nil {
302
+
return nil, fmt.Errorf("failed to parse SVG: %w", err)
303
+
}
304
+
305
+
return icon, nil
306
+
}
307
+
308
+
func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) {
309
+
svgData, err := pages.Files.ReadFile(svgPath)
310
+
if err != nil {
311
+
return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
312
+
}
313
+
314
+
icon, err := BuildSVGIconFromData(svgData, iconColor)
315
+
if err != nil {
316
+
return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err)
317
+
}
318
+
319
+
return icon, nil
320
+
}
321
+
322
+
func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) {
323
+
return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
324
+
}
325
+
326
+
func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error {
327
+
icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
328
+
if err != nil {
329
+
return err
330
+
}
331
+
332
+
c.DrawSVGIcon(icon, x, y, size)
333
+
334
+
return nil
335
+
}
336
+
337
+
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
+
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
+
if err != nil {
341
+
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
+
}
343
+
344
+
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
+
}
348
+
349
+
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
350
+
if err != nil {
351
+
return err
352
+
}
353
+
354
+
c.DrawSVGIcon(icon, x, y, size)
355
+
356
+
return nil
357
+
}
358
+
359
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
360
+
func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) {
361
+
// Set the icon size
362
+
w, h := float64(size), float64(size)
363
+
icon.SetTarget(0, 0, w, h)
364
+
365
+
// Create a temporary RGBA image for the icon
366
+
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
367
+
368
+
// Create scanner and rasterizer
369
+
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
370
+
raster := rasterx.NewDasher(size, size, scanner)
371
+
372
+
// Draw the icon
373
+
icon.Draw(raster, 1.0)
374
+
375
+
// Draw the icon onto the card at the specified position
376
+
bounds := c.Img.Bounds()
377
+
destRect := image.Rect(x, y, x+size, y+size)
378
+
379
+
// Make sure we don't draw outside the card bounds
380
+
if destRect.Max.X > bounds.Max.X {
381
+
destRect.Max.X = bounds.Max.X
382
+
}
383
+
if destRect.Max.Y > bounds.Max.Y {
384
+
destRect.Max.Y = bounds.Max.Y
385
+
}
386
+
387
+
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
388
+
}
389
+
390
+
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
391
+
func (c *Card) DrawImage(img image.Image) {
392
+
bounds := c.Img.Bounds()
393
+
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
394
+
srcBounds := img.Bounds()
395
+
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
396
+
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
397
+
398
+
var scale float64
399
+
if srcAspect > targetAspect {
400
+
// Image is wider than target, scale by width
401
+
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
402
+
} else {
403
+
// Image is taller or equal, scale by height
404
+
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
405
+
}
406
+
407
+
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
408
+
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
409
+
410
+
// Center the image within the target rectangle
411
+
offsetX := (targetRect.Dx() - newWidth) / 2
412
+
offsetY := (targetRect.Dy() - newHeight) / 2
413
+
414
+
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
415
+
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
416
+
}
417
+
418
+
func fallbackImage() image.Image {
419
+
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
420
+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
421
+
img.Set(0, 0, color.White)
422
+
return img
423
+
}
424
+
425
+
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
426
+
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
427
+
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
428
+
// this rendering process to be slowed down
429
+
client := &http.Client{
430
+
Timeout: 1 * time.Second, // 1 second timeout
431
+
}
432
+
433
+
resp, err := client.Get(url)
434
+
if err != nil {
435
+
log.Printf("error when fetching external image from %s: %v", url, err)
436
+
return nil, false
437
+
}
438
+
defer resp.Body.Close()
439
+
440
+
if resp.StatusCode != http.StatusOK {
441
+
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
442
+
return nil, false
443
+
}
444
+
445
+
contentType := resp.Header.Get("Content-Type")
446
+
447
+
body := resp.Body
448
+
bodyBytes, err := io.ReadAll(body)
449
+
if err != nil {
450
+
log.Printf("error when fetching external image from %s: %v", url, err)
451
+
return nil, false
452
+
}
453
+
454
+
// Handle SVG separately
455
+
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
+
return c.convertSVGToPNG(bodyBytes)
457
+
}
458
+
459
+
// Support content types are in-sync with the allowed custom avatar file types
460
+
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
461
+
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
462
+
return nil, false
463
+
}
464
+
465
+
bodyBuffer := bytes.NewReader(bodyBytes)
466
+
_, imgType, err := image.DecodeConfig(bodyBuffer)
467
+
if err != nil {
468
+
log.Printf("error when decoding external image from %s: %v", url, err)
469
+
return nil, false
470
+
}
471
+
472
+
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
473
+
if (contentType == "image/png" && imgType != "png") ||
474
+
(contentType == "image/jpeg" && imgType != "jpeg") ||
475
+
(contentType == "image/gif" && imgType != "gif") ||
476
+
(contentType == "image/webp" && imgType != "webp") {
477
+
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
478
+
return nil, false
479
+
}
480
+
481
+
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
482
+
if err != nil {
483
+
log.Printf("error w/ bodyBuffer.Seek")
484
+
return nil, false
485
+
}
486
+
img, _, err := image.Decode(bodyBuffer)
487
+
if err != nil {
488
+
log.Printf("error when decoding external image from %s: %v", url, err)
489
+
return nil, false
490
+
}
491
+
492
+
return img, true
493
+
}
494
+
495
+
// convertSVGToPNG converts SVG data to a PNG image
496
+
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
+
// Parse the SVG
498
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
+
if err != nil {
500
+
log.Printf("error parsing SVG: %v", err)
501
+
return nil, false
502
+
}
503
+
504
+
// Set a reasonable size for the rasterized image
505
+
width := 256
506
+
height := 256
507
+
icon.SetTarget(0, 0, float64(width), float64(height))
508
+
509
+
// Create an image to draw on
510
+
rgba := image.NewRGBA(image.Rect(0, 0, width, height))
511
+
512
+
// Fill with white background
513
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
514
+
515
+
// Create a scanner and rasterize the SVG
516
+
scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
517
+
raster := rasterx.NewDasher(width, height, scanner)
518
+
519
+
icon.Draw(raster, 1.0)
520
+
521
+
return rgba, true
522
+
}
523
+
524
+
func (c *Card) DrawExternalImage(url string) {
525
+
image, ok := c.fetchExternalImage(url)
526
+
if !ok {
527
+
image = fallbackImage()
528
+
}
529
+
c.DrawImage(image)
530
+
}
531
+
532
+
// DrawCircularExternalImage draws an external image as a circle at the specified position
533
+
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
534
+
img, ok := c.fetchExternalImage(url)
535
+
if !ok {
536
+
img = fallbackImage()
537
+
}
538
+
539
+
// Create a circular mask
540
+
circle := image.NewRGBA(image.Rect(0, 0, size, size))
541
+
center := size / 2
542
+
radius := float64(size / 2)
543
+
544
+
// Scale the source image to fit the circle
545
+
srcBounds := img.Bounds()
546
+
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
547
+
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
+
549
+
// Draw the image with circular clipping
550
+
for cy := 0; cy < size; cy++ {
551
+
for cx := 0; cx < size; cx++ {
552
+
// Calculate distance from center
553
+
dx := float64(cx - center)
554
+
dy := float64(cy - center)
555
+
distance := math.Sqrt(dx*dx + dy*dy)
556
+
557
+
// Only draw pixels within the circle
558
+
if distance <= radius {
559
+
circle.Set(cx, cy, scaledImg.At(cx, cy))
560
+
}
561
+
}
562
+
}
563
+
564
+
// Draw the circle onto the card
565
+
bounds := c.Img.Bounds()
566
+
destRect := image.Rect(x, y, x+size, y+size)
567
+
568
+
// Make sure we don't draw outside the card bounds
569
+
if destRect.Max.X > bounds.Max.X {
570
+
destRect.Max.X = bounds.Max.X
571
+
}
572
+
if destRect.Max.Y > bounds.Max.Y {
573
+
destRect.Max.Y = bounds.Max.Y
574
+
}
575
+
576
+
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
577
+
578
+
return nil
579
+
}
580
+
581
+
// DrawRect draws a rect with the given color
582
+
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
583
+
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
584
+
}
+31
-13
appview/pages/funcmap.go
+31
-13
appview/pages/funcmap.go
···
17
17
"strings"
18
18
"time"
19
19
20
+
"github.com/bluesky-social/indigo/atproto/syntax"
20
21
"github.com/dustin/go-humanize"
21
22
"github.com/go-enry/go-enry/v2"
22
23
"tangled.org/core/appview/filetree"
···
38
39
"contains": func(s string, target string) bool {
39
40
return strings.Contains(s, target)
40
41
},
42
+
"stripPort": func(hostname string) string {
43
+
if strings.Contains(hostname, ":") {
44
+
return strings.Split(hostname, ":")[0]
45
+
}
46
+
return hostname
47
+
},
41
48
"mapContains": func(m any, key any) bool {
42
49
mapValue := reflect.ValueOf(m)
43
50
if mapValue.Kind() != reflect.Map {
···
57
64
return "handle.invalid"
58
65
}
59
66
60
-
return "@" + identity.Handle.String()
67
+
return identity.Handle.String()
61
68
},
62
69
"truncateAt30": func(s string) string {
63
70
if len(s) <= 30 {
···
68
75
"splitOn": func(s, sep string) []string {
69
76
return strings.Split(s, sep)
70
77
},
78
+
"string": func(v any) string {
79
+
return fmt.Sprint(v)
80
+
},
71
81
"int64": func(a int) int64 {
72
82
return int64(a)
73
83
},
···
117
127
return b
118
128
},
119
129
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
130
+
if handle != "" && handle != syntax.HandleInvalid.String() {
131
+
return handle
122
132
} else {
123
133
return did
124
134
}
···
236
246
sanitized := p.rctx.SanitizeDescription(htmlString)
237
247
return template.HTML(sanitized)
238
248
},
249
+
"trimUriScheme": func(text string) string {
250
+
text = strings.TrimPrefix(text, "https://")
251
+
text = strings.TrimPrefix(text, "http://")
252
+
return text
253
+
},
239
254
"isNil": func(t any) bool {
240
255
// returns false for other "zero" values
241
256
return t == nil
···
265
280
return nil
266
281
},
267
282
"i": func(name string, classes ...string) template.HTML {
268
-
data, err := icon(name, classes)
283
+
data, err := p.icon(name, classes)
269
284
if err != nil {
270
285
log.Printf("icon %s does not exist", name)
271
-
data, _ = icon("airplay", classes)
286
+
data, _ = p.icon("airplay", classes)
272
287
}
273
288
return template.HTML(data)
274
289
},
275
-
"cssContentHash": CssContentHash,
290
+
"cssContentHash": p.CssContentHash,
276
291
"fileTree": filetree.FileTree,
277
292
"pathEscape": func(s string) string {
278
293
return url.PathEscape(s)
···
281
296
u, _ := url.PathUnescape(s)
282
297
return u
283
298
},
284
-
299
+
"safeUrl": func(s string) template.URL {
300
+
return template.URL(s)
301
+
},
285
302
"tinyAvatar": func(handle string) string {
286
-
return p.avatarUri(handle, "tiny")
303
+
return p.AvatarUrl(handle, "tiny")
287
304
},
288
305
"fullAvatar": func(handle string) string {
289
-
return p.avatarUri(handle, "")
306
+
return p.AvatarUrl(handle, "")
290
307
},
291
308
"langColor": enry.GetColor,
292
309
"layoutSide": func() string {
···
297
314
},
298
315
299
316
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
317
+
normalized := strings.ReplaceAll(s, ":", "_")
318
+
normalized = strings.ReplaceAll(normalized, ".", "_")
319
+
return normalized
302
320
},
303
321
"sshFingerprint": func(pubKey string) string {
304
322
fp, err := crypto.SSHFingerprint(pubKey)
···
310
328
}
311
329
}
312
330
313
-
func (p *Pages) avatarUri(handle, size string) string {
331
+
func (p *Pages) AvatarUrl(handle, size string) string {
314
332
handle = strings.TrimPrefix(handle, "@")
315
333
316
334
secret := p.avatar.SharedSecret
···
325
343
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
326
344
}
327
345
328
-
func icon(name string, classes []string) (template.HTML, error) {
346
+
func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
329
347
iconPath := filepath.Join("static", "icons", name)
330
348
331
349
if filepath.Ext(name) == "" {
+5
-2
appview/pages/funcmap_test.go
+5
-2
appview/pages/funcmap_test.go
···
2
2
3
3
import (
4
4
"html/template"
5
+
"log/slog"
6
+
"testing"
7
+
5
8
"tangled.org/core/appview/config"
6
9
"tangled.org/core/idresolver"
7
-
"testing"
8
10
)
9
11
10
12
func TestPages_funcMap(t *testing.T) {
···
13
15
// Named input parameters for receiver constructor.
14
16
config *config.Config
15
17
res *idresolver.Resolver
18
+
l *slog.Logger
16
19
want template.FuncMap
17
20
}{
18
21
// TODO: Add test cases.
19
22
}
20
23
for _, tt := range tests {
21
24
t.Run(tt.name, func(t *testing.T) {
22
-
p := NewPages(tt.config, tt.res)
25
+
p := NewPages(tt.config, tt.res, tt.l)
23
26
got := p.funcMap()
24
27
// TODO: update the condition below to compare got with tt.want.
25
28
if true {
+156
appview/pages/legal/privacy.md
+156
appview/pages/legal/privacy.md
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
4
+
collects, uses, and shares your personal information when you use our
5
+
platform and services (the "Service").
6
+
7
+
## 1. Information We Collect
8
+
9
+
### Account Information
10
+
11
+
When you create an account, we collect:
12
+
13
+
- Your chosen username
14
+
- Email address
15
+
- Profile information you choose to provide
16
+
- Authentication data
17
+
18
+
### Content and Activity
19
+
20
+
We store:
21
+
22
+
- Code repositories and associated metadata
23
+
- Issues, pull requests, and comments
24
+
- Activity logs and usage patterns
25
+
- Public keys for authentication
26
+
27
+
## 2. Data Location and Hosting
28
+
29
+
### EU Data Hosting
30
+
31
+
**All Tangled service data is hosted within the European Union.**
32
+
Specifically:
33
+
34
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
35
+
(*.tngl.sh) are located in Finland
36
+
- **Application Data:** All other service data is stored on EU-based
37
+
servers
38
+
- **Data Processing:** All data processing occurs within EU
39
+
jurisdiction
40
+
41
+
### External PDS Notice
42
+
43
+
**Important:** If your account is hosted on Bluesky's PDS or other
44
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
45
+
that data. The data protection, storage location, and privacy
46
+
practices for such accounts are governed by the respective PDS
47
+
provider's policies, not this Privacy Policy. We only control data
48
+
processing within our own services and infrastructure.
49
+
50
+
## 3. Third-Party Data Processors
51
+
52
+
We only share your data with the following third-party processors:
53
+
54
+
### Resend (Email Services)
55
+
56
+
- **Purpose:** Sending transactional emails (account verification,
57
+
notifications)
58
+
- **Data Shared:** Email address and necessary message content
59
+
60
+
### Cloudflare (Image Caching)
61
+
62
+
- **Purpose:** Caching and optimizing image delivery
63
+
- **Data Shared:** Public images and associated metadata for caching
64
+
purposes
65
+
66
+
### Posthog (Usage Metrics Tracking)
67
+
68
+
- **Purpose:** Tracking usage and platform metrics
69
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
70
+
information
71
+
72
+
## 4. How We Use Your Information
73
+
74
+
We use your information to:
75
+
76
+
- Provide and maintain the Service
77
+
- Process your transactions and requests
78
+
- Send you technical notices and support messages
79
+
- Improve and develop new features
80
+
- Ensure security and prevent fraud
81
+
- Comply with legal obligations
82
+
83
+
## 5. Data Sharing and Disclosure
84
+
85
+
We do not sell, trade, or rent your personal information. We may share
86
+
your information only in the following circumstances:
87
+
88
+
- With the third-party processors listed above
89
+
- When required by law or legal process
90
+
- To protect our rights, property, or safety, or that of our users
91
+
- In connection with a merger, acquisition, or sale of assets (with
92
+
appropriate protections)
93
+
94
+
## 6. Data Security
95
+
96
+
We implement appropriate technical and organizational measures to
97
+
protect your personal information against unauthorized access,
98
+
alteration, disclosure, or destruction. However, no method of
99
+
transmission over the Internet is 100% secure.
100
+
101
+
## 7. Data Retention
102
+
103
+
We retain your personal information for as long as necessary to provide
104
+
the Service and fulfill the purposes outlined in this Privacy Policy,
105
+
unless a longer retention period is required by law.
106
+
107
+
## 8. Your Rights
108
+
109
+
Under applicable data protection laws, you have the right to:
110
+
111
+
- Access your personal information
112
+
- Correct inaccurate information
113
+
- Request deletion of your information
114
+
- Object to processing of your information
115
+
- Data portability
116
+
- Withdraw consent (where applicable)
117
+
118
+
## 9. Cookies and Tracking
119
+
120
+
We use cookies and similar technologies to:
121
+
122
+
- Maintain your login session
123
+
- Remember your preferences
124
+
- Analyze usage patterns to improve the Service
125
+
126
+
You can control cookie settings through your browser preferences.
127
+
128
+
## 10. Children's Privacy
129
+
130
+
The Service is not intended for children under 16 years of age. We do
131
+
not knowingly collect personal information from children under 16. If
132
+
we become aware that we have collected such information, we will take
133
+
steps to delete it.
134
+
135
+
## 11. International Data Transfers
136
+
137
+
While all our primary data processing occurs within the EU, some of our
138
+
third-party processors may process data outside the EU. When this
139
+
occurs, we ensure appropriate safeguards are in place, such as Standard
140
+
Contractual Clauses or adequacy decisions.
141
+
142
+
## 12. Changes to This Privacy Policy
143
+
144
+
We may update this Privacy Policy from time to time. We will notify you
145
+
of any changes by posting the new Privacy Policy on this page and
146
+
updating the "Last updated" date.
147
+
148
+
## 13. Contact Information
149
+
150
+
If you have any questions about this Privacy Policy or wish to exercise
151
+
your rights, please contact us through our platform or via email.
152
+
153
+
---
154
+
155
+
This Privacy Policy complies with the EU General Data Protection
156
+
Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
+107
appview/pages/legal/terms.md
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
4
+
to and use of the Tangled platform and services (the "Service")
5
+
operated by us ("Tangled," "we," "us," or "our").
6
+
7
+
## 1. Acceptance of Terms
8
+
9
+
By accessing or using our Service, you agree to be bound by these Terms.
10
+
If you disagree with any part of these terms, then you may not access
11
+
the Service.
12
+
13
+
## 2. Account Registration
14
+
15
+
To use certain features of the Service, you must register for an
16
+
account. You agree to provide accurate, current, and complete
17
+
information during the registration process and to update such
18
+
information to keep it accurate, current, and complete.
19
+
20
+
## 3. Account Termination
21
+
22
+
> **Important Notice**
23
+
>
24
+
> **We reserve the right to terminate, suspend, or restrict access to
25
+
> your account at any time, for any reason, or for no reason at all, at
26
+
> our sole discretion.** This includes, but is not limited to,
27
+
> termination for violation of these Terms, inappropriate conduct, spam,
28
+
> abuse, or any other behavior we deem harmful to the Service or other
29
+
> users.
30
+
>
31
+
> Account termination may result in the loss of access to your
32
+
> repositories, data, and other content associated with your account. We
33
+
> are not obligated to provide advance notice of termination, though we
34
+
> may do so in our discretion.
35
+
36
+
## 4. Acceptable Use
37
+
38
+
You agree not to use the Service to:
39
+
40
+
- Violate any applicable laws or regulations
41
+
- Infringe upon the rights of others
42
+
- Upload, store, or share content that is illegal, harmful, threatening,
43
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
44
+
objectionable
45
+
- Engage in spam, phishing, or other deceptive practices
46
+
- Attempt to gain unauthorized access to the Service or other users'
47
+
accounts
48
+
- Interfere with or disrupt the Service or servers connected to the
49
+
Service
50
+
51
+
## 5. Content and Intellectual Property
52
+
53
+
You retain ownership of the content you upload to the Service. By
54
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
55
+
license to use, reproduce, modify, and distribute your content as
56
+
necessary to provide the Service.
57
+
58
+
## 6. Privacy
59
+
60
+
Your privacy is important to us. Please review our [Privacy
61
+
Policy](/privacy), which also governs your use of the Service.
62
+
63
+
## 7. Disclaimers
64
+
65
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
66
+
no warranties, expressed or implied, and hereby disclaim and negate all
67
+
other warranties including without limitation, implied warranties or
68
+
conditions of merchantability, fitness for a particular purpose, or
69
+
non-infringement of intellectual property or other violation of rights.
70
+
71
+
## 8. Limitation of Liability
72
+
73
+
In no event shall Tangled, nor its directors, employees, partners,
74
+
agents, suppliers, or affiliates, be liable for any indirect,
75
+
incidental, special, consequential, or punitive damages, including
76
+
without limitation, loss of profits, data, use, goodwill, or other
77
+
intangible losses, resulting from your use of the Service.
78
+
79
+
## 9. Indemnification
80
+
81
+
You agree to defend, indemnify, and hold harmless Tangled and its
82
+
affiliates, officers, directors, employees, and agents from and against
83
+
any and all claims, damages, obligations, losses, liabilities, costs,
84
+
or debt, and expenses (including attorney's fees).
85
+
86
+
## 10. Governing Law
87
+
88
+
These Terms shall be interpreted and governed by the laws of Finland,
89
+
without regard to its conflict of law provisions.
90
+
91
+
## 11. Changes to Terms
92
+
93
+
We reserve the right to modify or replace these Terms at any time. If a
94
+
revision is material, we will try to provide at least 30 days notice
95
+
prior to any new terms taking effect.
96
+
97
+
## 12. Contact Information
98
+
99
+
If you have any questions about these Terms of Service, please contact
100
+
us through our platform or via email.
101
+
102
+
---
103
+
104
+
These terms are effective as of the last updated date shown above and
105
+
will remain in effect except with respect to any changes in their
106
+
provisions in the future, which will be in effect immediately after
107
+
being posted on this page.
+111
appview/pages/markup/extension/atlink.go
+111
appview/pages/markup/extension/atlink.go
···
1
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
2
+
3
+
package extension
4
+
5
+
import (
6
+
"regexp"
7
+
8
+
"github.com/yuin/goldmark"
9
+
"github.com/yuin/goldmark/ast"
10
+
"github.com/yuin/goldmark/parser"
11
+
"github.com/yuin/goldmark/renderer"
12
+
"github.com/yuin/goldmark/renderer/html"
13
+
"github.com/yuin/goldmark/text"
14
+
"github.com/yuin/goldmark/util"
15
+
)
16
+
17
+
// An AtNode struct represents an AtNode
18
+
type AtNode struct {
19
+
Handle string
20
+
ast.BaseInline
21
+
}
22
+
23
+
var _ ast.Node = &AtNode{}
24
+
25
+
// Dump implements Node.Dump.
26
+
func (n *AtNode) Dump(source []byte, level int) {
27
+
ast.DumpHelper(n, source, level, nil, nil)
28
+
}
29
+
30
+
// KindAt is a NodeKind of the At node.
31
+
var KindAt = ast.NewNodeKind("At")
32
+
33
+
// Kind implements Node.Kind.
34
+
func (n *AtNode) Kind() ast.NodeKind {
35
+
return KindAt
36
+
}
37
+
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
+
40
+
type atParser struct{}
41
+
42
+
// NewAtParser return a new InlineParser that parses
43
+
// at expressions.
44
+
func NewAtParser() parser.InlineParser {
45
+
return &atParser{}
46
+
}
47
+
48
+
func (s *atParser) Trigger() []byte {
49
+
return []byte{'@'}
50
+
}
51
+
52
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
53
+
line, segment := block.PeekLine()
54
+
m := atRegexp.FindSubmatchIndex(line)
55
+
if m == nil {
56
+
return nil
57
+
}
58
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
59
+
block.Advance(m[1])
60
+
node := &AtNode{}
61
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
62
+
node.Handle = string(atSegment.Value(block.Source())[1:])
63
+
return node
64
+
}
65
+
66
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
67
+
// renders At nodes.
68
+
type atHtmlRenderer struct {
69
+
html.Config
70
+
}
71
+
72
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
73
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
74
+
r := &atHtmlRenderer{
75
+
Config: html.NewConfig(),
76
+
}
77
+
for _, opt := range opts {
78
+
opt.SetHTMLOption(&r.Config)
79
+
}
80
+
return r
81
+
}
82
+
83
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
84
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
85
+
reg.Register(KindAt, r.renderAt)
86
+
}
87
+
88
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
89
+
if entering {
90
+
w.WriteString(`<a href="/@`)
91
+
w.WriteString(n.(*AtNode).Handle)
92
+
w.WriteString(`" class="mention">`)
93
+
} else {
94
+
w.WriteString("</a>")
95
+
}
96
+
return ast.WalkContinue, nil
97
+
}
98
+
99
+
type atExt struct{}
100
+
101
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
102
+
var AtExt = &atExt{}
103
+
104
+
func (e *atExt) Extend(m goldmark.Markdown) {
105
+
m.Parser().AddOptions(parser.WithInlineParsers(
106
+
util.Prioritized(NewAtParser(), 500),
107
+
))
108
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
109
+
util.Prioritized(NewAtHTMLRenderer(), 500),
110
+
))
111
+
}
+15
-17
appview/pages/markup/format.go
+15
-17
appview/pages/markup/format.go
···
1
1
package markup
2
2
3
-
import "strings"
3
+
import (
4
+
"regexp"
5
+
)
4
6
5
7
type Format string
6
8
···
10
12
)
11
13
12
14
var FileTypes map[Format][]string = map[Format][]string{
13
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
15
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
16
}
15
17
16
-
// ReadmeFilenames contains the list of common README filenames to search for,
17
-
// in order of preference. Only includes well-supported formats.
18
-
var ReadmeFilenames = []string{
19
-
"README.md", "readme.md",
20
-
"README",
21
-
"readme",
22
-
"README.markdown",
23
-
"readme.markdown",
24
-
"README.txt",
25
-
"readme.txt",
18
+
var FileTypePatterns = map[Format]*regexp.Regexp{
19
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20
+
}
21
+
22
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23
+
24
+
func IsReadmeFile(filename string) bool {
25
+
return ReadmePattern.MatchString(filename)
26
26
}
27
27
28
28
func GetFormat(filename string) Format {
29
-
for format, extensions := range FileTypes {
30
-
for _, extension := range extensions {
31
-
if strings.HasSuffix(filename, extension) {
32
-
return format
33
-
}
29
+
for format, pattern := range FileTypePatterns {
30
+
if pattern.MatchString(filename) {
31
+
return format
34
32
}
35
33
}
36
34
// default format
+38
-2
appview/pages/markup/markdown.go
+38
-2
appview/pages/markup/markdown.go
···
5
5
"bytes"
6
6
"fmt"
7
7
"io"
8
+
"io/fs"
8
9
"net/url"
9
10
"path"
10
11
"strings"
···
20
21
"github.com/yuin/goldmark/renderer/html"
21
22
"github.com/yuin/goldmark/text"
22
23
"github.com/yuin/goldmark/util"
24
+
callout "gitlab.com/staticnoise/goldmark-callout"
23
25
htmlparse "golang.org/x/net/html"
24
26
25
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
26
29
"tangled.org/core/appview/pages/repoinfo"
27
30
)
28
31
···
45
48
IsDev bool
46
49
RendererType RendererType
47
50
Sanitizer Sanitizer
51
+
Files fs.FS
48
52
}
49
53
50
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
+
func NewMarkdown() goldmark.Markdown {
51
55
md := goldmark.New(
52
56
goldmark.WithExtensions(
53
57
extension.GFM,
···
62
66
extension.WithFootnoteIDPrefix([]byte("footnote")),
63
67
),
64
68
treeblood.MathML(),
69
+
callout.CalloutExtention,
70
+
textension.AtExt,
65
71
),
66
72
goldmark.WithParserOptions(
67
73
parser.WithAutoHeadingID(),
68
74
),
69
75
goldmark.WithRendererOptions(html.WithUnsafe()),
70
76
)
77
+
return md
78
+
}
79
+
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
md := NewMarkdown()
71
82
72
83
if rctx != nil {
73
84
var transformers []util.PrioritizedValue
···
140
151
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
141
152
switch node.Type {
142
153
case htmlparse.ElementNode:
143
-
if node.Data == "img" || node.Data == "source" {
154
+
switch node.Data {
155
+
case "img", "source":
144
156
for i, attr := range node.Attr {
145
157
if attr.Key != "src" {
146
158
continue
···
288
300
}
289
301
290
302
return path.Join(rctx.CurrentDir, dst)
303
+
}
304
+
305
+
// FindUserMentions returns Set of user handles from given markup soruce.
306
+
// It doesn't guarntee unique DIDs
307
+
func FindUserMentions(source string) []string {
308
+
var (
309
+
mentions []string
310
+
mentionsSet = make(map[string]struct{})
311
+
md = NewMarkdown()
312
+
sourceBytes = []byte(source)
313
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
314
+
)
315
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
316
+
if entering && n.Kind() == textension.KindAt {
317
+
handle := n.(*textension.AtNode).Handle
318
+
mentionsSet[handle] = struct{}{}
319
+
return ast.WalkSkipChildren, nil
320
+
}
321
+
return ast.WalkContinue, nil
322
+
})
323
+
for handle := range mentionsSet {
324
+
mentions = append(mentions, handle)
325
+
}
326
+
return mentions
291
327
}
292
328
293
329
func isAbsoluteUrl(link string) bool {
+6
appview/pages/markup/sanitizer.go
+6
appview/pages/markup/sanitizer.go
···
77
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
79
80
+
// at-mentions
81
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
+
80
83
// centering content
81
84
policy.AllowElements("center")
82
85
···
113
116
}
114
117
policy.AllowNoAttrs().OnElements(mathElements...)
115
118
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
119
+
120
+
// goldmark-callout
121
+
policy.AllowAttrs("data-callout").OnElements("details")
116
122
117
123
return policy
118
124
}
+162
-71
appview/pages/pages.go
+162
-71
appview/pages/pages.go
···
15
15
"path/filepath"
16
16
"strings"
17
17
"sync"
18
+
"time"
18
19
19
20
"tangled.org/core/api/tangled"
20
21
"tangled.org/core/appview/commitverify"
···
38
39
"github.com/go-git/go-git/v5/plumbing/object"
39
40
)
40
41
41
-
//go:embed templates/* static
42
+
//go:embed templates/* static legal
42
43
var Files embed.FS
43
44
44
45
type Pages struct {
···
54
55
logger *slog.Logger
55
56
}
56
57
57
-
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
58
+
func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages {
58
59
// initialized with safe defaults, can be overriden per use
59
60
rctx := &markup.RenderContext{
60
61
IsDev: config.Core.Dev,
61
62
CamoUrl: config.Camo.Host,
62
63
CamoSecret: config.Camo.SharedSecret,
63
64
Sanitizer: markup.NewSanitizer(),
65
+
Files: Files,
64
66
}
65
67
66
68
p := &Pages{
···
71
73
rctx: rctx,
72
74
resolver: res,
73
75
templateDir: "appview/pages",
74
-
logger: slog.Default().With("component", "pages"),
76
+
logger: logger,
75
77
}
76
78
77
79
if p.dev {
···
220
222
221
223
type LoginParams struct {
222
224
ReturnUrl string
225
+
ErrorCode string
223
226
}
224
227
225
228
func (p *Pages) Login(w io.Writer, params LoginParams) error {
226
229
return p.executePlain("user/login", w, params)
227
230
}
228
231
229
-
func (p *Pages) Signup(w io.Writer) error {
230
-
return p.executePlain("user/signup", w, nil)
232
+
type SignupParams struct {
233
+
CloudflareSiteKey string
234
+
}
235
+
236
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
237
+
return p.executePlain("user/signup", w, params)
231
238
}
232
239
233
240
func (p *Pages) CompleteSignup(w io.Writer) error {
···
242
249
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
243
250
filename := "terms.md"
244
251
filePath := filepath.Join("legal", filename)
245
-
markdownBytes, err := os.ReadFile(filePath)
252
+
253
+
file, err := p.embedFS.Open(filePath)
254
+
if err != nil {
255
+
return fmt.Errorf("failed to read %s: %w", filename, err)
256
+
}
257
+
defer file.Close()
258
+
259
+
markdownBytes, err := io.ReadAll(file)
246
260
if err != nil {
247
261
return fmt.Errorf("failed to read %s: %w", filename, err)
248
262
}
···
263
277
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
264
278
filename := "privacy.md"
265
279
filePath := filepath.Join("legal", filename)
266
-
markdownBytes, err := os.ReadFile(filePath)
280
+
281
+
file, err := p.embedFS.Open(filePath)
282
+
if err != nil {
283
+
return fmt.Errorf("failed to read %s: %w", filename, err)
284
+
}
285
+
defer file.Close()
286
+
287
+
markdownBytes, err := io.ReadAll(file)
267
288
if err != nil {
268
289
return fmt.Errorf("failed to read %s: %w", filename, err)
269
290
}
···
276
297
return p.execute("legal/privacy", w, params)
277
298
}
278
299
300
+
type BrandParams struct {
301
+
LoggedInUser *oauth.User
302
+
}
303
+
304
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
305
+
return p.execute("brand/brand", w, params)
306
+
}
307
+
279
308
type TimelineParams struct {
280
309
LoggedInUser *oauth.User
281
310
Timeline []models.TimelineEvent
282
311
Repos []models.Repo
312
+
GfiLabel *models.LabelDefinition
283
313
}
284
314
285
315
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
286
316
return p.execute("timeline/timeline", w, params)
287
317
}
288
318
319
+
type GoodFirstIssuesParams struct {
320
+
LoggedInUser *oauth.User
321
+
Issues []models.Issue
322
+
RepoGroups []*models.RepoGroup
323
+
LabelDefs map[string]*models.LabelDefinition
324
+
GfiLabel *models.LabelDefinition
325
+
Page pagination.Page
326
+
}
327
+
328
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
329
+
return p.execute("goodfirstissues/index", w, params)
330
+
}
331
+
289
332
type UserProfileSettingsParams struct {
290
333
LoggedInUser *oauth.User
291
334
Tabs []map[string]any
···
296
339
return p.execute("user/settings/profile", w, params)
297
340
}
298
341
342
+
type NotificationsParams struct {
343
+
LoggedInUser *oauth.User
344
+
Notifications []*models.NotificationWithEntity
345
+
UnreadCount int
346
+
Page pagination.Page
347
+
Total int64
348
+
}
349
+
350
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
351
+
return p.execute("notifications/list", w, params)
352
+
}
353
+
354
+
type NotificationItemParams struct {
355
+
Notification *models.Notification
356
+
}
357
+
358
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
359
+
return p.executePlain("notifications/fragments/item", w, params)
360
+
}
361
+
362
+
type NotificationCountParams struct {
363
+
Count int64
364
+
}
365
+
366
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
367
+
return p.executePlain("notifications/fragments/count", w, params)
368
+
}
369
+
299
370
type UserKeysSettingsParams struct {
300
371
LoggedInUser *oauth.User
301
372
PubKeys []models.PublicKey
···
316
387
317
388
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
318
389
return p.execute("user/settings/emails", w, params)
390
+
}
391
+
392
+
type UserNotificationSettingsParams struct {
393
+
LoggedInUser *oauth.User
394
+
Preferences *models.NotificationPreferences
395
+
Tabs []map[string]any
396
+
Tab string
397
+
}
398
+
399
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
400
+
return p.execute("user/settings/notifications", w, params)
319
401
}
320
402
321
403
type UpgradeBannerParams struct {
···
484
566
485
567
type FollowCard struct {
486
568
UserDid string
569
+
LoggedInUser *oauth.User
487
570
FollowStatus models.FollowStatus
488
571
FollowersCount int64
489
572
FollowingCount int64
···
557
640
return p.executePlain("repo/fragments/repoStar", w, params)
558
641
}
559
642
560
-
type RepoDescriptionParams struct {
561
-
RepoInfo repoinfo.RepoInfo
562
-
}
563
-
564
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
565
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
566
-
}
567
-
568
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
569
-
return p.executePlain("repo/fragments/repoDescription", w, params)
570
-
}
571
-
572
643
type RepoIndexParams struct {
573
644
LoggedInUser *oauth.User
574
645
RepoInfo repoinfo.RepoInfo
···
578
649
TagsTrunc []*types.TagReference
579
650
BranchesTrunc []types.Branch
580
651
// ForkInfo *types.ForkInfo
581
-
HTMLReadme template.HTML
582
-
Raw bool
583
-
EmailToDidOrHandle map[string]string
584
-
VerifiedCommits commitverify.VerifiedCommits
585
-
Languages []types.RepoLanguageDetails
586
-
Pipelines map[string]models.Pipeline
587
-
NeedsKnotUpgrade bool
652
+
HTMLReadme template.HTML
653
+
Raw bool
654
+
EmailToDid map[string]string
655
+
VerifiedCommits commitverify.VerifiedCommits
656
+
Languages []types.RepoLanguageDetails
657
+
Pipelines map[string]models.Pipeline
658
+
NeedsKnotUpgrade bool
588
659
types.RepoIndexResponse
589
660
}
590
661
···
619
690
}
620
691
621
692
type RepoLogParams struct {
622
-
LoggedInUser *oauth.User
623
-
RepoInfo repoinfo.RepoInfo
624
-
TagMap map[string][]string
693
+
LoggedInUser *oauth.User
694
+
RepoInfo repoinfo.RepoInfo
695
+
TagMap map[string][]string
696
+
Active string
697
+
EmailToDid map[string]string
698
+
VerifiedCommits commitverify.VerifiedCommits
699
+
Pipelines map[string]models.Pipeline
700
+
625
701
types.RepoLogResponse
626
-
Active string
627
-
EmailToDidOrHandle map[string]string
628
-
VerifiedCommits commitverify.VerifiedCommits
629
-
Pipelines map[string]models.Pipeline
630
702
}
631
703
632
704
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
635
707
}
636
708
637
709
type RepoCommitParams struct {
638
-
LoggedInUser *oauth.User
639
-
RepoInfo repoinfo.RepoInfo
640
-
Active string
641
-
EmailToDidOrHandle map[string]string
642
-
Pipeline *models.Pipeline
643
-
DiffOpts types.DiffOpts
710
+
LoggedInUser *oauth.User
711
+
RepoInfo repoinfo.RepoInfo
712
+
Active string
713
+
EmailToDid map[string]string
714
+
Pipeline *models.Pipeline
715
+
DiffOpts types.DiffOpts
644
716
645
717
// singular because it's always going to be just one
646
718
VerifiedCommit commitverify.VerifiedCommits
···
654
726
}
655
727
656
728
type RepoTreeParams struct {
657
-
LoggedInUser *oauth.User
658
-
RepoInfo repoinfo.RepoInfo
659
-
Active string
660
-
BreadCrumbs [][]string
661
-
TreePath string
662
-
Readme string
663
-
ReadmeFileName string
664
-
HTMLReadme template.HTML
665
-
Raw bool
729
+
LoggedInUser *oauth.User
730
+
RepoInfo repoinfo.RepoInfo
731
+
Active string
732
+
BreadCrumbs [][]string
733
+
TreePath string
734
+
Raw bool
735
+
HTMLReadme template.HTML
666
736
types.RepoTreeResponse
667
737
}
668
738
···
690
760
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
691
761
params.Active = "overview"
692
762
693
-
if params.ReadmeFileName != "" {
694
-
params.ReadmeFileName = filepath.Base(params.ReadmeFileName)
763
+
p.rctx.RepoInfo = params.RepoInfo
764
+
p.rctx.RepoInfo.Ref = params.Ref
765
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
695
766
767
+
if params.ReadmeFileName != "" {
696
768
ext := filepath.Ext(params.ReadmeFileName)
697
769
switch ext {
698
770
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
889
961
LabelDefs map[string]*models.LabelDefinition
890
962
Page pagination.Page
891
963
FilteringByOpen bool
964
+
FilterQuery string
892
965
}
893
966
894
967
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
905
978
LabelDefs map[string]*models.LabelDefinition
906
979
907
980
OrderedReactionKinds []models.ReactionKind
908
-
Reactions map[models.ReactionKind]int
981
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
909
982
UserReacted map[models.ReactionKind]bool
910
983
}
911
984
···
930
1003
ThreadAt syntax.ATURI
931
1004
Kind models.ReactionKind
932
1005
Count int
1006
+
Users []string
933
1007
IsReacted bool
934
1008
}
935
1009
···
1018
1092
Pulls []*models.Pull
1019
1093
Active string
1020
1094
FilteringBy models.PullState
1095
+
FilterQuery string
1021
1096
Stacks map[string]models.Stack
1022
1097
Pipelines map[string]models.Pipeline
1098
+
LabelDefs map[string]*models.LabelDefinition
1023
1099
}
1024
1100
1025
1101
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1046
1122
}
1047
1123
1048
1124
type RepoSinglePullParams struct {
1049
-
LoggedInUser *oauth.User
1050
-
RepoInfo repoinfo.RepoInfo
1051
-
Active string
1052
-
Pull *models.Pull
1053
-
Stack models.Stack
1054
-
AbandonedPulls []*models.Pull
1055
-
MergeCheck types.MergeCheckResponse
1056
-
ResubmitCheck ResubmitResult
1057
-
Pipelines map[string]models.Pipeline
1125
+
LoggedInUser *oauth.User
1126
+
RepoInfo repoinfo.RepoInfo
1127
+
Active string
1128
+
Pull *models.Pull
1129
+
Stack models.Stack
1130
+
AbandonedPulls []*models.Pull
1131
+
BranchDeleteStatus *models.BranchDeleteStatus
1132
+
MergeCheck types.MergeCheckResponse
1133
+
ResubmitCheck ResubmitResult
1134
+
Pipelines map[string]models.Pipeline
1058
1135
1059
1136
OrderedReactionKinds []models.ReactionKind
1060
-
Reactions map[models.ReactionKind]int
1137
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
1061
1138
UserReacted map[models.ReactionKind]bool
1139
+
1140
+
LabelDefs map[string]*models.LabelDefinition
1062
1141
}
1063
1142
1064
1143
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1148
1227
}
1149
1228
1150
1229
type PullActionsParams struct {
1151
-
LoggedInUser *oauth.User
1152
-
RepoInfo repoinfo.RepoInfo
1153
-
Pull *models.Pull
1154
-
RoundNumber int
1155
-
MergeCheck types.MergeCheckResponse
1156
-
ResubmitCheck ResubmitResult
1157
-
Stack models.Stack
1230
+
LoggedInUser *oauth.User
1231
+
RepoInfo repoinfo.RepoInfo
1232
+
Pull *models.Pull
1233
+
RoundNumber int
1234
+
MergeCheck types.MergeCheckResponse
1235
+
ResubmitCheck ResubmitResult
1236
+
BranchDeleteStatus *models.BranchDeleteStatus
1237
+
Stack models.Stack
1158
1238
}
1159
1239
1160
1240
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1270
1350
Name string
1271
1351
Command string
1272
1352
Collapsed bool
1353
+
StartTime time.Time
1273
1354
}
1274
1355
1275
1356
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1276
1357
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1277
1358
}
1278
1359
1360
+
type LogBlockEndParams struct {
1361
+
Id int
1362
+
StartTime time.Time
1363
+
EndTime time.Time
1364
+
}
1365
+
1366
+
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1367
+
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1368
+
}
1369
+
1279
1370
type LogLineParams struct {
1280
1371
Id int
1281
1372
Content string
···
1391
1482
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1392
1483
}
1393
1484
1394
-
sub, err := fs.Sub(Files, "static")
1485
+
sub, err := fs.Sub(p.embedFS, "static")
1395
1486
if err != nil {
1396
1487
p.logger.Error("no static dir found? that's crazy", "err", err)
1397
1488
panic(err)
···
1414
1505
})
1415
1506
}
1416
1507
1417
-
func CssContentHash() string {
1418
-
cssFile, err := Files.Open("static/tw.css")
1508
+
func (p *Pages) CssContentHash() string {
1509
+
cssFile, err := p.embedFS.Open("static/tw.css")
1419
1510
if err != nil {
1420
1511
slog.Debug("Error opening CSS file", "err", err)
1421
1512
return ""
+7
-7
appview/pages/repoinfo/repoinfo.go
+7
-7
appview/pages/repoinfo/repoinfo.go
···
1
1
package repoinfo
2
2
3
3
import (
4
-
"fmt"
5
4
"path"
6
5
"slices"
7
-
"strings"
8
6
9
7
"github.com/bluesky-social/indigo/atproto/syntax"
10
8
"tangled.org/core/appview/models"
11
9
"tangled.org/core/appview/state/userutil"
12
10
)
13
11
14
-
func (r RepoInfo) OwnerWithAt() string {
12
+
func (r RepoInfo) Owner() string {
15
13
if r.OwnerHandle != "" {
16
-
return fmt.Sprintf("@%s", r.OwnerHandle)
14
+
return r.OwnerHandle
17
15
} else {
18
16
return r.OwnerDid
19
17
}
20
18
}
21
19
22
20
func (r RepoInfo) FullName() string {
23
-
return path.Join(r.OwnerWithAt(), r.Name)
21
+
return path.Join(r.Owner(), r.Name)
24
22
}
25
23
26
24
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
-
return after
25
+
if r.OwnerHandle != "" {
26
+
return r.OwnerHandle
29
27
} else {
30
28
return userutil.FlattenDid(r.OwnerDid)
31
29
}
···
56
54
OwnerDid string
57
55
OwnerHandle string
58
56
Description string
57
+
Website string
58
+
Topics []string
59
59
Knot string
60
60
Spindle string
61
61
RepoAt syntax.ATURI
+224
appview/pages/templates/brand/brand.html
+224
appview/pages/templates/brand/brand.html
···
1
+
{{ define "title" }}brand{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Assets and guidelines for using Tangled's logo and brand elements.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="space-y-16">
14
+
15
+
<!-- Introduction Section -->
16
+
<section>
17
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
+
follow the below guidelines when using Dolly and the logotype.
20
+
</p>
21
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
+
</p>
24
+
</section>
25
+
26
+
<!-- Black Logotype Section -->
27
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
28
+
<div class="order-2 lg:order-1">
29
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
30
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
31
+
alt="Tangled logo - black version"
32
+
class="w-full max-w-sm mx-auto" />
33
+
</div>
34
+
</div>
35
+
<div class="order-1 lg:order-2">
36
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
+
<p class="text-gray-700 dark:text-gray-300">
39
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
+
backgrounds and designs.
41
+
</p>
42
+
</div>
43
+
</section>
44
+
45
+
<!-- White Logotype Section -->
46
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
47
+
<div class="order-2 lg:order-1">
48
+
<div class="bg-black p-8 sm:p-16 rounded">
49
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
50
+
alt="Tangled logo - white version"
51
+
class="w-full max-w-sm mx-auto" />
52
+
</div>
53
+
</div>
54
+
<div class="order-1 lg:order-2">
55
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
+
<p class="text-gray-700 dark:text-gray-300">
58
+
This version features white text and elements, ideal for dark backgrounds
59
+
and inverted designs.
60
+
</p>
61
+
</div>
62
+
</section>
63
+
64
+
<!-- Mark Only Section -->
65
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
66
+
<div class="order-2 lg:order-1">
67
+
<div class="grid grid-cols-2 gap-2">
68
+
<!-- Black mark on light background -->
69
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
70
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
71
+
alt="Dolly face - black version"
72
+
class="w-full max-w-16 mx-auto" />
73
+
</div>
74
+
<!-- White mark on dark background -->
75
+
<div class="bg-black p-8 sm:p-12 rounded">
76
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
77
+
alt="Dolly face - white version"
78
+
class="w-full max-w-16 mx-auto" />
79
+
</div>
80
+
</div>
81
+
</div>
82
+
<div class="order-1 lg:order-2">
83
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
+
</p>
87
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
88
+
<strong class="font-semibold">Note</strong>: for situations where the background
89
+
is unknown, use the black version for ideal contrast in most environments.
90
+
</p>
91
+
</div>
92
+
</section>
93
+
94
+
<!-- Colored Backgrounds Section -->
95
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
96
+
<div class="order-2 lg:order-1">
97
+
<div class="grid grid-cols-2 gap-2">
98
+
<!-- Pastel Green background -->
99
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
100
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
101
+
alt="Tangled logo on pastel green background"
102
+
class="w-full max-w-16 mx-auto" />
103
+
</div>
104
+
<!-- Pastel Blue background -->
105
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
106
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
107
+
alt="Tangled logo on pastel blue background"
108
+
class="w-full max-w-16 mx-auto" />
109
+
</div>
110
+
<!-- Pastel Yellow background -->
111
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
112
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
113
+
alt="Tangled logo on pastel yellow background"
114
+
class="w-full max-w-16 mx-auto" />
115
+
</div>
116
+
<!-- Pastel Red background -->
117
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
118
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
119
+
alt="Tangled logo on pastel red background"
120
+
class="w-full max-w-16 mx-auto" />
121
+
</div>
122
+
</div>
123
+
</div>
124
+
<div class="order-1 lg:order-2">
125
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
+
White logo mark on colored backgrounds.
128
+
</p>
129
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
130
+
The white logo mark provides contrast on colored backgrounds.
131
+
Perfect for more fun design contexts.
132
+
</p>
133
+
</div>
134
+
</section>
135
+
136
+
<!-- Black Logo on Pastel Backgrounds Section -->
137
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
138
+
<div class="order-2 lg:order-1">
139
+
<div class="grid grid-cols-2 gap-2">
140
+
<!-- Pastel Green background -->
141
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
142
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
143
+
alt="Tangled logo on pastel green background"
144
+
class="w-full max-w-16 mx-auto" />
145
+
</div>
146
+
<!-- Pastel Blue background -->
147
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
148
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
149
+
alt="Tangled logo on pastel blue background"
150
+
class="w-full max-w-16 mx-auto" />
151
+
</div>
152
+
<!-- Pastel Yellow background -->
153
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
154
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
155
+
alt="Tangled logo on pastel yellow background"
156
+
class="w-full max-w-16 mx-auto" />
157
+
</div>
158
+
<!-- Pastel Pink background -->
159
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
160
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
161
+
alt="Tangled logo on pastel pink background"
162
+
class="w-full max-w-16 mx-auto" />
163
+
</div>
164
+
</div>
165
+
</div>
166
+
<div class="order-1 lg:order-2">
167
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
+
Dark logo mark on lighter, pastel backgrounds.
170
+
</p>
171
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
172
+
The dark logo mark works beautifully on pastel backgrounds,
173
+
providing crisp contrast.
174
+
</p>
175
+
</div>
176
+
</section>
177
+
178
+
<!-- Recoloring Section -->
179
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
180
+
<div class="order-2 lg:order-1">
181
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
182
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
183
+
alt="Recolored Tangled logotype in gray/sand color"
184
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
185
+
</div>
186
+
</div>
187
+
<div class="order-1 lg:order-2">
188
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
+
Custom coloring of the logotype is permitted.
191
+
</p>
192
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
193
+
Recoloring the logotype is allowed as long as readability is maintained.
194
+
</p>
195
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
+
</p>
198
+
</div>
199
+
</section>
200
+
201
+
<!-- Silhouette Section -->
202
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
+
<div class="order-2 lg:order-1">
204
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
+
alt="Dolly silhouette"
207
+
class="w-full max-w-32 mx-auto" />
208
+
</div>
209
+
</div>
210
+
<div class="order-1 lg:order-2">
211
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
+
<p class="text-gray-700 dark:text-gray-300">
214
+
The silhouette can be used where a subtle brand presence is needed,
215
+
or as a background element. Works on any background color with proper contrast.
216
+
For example, we use this as the site's favicon.
217
+
</p>
218
+
</div>
219
+
</section>
220
+
221
+
</div>
222
+
</main>
223
+
</div>
224
+
{{ end }}
+4
-11
appview/pages/templates/errors/500.html
+4
-11
appview/pages/templates/errors/500.html
···
5
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
6
<div class="mb-6">
7
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
9
</div>
10
10
</div>
11
11
···
14
14
500 — internal server error
15
15
</h1>
16
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
26
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
20
<button onclick="location.reload()" class="btn-create gap-2">
28
21
{{ i "refresh-cw" "w-4 h-4" }}
29
22
try again
30
23
</button>
31
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
25
+
{{ i "arrow-left" "w-4 h-4" }}
33
26
back to home
34
27
</a>
35
28
</div>
+82
-54
appview/pages/templates/fragments/dolly/logo.html
+82
-54
appview/pages/templates/fragments/dolly/logo.html
···
1
1
{{ define "fragments/dolly/logo" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
class="{{.}}"
6
-
width="25"
7
-
height="25"
8
-
viewBox="0 0 25 25"
9
-
sodipodi:docname="tangled_dolly_face_only.png"
10
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
-
xmlns:xlink="http://www.w3.org/1999/xlink"
13
-
xmlns="http://www.w3.org/2000/svg"
14
-
xmlns:svg="http://www.w3.org/2000/svg">
15
-
<title>Dolly</title>
16
-
<defs
17
-
id="defs1" />
18
-
<sodipodi:namedview
19
-
id="namedview1"
20
-
pagecolor="#ffffff"
21
-
bordercolor="#000000"
22
-
borderopacity="0.25"
23
-
inkscape:showpageshadow="2"
24
-
inkscape:pageopacity="0.0"
25
-
inkscape:pagecheckerboard="true"
26
-
inkscape:deskcolor="#d5d5d5">
27
-
<inkscape:page
28
-
x="0"
29
-
y="0"
30
-
width="25"
31
-
height="25"
32
-
id="page2"
33
-
margin="0"
34
-
bleed="0" />
35
-
</sodipodi:namedview>
36
-
<g
37
-
inkscape:groupmode="layer"
38
-
inkscape:label="Image"
39
-
id="g1">
40
-
<image
41
-
width="252.48"
42
-
height="248.96001"
43
-
preserveAspectRatio="none"
44
-
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ 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 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
-
id="image1"
46
-
x="-233.6257"
47
-
y="10.383364"
48
-
style="display:none" />
49
-
<path
50
-
fill="currentColor"
51
-
style="stroke-width:0.111183"
52
-
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
53
-
id="path4" />
54
-
</g>
55
-
</svg>
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{ . }}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
10
+
inkscape:export-filename="tangled_logotype_black_on_trans.svg"
11
+
inkscape:export-xdpi="96"
12
+
inkscape:export-ydpi="96"
13
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
14
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
15
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
16
+
xmlns="http://www.w3.org/2000/svg"
17
+
xmlns:svg="http://www.w3.org/2000/svg"
18
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
+
xmlns:cc="http://creativecommons.org/ns#">
20
+
<sodipodi:namedview
21
+
id="namedview1"
22
+
pagecolor="#ffffff"
23
+
bordercolor="#000000"
24
+
borderopacity="0.25"
25
+
inkscape:showpageshadow="2"
26
+
inkscape:pageopacity="0.0"
27
+
inkscape:pagecheckerboard="true"
28
+
inkscape:deskcolor="#d5d5d5"
29
+
inkscape:zoom="45.254834"
30
+
inkscape:cx="3.1377863"
31
+
inkscape:cy="8.9382717"
32
+
inkscape:window-width="3840"
33
+
inkscape:window-height="2160"
34
+
inkscape:window-x="0"
35
+
inkscape:window-y="0"
36
+
inkscape:window-maximized="0"
37
+
inkscape:current-layer="g1"
38
+
borderlayer="true">
39
+
<inkscape:page
40
+
x="0"
41
+
y="0"
42
+
width="25"
43
+
height="25"
44
+
id="page2"
45
+
margin="0"
46
+
bleed="0" />
47
+
</sodipodi:namedview>
48
+
<g
49
+
inkscape:groupmode="layer"
50
+
inkscape:label="Image"
51
+
id="g1"
52
+
transform="translate(-0.42924038,-0.87777209)">
53
+
<path
54
+
fill="currentColor"
55
+
style="stroke-width:0.111183;"
56
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
+
id="path4"
58
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
59
+
</g>
60
+
<metadata
61
+
id="metadata1">
62
+
<rdf:RDF>
63
+
<cc:Work
64
+
rdf:about="">
65
+
<cc:license
66
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
67
+
</cc:Work>
68
+
<cc:License
69
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
70
+
<cc:permits
71
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
72
+
<cc:permits
73
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
74
+
<cc:requires
75
+
rdf:resource="http://creativecommons.org/ns#Notice" />
76
+
<cc:requires
77
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
78
+
<cc:permits
79
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
80
+
</cc:License>
81
+
</rdf:RDF>
82
+
</metadata>
83
+
</svg>
56
84
{{ end }}
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
···
2
2
<svg
3
3
version="1.1"
4
4
id="svg1"
5
-
width="32"
6
-
height="32"
5
+
width="25"
6
+
height="25"
7
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
+
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
+
inkscape:export-xdpi="96"
11
+
inkscape:export-ydpi="96"
12
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
9
13
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
14
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
15
xmlns="http://www.w3.org/2000/svg"
12
-
xmlns:svg="http://www.w3.org/2000/svg">
13
-
<style>
14
-
.dolly {
15
-
color: #000000;
16
-
}
16
+
xmlns:svg="http://www.w3.org/2000/svg"
17
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
+
xmlns:cc="http://creativecommons.org/ns#">
19
+
<style>
20
+
.dolly {
21
+
color: #000000;
22
+
}
17
23
18
-
@media (prefers-color-scheme: dark) {
19
-
.dolly {
20
-
color: #ffffff;
21
-
}
22
-
}
23
-
</style>
24
-
<title>Dolly</title>
25
-
<defs
26
-
id="defs1" />
24
+
@media (prefers-color-scheme: dark) {
25
+
.dolly {
26
+
color: #ffffff;
27
+
}
28
+
}
29
+
</style>
27
30
<sodipodi:namedview
28
31
id="namedview1"
29
32
pagecolor="#ffffff"
···
32
35
inkscape:showpageshadow="2"
33
36
inkscape:pageopacity="0.0"
34
37
inkscape:pagecheckerboard="true"
35
-
inkscape:deskcolor="#d1d1d1">
38
+
inkscape:deskcolor="#d5d5d5"
39
+
inkscape:zoom="64"
40
+
inkscape:cx="4.96875"
41
+
inkscape:cy="13.429688"
42
+
inkscape:window-width="3840"
43
+
inkscape:window-height="2160"
44
+
inkscape:window-x="0"
45
+
inkscape:window-y="0"
46
+
inkscape:window-maximized="0"
47
+
inkscape:current-layer="g1"
48
+
borderlayer="true">
36
49
<inkscape:page
37
50
x="0"
38
51
y="0"
···
45
58
<g
46
59
inkscape:groupmode="layer"
47
60
inkscape:label="Image"
48
-
id="g1">
61
+
id="g1"
62
+
transform="translate(-0.42924038,-0.87777209)">
49
63
<path
50
64
class="dolly"
51
65
fill="currentColor"
52
-
style="stroke-width:1.12248"
53
-
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"
54
-
id="path1" />
66
+
style="stroke-width:0.111183"
67
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
+
id="path7"
69
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
55
70
</g>
71
+
<metadata
72
+
id="metadata1">
73
+
<rdf:RDF>
74
+
<cc:Work
75
+
rdf:about="">
76
+
<cc:license
77
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
+
</cc:Work>
79
+
<cc:License
80
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
+
<cc:permits
82
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
+
<cc:permits
84
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
+
<cc:requires
86
+
rdf:resource="http://creativecommons.org/ns#Notice" />
87
+
<cc:requires
88
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
+
<cc:permits
90
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
+
</cc:License>
92
+
</rdf:RDF>
93
+
</metadata>
56
94
</svg>
57
95
{{ end }}
+25
appview/pages/templates/fragments/tabSelector.html
+25
appview/pages/templates/fragments/tabSelector.html
···
1
+
{{ define "fragments/tabSelector" }}
2
+
{{ $name := .Name }}
3
+
{{ $all := .Values }}
4
+
{{ $active := .Active }}
5
+
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
6
+
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
7
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
8
+
{{ range $index, $value := $all }}
9
+
{{ $isActive := eq $value.Key $active }}
10
+
<a href="?{{ $name }}={{ $value.Key }}"
11
+
class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
12
+
{{ if $value.Icon }}
13
+
{{ i $value.Icon "size-4" }}
14
+
{{ end }}
15
+
16
+
{{ with $value.Meta }}
17
+
{{ . }}
18
+
{{ end }}
19
+
20
+
{{ $value.Value }}
21
+
</a>
22
+
{{ end }}
23
+
</div>
24
+
{{ end }}
25
+
+36
appview/pages/templates/fragments/workflow-timers.html
+36
appview/pages/templates/fragments/workflow-timers.html
···
1
+
{{ define "fragments/workflow-timers" }}
2
+
<script>
3
+
function formatElapsed(seconds) {
4
+
if (seconds < 1) return '0s';
5
+
if (seconds < 60) return `${seconds}s`;
6
+
const minutes = Math.floor(seconds / 60);
7
+
const secs = seconds % 60;
8
+
if (seconds < 3600) return `${minutes}m ${secs}s`;
9
+
const hours = Math.floor(seconds / 3600);
10
+
const mins = Math.floor((seconds % 3600) / 60);
11
+
return `${hours}h ${mins}m`;
12
+
}
13
+
14
+
function updateTimers() {
15
+
const now = Math.floor(Date.now() / 1000);
16
+
17
+
document.querySelectorAll('[data-timer]').forEach(el => {
18
+
const startTime = parseInt(el.dataset.start);
19
+
const endTime = el.dataset.end ? parseInt(el.dataset.end) : null;
20
+
21
+
if (endTime) {
22
+
// Step is complete, show final time
23
+
const elapsed = endTime - startTime;
24
+
el.textContent = formatElapsed(elapsed);
25
+
} else {
26
+
// Step is running, update live
27
+
const elapsed = now - startTime;
28
+
el.textContent = formatElapsed(elapsed);
29
+
}
30
+
});
31
+
}
32
+
33
+
setInterval(updateTimers, 1000);
34
+
updateTimers();
35
+
</script>
36
+
{{ end }}
+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 }}
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Id }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
17
19
{{ block "addKnotMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
···
54
62
</div>
55
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
64
</form>
57
-
{{ end }}
65
+
{{ end }}
+1
-1
appview/pages/templates/labels/fragments/label.html
+1
-1
appview/pages/templates/labels/fragments/label.html
···
2
2
{{ $d := .def }}
3
3
{{ $v := .val }}
4
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
7
8
8
{{ $lhs := printf "%s" $d.Name }}
+17
-12
appview/pages/templates/layouts/base.html
+17
-12
appview/pages/templates/layouts/base.html
···
9
9
10
10
<script defer src="/static/htmx.min.js"></script>
11
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
+
<script defer src="/static/actor-typeahead.js" type="module"></script>
12
13
13
14
<!-- preconnect to image cdn -->
14
15
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
16
<link rel="preconnect" href="https://camo.tangled.sh" />
17
+
18
+
<!-- pwa manifest -->
19
+
<link rel="manifest" href="/pwa-manifest.json" />
16
20
17
21
<!-- preload main font -->
18
22
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
···
21
25
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
26
{{ block "extrameta" . }}{{ end }}
23
27
</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);">
28
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
26
29
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
+
<header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
31
29
32
{{ if .LoggedInUser }}
30
33
<div id="upgrade-banner"
···
38
41
{{ end }}
39
42
40
43
{{ 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
+
<div class="flex-grow">
45
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
46
+
{{ block "contentLayout" . }}
47
+
<main>
44
48
{{ block "content" . }}{{ end }}
45
49
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
50
+
{{ end }}
51
+
52
+
{{ block "contentAfterLayout" . }}
53
+
<main>
50
54
{{ block "contentAfter" . }}{{ end }}
51
55
</main>
52
-
{{ end }}
56
+
{{ end }}
57
+
</div>
53
58
</div>
54
59
{{ end }}
55
60
56
61
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
62
+
<footer class="mt-12">
58
63
{{ template "layouts/fragments/footer" . }}
59
64
</footer>
60
65
{{ end }}
+22
-16
appview/pages/templates/layouts/fragments/topbar.html
+22
-16
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
2
+
<nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
6
-
{{ template "fragments/logotypeSmall" }}
5
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
+
alpha
10
+
</span>
7
11
</a>
8
12
</div>
9
13
10
-
<div id="right-items" class="flex items-center gap-2">
14
+
<div id="right-items" class="flex items-center gap-4">
11
15
{{ with .LoggedInUser }}
12
16
{{ block "newButton" . }} {{ end }}
13
-
{{ block "dropDown" . }} {{ end }}
17
+
{{ template "notifications/fragments/bell" }}
18
+
{{ block "profileDropdown" . }} {{ end }}
14
19
{{ else }}
15
20
<a href="/login">login</a>
16
21
<span class="text-gray-500 dark:text-gray-400">or</span>
···
26
31
{{ define "newButton" }}
27
32
<details class="relative inline-block text-left nav-dropdown">
28
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
-
{{ i "plus" "w-4 h-4" }} new
34
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
30
35
</summary>
31
-
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
36
+
<div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
37
<a href="/repo/new" class="flex items-center gap-2">
33
38
{{ i "book-plus" "w-4 h-4" }}
34
39
new repository
···
41
46
</details>
42
47
{{ end }}
43
48
44
-
{{ define "dropDown" }}
49
+
{{ define "profileDropdown" }}
45
50
<details class="relative inline-block text-left nav-dropdown">
46
-
<summary
47
-
class="cursor-pointer list-none flex items-center"
48
-
>
49
-
{{ $user := didOrHandle .Did .Handle }}
50
-
{{ template "user/fragments/picHandle" $user }}
51
+
<summary class="cursor-pointer list-none flex items-center gap-1">
52
+
{{ $user := .Did }}
53
+
<img
54
+
src="{{ tinyAvatar $user }}"
55
+
alt=""
56
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
57
+
/>
58
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
51
59
</summary>
52
-
<div
53
-
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
54
-
>
60
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
55
61
<a href="/{{ $user }}">profile</a>
56
62
<a href="/{{ $user }}?tab=repos">repositories</a>
57
63
<a href="/{{ $user }}?tab=strings">strings</a>
+9
appview/pages/templates/layouts/profilebase.html
+9
appview/pages/templates/layouts/profilebase.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
+
{{ $avatarUrl := fullAvatar .Card.UserHandle }}
4
5
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
6
<meta property="og:type" content="profile" />
6
7
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
8
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
9
+
<meta property="og:image" content="{{ $avatarUrl }}" />
10
+
<meta property="og:image:width" content="512" />
11
+
<meta property="og:image:height" content="512" />
12
+
13
+
<meta name="twitter:card" content="summary" />
14
+
<meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
15
+
<meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
16
+
<meta name="twitter:image" content="{{ $avatarUrl }}" />
8
17
{{ end }}
9
18
10
19
{{ define "content" }}
+53
-25
appview/pages/templates/layouts/repobase.html
+53
-25
appview/pages/templates/layouts/repobase.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
-
{{ if .RepoInfo.Source }}
6
-
<p class="text-sm">
7
-
<div class="flex items-center">
8
-
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
9
-
forked from
10
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
-
</div>
13
-
</p>
14
-
{{ end }}
15
-
<div class="text-lg flex items-center justify-between">
16
-
<div>
17
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
-
<span class="select-none">/</span>
19
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
4
+
<section id="repo-header" class="mb-4 p-2 dark:text-white">
5
+
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
6
+
<!-- left items -->
7
+
<div class="flex flex-col gap-2">
8
+
<!-- repo owner / repo name -->
9
+
<div class="flex items-center gap-2 flex-wrap">
10
+
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
11
+
<span class="select-none">/</span>
12
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
13
+
</div>
14
+
15
+
{{ if .RepoInfo.Source }}
16
+
{{ $sourceOwner := resolve .RepoInfo.Source.Did }}
17
+
<div class="flex items-center gap-1 text-sm flex-wrap">
18
+
{{ i "git-fork" "w-3 h-3 shrink-0" }}
19
+
<span>forked from</span>
20
+
<a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">
21
+
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
22
+
</a>
23
+
</div>
24
+
{{ end }}
25
+
26
+
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
27
+
{{ if .RepoInfo.Description }}
28
+
{{ .RepoInfo.Description | description }}
29
+
{{ else }}
30
+
<span class="italic">this repo has no description</span>
31
+
{{ end }}
32
+
33
+
{{ with .RepoInfo.Website }}
34
+
<span class="flex items-center gap-1">
35
+
<span class="flex-shrink-0">{{ i "globe" "size-4" }}</span>
36
+
<a href="{{ . }}">{{ . | trimUriScheme }}</a>
37
+
</span>
38
+
{{ end }}
39
+
40
+
{{ if .RepoInfo.Topics }}
41
+
<div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300">
42
+
{{ range .RepoInfo.Topics }}
43
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span>
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
48
+
</span>
20
49
</div>
21
50
22
-
<div class="flex items-center gap-2 z-auto">
23
-
<a
24
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
-
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
-
>
27
-
{{ i "rss" "size-4" }}
28
-
</a>
51
+
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
29
52
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
53
<a
31
54
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
···
36
59
fork
37
60
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
61
</a>
62
+
<a
63
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
64
+
href="/{{ .RepoInfo.FullName }}/feed.atom">
65
+
{{ i "rss" "size-4" }}
66
+
<span class="md:hidden">atom</span>
67
+
</a>
39
68
</div>
40
69
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
42
70
</section>
43
71
44
72
<section class="w-full flex flex-col" >
···
79
107
</div>
80
108
</nav>
81
109
{{ block "repoContentLayout" . }}
82
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
110
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
83
111
{{ block "repoContent" . }}{{ end }}
84
112
</section>
85
113
{{ block "repoAfter" . }}{{ end }}
+13
-6
appview/pages/templates/legal/privacy.html
+13
-6
appview/pages/templates/legal/privacy.html
···
1
1
{{ define "title" }}privacy policy{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Learn how we collect, use, and protect your personal information.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
9
15
</div>
16
+
</main>
10
17
</div>
11
-
{{ end }}
18
+
{{ end }}
+13
-6
appview/pages/templates/legal/terms.html
+13
-6
appview/pages/templates/legal/terms.html
···
1
1
{{ define "title" }}terms of service{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
A few things you should know.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
9
15
</div>
16
+
</main>
10
17
</div>
11
-
{{ end }}
18
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
+11
appview/pages/templates/notifications/fragments/bell.html
···
1
+
{{define "notifications/fragments/bell"}}
2
+
<div class="relative"
3
+
hx-get="/notifications/count"
4
+
hx-target="#notification-count"
5
+
hx-trigger="load, every 30s">
6
+
<a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group">
7
+
{{ i "bell" "w-5 h-5" }}
8
+
<span id="notification-count"></span>
9
+
</a>
10
+
</div>
11
+
{{end}}
+7
appview/pages/templates/notifications/fragments/count.html
+7
appview/pages/templates/notifications/fragments/count.html
···
1
+
{{define "notifications/fragments/count"}}
2
+
{{if and .Count (gt .Count 0)}}
3
+
<span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center">
4
+
{{if gt .Count 99}}99+{{else}}{{.Count}}{{end}}
5
+
</span>
6
+
{{end}}
7
+
{{end}}
+90
appview/pages/templates/notifications/fragments/item.html
+90
appview/pages/templates/notifications/fragments/item.html
···
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
+
<div class="flex items-center gap-1">
12
+
<span>{{ template "notificationHeader" . }}</span>
13
+
<span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span>
14
+
</div>
15
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
16
+
</div>
17
+
18
+
</div>
19
+
</a>
20
+
{{end}}
21
+
22
+
{{ define "notificationIcon" }}
23
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
24
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
25
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10">
26
+
{{ i .Icon "size-3 text-black dark:text-white" }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
30
+
31
+
{{ define "notificationHeader" }}
32
+
{{ $actor := resolve .ActorDid }}
33
+
34
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
35
+
{{ if eq .Type "repo_starred" }}
36
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
37
+
{{ else if eq .Type "issue_created" }}
38
+
opened an issue
39
+
{{ else if eq .Type "issue_commented" }}
40
+
commented on an issue
41
+
{{ else if eq .Type "issue_closed" }}
42
+
closed an issue
43
+
{{ else if eq .Type "issue_reopen" }}
44
+
reopened an issue
45
+
{{ else if eq .Type "pull_created" }}
46
+
created a pull request
47
+
{{ else if eq .Type "pull_commented" }}
48
+
commented on a pull request
49
+
{{ else if eq .Type "pull_merged" }}
50
+
merged a pull request
51
+
{{ else if eq .Type "pull_closed" }}
52
+
closed a pull request
53
+
{{ else if eq .Type "pull_reopen" }}
54
+
reopened a pull request
55
+
{{ else if eq .Type "followed" }}
56
+
followed you
57
+
{{ else if eq .Type "user_mentioned" }}
58
+
mentioned you
59
+
{{ else }}
60
+
{{ end }}
61
+
{{ end }}
62
+
63
+
{{ define "notificationSummary" }}
64
+
{{ if eq .Type "repo_starred" }}
65
+
<!-- no summary -->
66
+
{{ else if .Issue }}
67
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
68
+
{{ else if .Pull }}
69
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
70
+
{{ else if eq .Type "followed" }}
71
+
<!-- no summary -->
72
+
{{ else }}
73
+
{{ end }}
74
+
{{ end }}
75
+
76
+
{{ define "notificationUrl" }}
77
+
{{ $url := "" }}
78
+
{{ if eq .Type "repo_starred" }}
79
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
80
+
{{ else if .Issue }}
81
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
82
+
{{ else if .Pull }}
83
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
84
+
{{ else if eq .Type "followed" }}
85
+
{{$url = printf "/%s" (resolve .ActorDid)}}
86
+
{{ else }}
87
+
{{ end }}
88
+
89
+
{{ $url }}
90
+
{{ end }}
+65
appview/pages/templates/notifications/list.html
+65
appview/pages/templates/notifications/list.html
···
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" }}
9
+
preferences
10
+
</a>
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" }}
26
+
</div>
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 }}
+14
-14
appview/pages/templates/repo/commit.html
+14
-14
appview/pages/templates/repo/commit.html
···
24
24
</div>
25
25
</div>
26
26
27
-
<div class="flex items-center space-x-2">
28
-
<p class="text-sm text-gray-500 dark:text-gray-300">
29
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
27
+
<div class="flex flex-wrap items-center space-x-2">
28
+
<p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300">
29
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
30
30
31
-
{{ if $didOrHandle }}
32
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
33
33
{{ else }}
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
36
+
36
37
<span class="px-1 select-none before:content-['\00B7']"></span>
37
38
{{ template "repo/fragments/time" $commit.Author.When }}
38
39
<span class="px-1 select-none before:content-['\00B7']"></span>
39
-
</p>
40
40
41
-
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
42
41
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
42
+
43
43
{{ if $commit.Parent }}
44
-
{{ i "arrow-left" "w-3 h-3 mx-1" }}
45
-
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
44
+
{{ i "arrow-left" "w-3 h-3 mx-1" }}
45
+
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
46
46
{{ end }}
47
47
</p>
48
48
···
58
58
<div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div>
59
59
<div class="flex items-center gap-2 my-2">
60
60
{{ i "user" "w-4 h-4" }}
61
-
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
61
+
{{ $committerDid := index $.EmailToDid $commit.Committer.Email }}
62
+
{{ template "user/fragments/picHandleLink" $committerDid }}
63
63
</div>
64
64
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<header class="px-1 col-span-full" style="z-index: 20;">
83
+
<header class="col-span-full" style="z-index: 20;">
84
84
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
88
88
{{ define "mainLayout" }}
89
-
<div class="px-1 col-span-full flex flex-col gap-4">
89
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
90
90
{{ block "contentLayout" . }}
91
91
{{ block "content" . }}{{ end }}
92
92
{{ end }}
···
105
105
{{ end }}
106
106
107
107
{{ define "footerLayout" }}
108
-
<footer class="px-1 col-span-full mt-12">
108
+
<footer class="col-span-full mt-12">
109
109
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
35
35
36
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
-
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
40
</div>
41
41
</div>
+7
appview/pages/templates/repo/fork.html
+7
appview/pages/templates/repo/fork.html
···
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
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
+
9
16
<fieldset class="space-y-3">
10
17
<legend class="dark:text-white">Select a knot to fork into</legend>
11
18
<div class="space-y-2">
+5
-5
appview/pages/templates/repo/fragments/cloneDropdown.html
+5
-5
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
1
{{ define "repo/fragments/cloneDropdown" }}
2
2
{{ $knot := .RepoInfo.Knot }}
3
3
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
4
+
{{ $knot = "tangled.org" }}
5
5
{{ end }}
6
6
7
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
29
<code
30
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code>
34
34
<button
35
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
48
48
<code
49
49
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
50
50
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
51
+
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
53
<button
54
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
55
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
···
5
5
{{ if .Split }}
6
6
{{ $active = "split" }}
7
7
{{ end }}
8
-
{{ $values := list "unified" "split" }}
9
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
8
+
9
+
{{ $unified :=
10
+
(dict
11
+
"Key" "unified"
12
+
"Value" "unified"
13
+
"Icon" "square-split-vertical"
14
+
"Meta" "") }}
15
+
{{ $split :=
16
+
(dict
17
+
"Key" "split"
18
+
"Value" "split"
19
+
"Icon" "square-split-horizontal"
20
+
"Meta" "") }}
21
+
{{ $values := list $unified $split }}
22
+
23
+
{{ template "fragments/tabSelector"
24
+
(dict
25
+
"Name" "diff"
26
+
"Values" $values
27
+
"Active" $active) }}
10
28
</section>
11
29
{{ end }}
12
30
13
-
{{ define "tabSelector" }}
14
-
{{ $name := .Name }}
15
-
{{ $all := .Values }}
16
-
{{ $active := .Active }}
17
-
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
18
-
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
19
-
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
20
-
{{ range $index, $value := $all }}
21
-
{{ $isActive := eq $value $active }}
22
-
<a href="?{{ $name }}={{ $value }}"
23
-
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
24
-
{{ $value }}
25
-
</a>
26
-
{{ end }}
27
-
</div>
28
-
{{ end }}
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
1
-
{{ define "repo/fragments/editRepoDescription" }}
2
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
-
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
5
-
{{ i "check" "w-3 h-3" }} save
6
-
</button>
7
-
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
-
{{ i "x" "w-3 h-3" }} cancel
9
-
</button>
10
-
</form>
11
-
{{ end }}
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
···
1
+
{{ define "repo/fragments/externalLinkPanel" }}
2
+
<div id="at-uri-panel" class="px-2 md:px-0">
3
+
<div class="flex justify-between items-center gap-2">
4
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span>
5
+
<div class="flex items-center gap-2">
6
+
<button
7
+
onclick="copyToClipboard(this)"
8
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
9
+
title="Copy to clipboard">
10
+
{{ i "copy" "w-4 h-4" }}
11
+
</button>
12
+
<a
13
+
href="https://pdsls.dev/{{.}}"
14
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
15
+
title="View in PDSls">
16
+
{{ i "arrow-up-right" "w-4 h-4" }}
17
+
</a>
18
+
</div>
19
+
</div>
20
+
<span
21
+
class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600"
22
+
onclick="window.getSelection().selectAllChildren(this)"
23
+
title="{{.}}"
24
+
data-aturi="{{ . | string | safeUrl }}"
25
+
>{{.}}</span>
26
+
27
+
28
+
</div>
29
+
30
+
<script>
31
+
function copyToClipboard(button) {
32
+
const container = document.getElementById("at-uri-panel");
33
+
const urlSpan = container?.querySelector('[data-aturi]');
34
+
const text = urlSpan?.getAttribute('data-aturi');
35
+
console.log("copying to clipboard", text)
36
+
if (!text) return;
37
+
38
+
navigator.clipboard.writeText(text).then(() => {
39
+
const originalContent = button.innerHTML;
40
+
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
41
+
setTimeout(() => {
42
+
button.innerHTML = originalContent;
43
+
}, 2000);
44
+
});
45
+
}
46
+
</script>
47
+
{{ end }}
48
+
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+9
-1
appview/pages/templates/repo/fragments/og.html
+9
-1
appview/pages/templates/repo/fragments/og.html
···
2
2
{{ $title := or .Title .RepoInfo.FullName }}
3
3
{{ $description := or .Description .RepoInfo.Description }}
4
4
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
-
5
+
{{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }}
6
6
7
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
8
<meta property="og:type" content="object" />
9
9
<meta property="og:url" content="{{ $url }}" />
10
10
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
11
19
{{ end }}
+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
2
<button
3
3
id="reactIndi-{{ .Kind }}"
4
4
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
-
leading-4 px-3 gap-1
5
+
leading-4 px-3 gap-1 relative group
6
6
{{ if eq .Count 0 }}
7
7
hidden
8
8
{{ end }}
···
20
20
dark:hover:border-gray-600
21
21
{{ end }}
22
22
"
23
+
{{ if gt (length .Users) 0 }}
24
+
title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}"
25
+
{{ else }}
26
+
title="{{ .Kind }}"
27
+
{{ end }}
23
28
{{ if .IsReacted }}
24
29
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
25
30
{{ else }}
+2
-2
appview/pages/templates/repo/fragments/readme.html
+2
-2
appview/pages/templates/repo/fragments/readme.html
···
1
1
{{ define "repo/fragments/readme" }}
2
2
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
3
{{- if .ReadmeFileName -}}
4
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
4
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
5
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
6
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
7
</div>
8
8
{{- end -}}
9
9
<section
10
-
class="p-6 overflow-auto {{ if not .Raw }}
10
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
11
11
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
12
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
13
dark:[&_pre]:border dark:[&_pre]:border-gray-700
-15
appview/pages/templates/repo/fragments/repoDescription.html
-15
appview/pages/templates/repo/fragments/repoDescription.html
···
1
-
{{ define "repo/fragments/repoDescription" }}
2
-
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
-
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description | description }}
5
-
{{ else }}
6
-
<span class="italic">this repo has no description</span>
7
-
{{ end }}
8
-
9
-
{{ if .RepoInfo.Roles.IsOwner }}
10
-
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
-
{{ i "pencil" "w-3 h-3" }}
12
-
</button>
13
-
{{ end }}
14
-
</span>
15
-
{{ end }}
+3
-13
appview/pages/templates/repo/index.html
+3
-13
appview/pages/templates/repo/index.html
···
222
222
class="mx-1 before:content-['·'] before:select-none"
223
223
></span>
224
224
<span>
225
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
226
-
<a
227
-
href="{{ if $didOrHandle }}
228
-
/{{ $didOrHandle }}
229
-
{{ else }}
230
-
mailto:{{ .Author.Email }}
231
-
{{ end }}"
225
+
{{ $did := index $.EmailToDid .Author.Email }}
226
+
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
232
227
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
233
-
>{{ if $didOrHandle }}
234
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
235
-
{{ else }}
236
-
{{ .Author.Name }}
237
-
{{ end }}</a
238
-
>
228
+
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
239
229
</span>
240
230
<div class="inline-block px-1 select-none after:content-['·']"></div>
241
231
{{ template "repo/fragments/time" .Committer.When }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
1
+
{{ define "repo/issues/fragments/globalIssueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2 mb-3">
6
+
<div class="flex items-center gap-3 mb-2">
7
+
<a
8
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
+
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
+
>
11
+
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
+
</a>
13
+
</div>
14
+
<a
15
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
+
class="no-underline hover:underline"
17
+
>
18
+
{{ .Title | description }}
19
+
<span class="text-gray-500">#{{ .IssueId }}</span>
20
+
</a>
21
+
</div>
22
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
+
{{ $icon := "ban" }}
25
+
{{ $state := "closed" }}
26
+
{{ if .Open }}
27
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
+
{{ $icon = "circle-dot" }}
29
+
{{ $state = "open" }}
30
+
{{ end }}
31
+
32
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
+
<span class="text-white dark:text-white">{{ $state }}</span>
35
+
</span>
36
+
37
+
<span class="ml-1">
38
+
{{ template "user/fragments/picHandleLink" .Did }}
39
+
</span>
40
+
41
+
<span class="before:content-['·']">
42
+
{{ template "repo/fragments/time" .Created }}
43
+
</span>
44
+
45
+
<span class="before:content-['·']">
46
+
{{ $s := "s" }}
47
+
{{ if eq (len .Comments) 1 }}
48
+
{{ $s = "" }}
49
+
{{ end }}
50
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
+
</span>
52
+
53
+
{{ $state := .Labels }}
54
+
{{ range $k, $d := $.LabelDefs }}
55
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
+
{{ end }}
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
34
34
35
35
{{ define "editIssueComment" }}
36
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
38
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
44
45
45
{{ define "deleteIssueComment" }}
46
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
48
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
1
+
{{ define "repo/issues/fragments/issueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2">
6
+
<a
7
+
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
+
class="no-underline hover:underline"
9
+
>
10
+
{{ .Title | description }}
11
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
12
+
</a>
13
+
</div>
14
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
+
{{ $icon := "ban" }}
17
+
{{ $state := "closed" }}
18
+
{{ if .Open }}
19
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
+
{{ $icon = "circle-dot" }}
21
+
{{ $state = "open" }}
22
+
{{ end }}
23
+
24
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
+
<span class="text-white dark:text-white">{{ $state }}</span>
27
+
</span>
28
+
29
+
<span class="ml-1">
30
+
{{ template "user/fragments/picHandleLink" .Did }}
31
+
</span>
32
+
33
+
<span class="before:content-['·']">
34
+
{{ template "repo/fragments/time" .Created }}
35
+
</span>
36
+
37
+
<span class="before:content-['·']">
38
+
{{ $s := "s" }}
39
+
{{ if eq (len .Comments) 1 }}
40
+
{{ $s = "" }}
41
+
{{ end }}
42
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
+
</span>
44
+
45
+
{{ $state := .Labels }}
46
+
{{ range $k, $d := $.LabelDefs }}
47
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
+
{{ end }}
50
+
{{ end }}
51
+
</div>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
···
138
138
</div>
139
139
</form>
140
140
{{ else }}
141
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
142
-
<a href="/login" class="underline">login</a> to join the discussion
141
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center">
142
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
143
+
sign up
144
+
</a>
145
+
<span class="text-gray-500 dark:text-gray-400">or</span>
146
+
<a href="/login" class="underline">login</a>
147
+
to add to the discussion
143
148
</div>
144
149
{{ end }}
145
150
{{ end }}
+19
appview/pages/templates/repo/issues/fragments/og.html
+19
appview/pages/templates/repo/issues/fragments/og.html
···
1
+
{{ define "repo/issues/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
3
+
{{ $description := or .Issue.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+9
-35
appview/pages/templates/repo/issues/issue.html
+9
-35
appview/pages/templates/repo/issues/issue.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
5
-
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
-
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
-
8
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
5
+
{{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }}
9
6
{{ end }}
10
7
11
8
{{ define "repoContentLayout" }}
···
22
19
"Defs" $.LabelDefs
23
20
"Subject" $.Issue.AtUri
24
21
"State" $.Issue.Labels) }}
25
-
{{ template "issueParticipants" . }}
22
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
+
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
26
24
</div>
27
25
</div>
28
26
{{ end }}
···
87
85
88
86
{{ define "editIssue" }}
89
87
<a
90
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
88
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
91
89
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
92
90
hx-swap="innerHTML"
93
91
hx-target="#issue-{{.Issue.IssueId}}">
···
97
95
98
96
{{ define "deleteIssue" }}
99
97
<a
100
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
98
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
101
99
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
102
100
hx-confirm="Are you sure you want to delete your issue?"
103
101
hx-swap="none">
···
110
108
<div class="flex items-center gap-2">
111
109
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
110
{{ range $kind := .OrderedReactionKinds }}
111
+
{{ $reactionData := index $.Reactions $kind }}
113
112
{{
114
113
template "repo/fragments/reaction"
115
114
(dict
116
115
"Kind" $kind
117
-
"Count" (index $.Reactions $kind)
116
+
"Count" $reactionData.Count
118
117
"IsReacted" (index $.UserReacted $kind)
119
-
"ThreadAt" $.Issue.AtUri)
118
+
"ThreadAt" $.Issue.AtUri
119
+
"Users" $reactionData.Users)
120
120
}}
121
121
{{ end }}
122
122
</div>
123
123
{{ end }}
124
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
125
152
126
{{ define "repoAfter" }}
153
127
<div class="flex flex-col gap-4 mt-4">
+45
-76
appview/pages/templates/repo/issues/issues.html
+45
-76
appview/pages/templates/repo/issues/issues.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
12
-
<div class="flex gap-4">
11
+
{{ $active := "closed" }}
12
+
{{ if .FilteringByOpen }}
13
+
{{ $active = "open" }}
14
+
{{ end }}
15
+
16
+
{{ $open :=
17
+
(dict
18
+
"Key" "open"
19
+
"Value" "open"
20
+
"Icon" "circle-dot"
21
+
"Meta" (string .RepoInfo.Stats.IssueCount.Open)) }}
22
+
{{ $closed :=
23
+
(dict
24
+
"Key" "closed"
25
+
"Value" "closed"
26
+
"Icon" "ban"
27
+
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
28
+
{{ $values := list $open $closed }}
29
+
30
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
31
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
32
+
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
33
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
34
+
{{ i "search" "w-4 h-4" }}
35
+
</div>
36
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
37
+
<a
38
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
39
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
40
+
>
41
+
{{ i "x" "w-4 h-4" }}
42
+
</a>
43
+
</form>
44
+
<div class="sm:row-start-1">
45
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
46
+
</div>
13
47
<a
14
-
href="?state=open"
15
-
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
-
>
17
-
{{ i "circle-dot" "w-4 h-4" }}
18
-
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
19
-
</a>
20
-
<a
21
-
href="?state=closed"
22
-
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
-
>
24
-
{{ i "ban" "w-4 h-4" }}
25
-
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
26
-
</a>
27
-
</div>
28
-
<a
29
48
href="/{{ .RepoInfo.FullName }}/issues/new"
30
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
31
-
>
49
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
50
+
>
32
51
{{ i "circle-plus" "w-4 h-4" }}
33
52
<span>new</span>
34
-
</a>
35
-
</div>
36
-
<div class="error" id="issues"></div>
53
+
</a>
54
+
</div>
55
+
<div class="error" id="issues"></div>
37
56
{{ end }}
38
57
39
58
{{ 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 }}
59
+
<div class="mt-2">
60
+
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
92
61
</div>
93
62
{{ block "pagination" . }} {{ end }}
94
63
{{ end }}
···
105
74
<a
106
75
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
107
76
hx-boost="true"
108
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
77
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
109
78
>
110
79
{{ i "chevron-left" "w-4 h-4" }}
111
80
previous
···
119
88
<a
120
89
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
121
90
hx-boost="true"
122
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
91
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
123
92
>
124
93
next
125
94
{{ i "chevron-right" "w-4 h-4" }}
+6
-6
appview/pages/templates/repo/log.html
+6
-6
appview/pages/templates/repo/log.html
···
27
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
28
<div class="{{ $grid }} py-3">
29
29
<div class="align-top truncate col-span-2">
30
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
-
{{ if $didOrHandle }}
32
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
30
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
33
33
{{ else }}
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
···
153
153
</span>
154
154
<span class="mx-2 before:content-['·'] before:select-none"></span>
155
155
<span>
156
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
157
-
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
156
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
+
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
-
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
159
+
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
160
</a>
161
161
</span>
162
162
<div class="inline-block px-1 select-none after:content-['·']"></div>
+163
-61
appview/pages/templates/repo/new.html
+163
-61
appview/pages/templates/repo/new.html
···
1
1
{{ define "title" }}new repo{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
4
+
<div class="grid grid-cols-12">
5
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
6
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Repositories contain a project's files and version history. All
9
+
repositories are publicly accessible.
10
+
</p>
11
+
</div>
12
+
{{ template "newRepoPanel" . }}
6
13
</div>
7
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
<div class="space-y-2">
10
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
-
<input
12
-
type="text"
13
-
id="name"
14
-
name="name"
15
-
required
16
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
-
/>
18
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
14
+
{{ end }}
19
15
20
-
<label for="branch" class="dark:text-white">Default branch</label>
21
-
<input
22
-
type="text"
23
-
id="branch"
24
-
name="branch"
25
-
value="main"
26
-
required
27
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
-
/>
16
+
{{ define "newRepoPanel" }}
17
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
18
+
{{ template "newRepoForm" . }}
19
+
</div>
20
+
{{ end }}
29
21
30
-
<label for="description" class="dark:text-white">Description</label>
31
-
<input
32
-
type="text"
33
-
id="description"
34
-
name="description"
35
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
-
/>
22
+
{{ define "newRepoForm" }}
23
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
24
+
{{ template "step-1" . }}
25
+
{{ template "step-2" . }}
26
+
27
+
<div class="mt-8 flex justify-end">
28
+
<button type="submit" class="btn-create flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
create repo
31
+
<span id="spinner" class="group">
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</span>
34
+
</button>
37
35
</div>
36
+
<div id="repo" class="error mt-2"></div>
38
37
39
-
<fieldset class="space-y-3">
40
-
<legend class="dark:text-white">Select a knot</legend>
38
+
</form>
39
+
{{ end }}
40
+
41
+
{{ define "step-1" }}
42
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
43
+
<div class="absolute -left-3 -top-0">
44
+
{{ template "numberCircle" 1 }}
45
+
</div>
46
+
47
+
<!-- Content column -->
48
+
<div class="flex-1 pb-12">
49
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
50
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
51
+
41
52
<div class="space-y-2">
42
-
<div class="flex flex-col">
43
-
{{ range .Knots }}
44
-
<div class="flex items-center">
45
-
<input
46
-
type="radio"
47
-
name="domain"
48
-
value="{{ . }}"
49
-
class="mr-2"
50
-
id="domain-{{ . }}"
51
-
/>
52
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
53
-
</div>
54
-
{{ else }}
55
-
<p class="dark:text-white">No knots available.</p>
56
-
{{ end }}
57
-
</div>
53
+
{{ template "name" . }}
54
+
{{ template "description" . }}
58
55
</div>
59
-
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
-
</fieldset>
56
+
</div>
57
+
</div>
58
+
{{ end }}
61
59
62
-
<div class="space-y-2">
63
-
<button type="submit" class="btn-create flex items-center gap-2">
64
-
{{ i "book-plus" "w-4 h-4" }}
65
-
create repo
66
-
<span id="spinner" class="group">
67
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
-
</span>
69
-
</button>
70
-
<div id="repo" class="error"></div>
60
+
{{ define "step-2" }}
61
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
62
+
<div class="absolute -left-3 -top-0">
63
+
{{ template "numberCircle" 2 }}
71
64
</div>
72
-
</form>
73
-
</div>
65
+
66
+
<div class="flex-1">
67
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
68
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
69
+
70
+
<div class="space-y-2">
71
+
{{ template "defaultBranch" . }}
72
+
{{ template "knot" . }}
73
+
</div>
74
+
</div>
75
+
</div>
76
+
{{ end }}
77
+
78
+
{{ define "name" }}
79
+
<!-- Repository Name with Owner -->
80
+
<div>
81
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
82
+
Repository name
83
+
</label>
84
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
85
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
86
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
87
+
</div>
88
+
<input
89
+
type="text"
90
+
id="name"
91
+
name="name"
92
+
required
93
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
94
+
placeholder="repository-name"
95
+
/>
96
+
</div>
97
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
98
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
99
+
</p>
100
+
</div>
101
+
{{ end }}
102
+
103
+
{{ define "description" }}
104
+
<!-- Description -->
105
+
<div>
106
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
107
+
Description
108
+
</label>
109
+
<input
110
+
type="text"
111
+
id="description"
112
+
name="description"
113
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
114
+
placeholder="A brief description of your project..."
115
+
/>
116
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
117
+
Optional. A short description to help others understand what your project does.
118
+
</p>
119
+
</div>
120
+
{{ end }}
121
+
122
+
{{ define "defaultBranch" }}
123
+
<!-- Default Branch -->
124
+
<div>
125
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
126
+
Default branch
127
+
</label>
128
+
<input
129
+
type="text"
130
+
id="branch"
131
+
name="branch"
132
+
value="main"
133
+
required
134
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
135
+
/>
136
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
137
+
The primary branch where development happens. Common choices are "main" or "master".
138
+
</p>
139
+
</div>
140
+
{{ end }}
141
+
142
+
{{ define "knot" }}
143
+
<!-- Knot Selection -->
144
+
<div>
145
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
146
+
Select a knot
147
+
</label>
148
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
149
+
{{ range .Knots }}
150
+
<div class="flex items-center">
151
+
<input
152
+
type="radio"
153
+
name="domain"
154
+
value="{{ . }}"
155
+
class="mr-2"
156
+
id="domain-{{ . }}"
157
+
required
158
+
/>
159
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
+
</div>
161
+
{{ else }}
162
+
<p class="dark:text-white">no knots available.</p>
163
+
{{ end }}
164
+
</div>
165
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
+
A knot hosts repository data and handles Git operations.
167
+
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
</p>
169
+
</div>
170
+
{{ end }}
171
+
172
+
{{ define "numberCircle" }}
173
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
174
+
{{.}}
175
+
</div>
74
176
{{ end }}
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
2
2
<div id="lines" hx-swap-oob="beforeend">
3
3
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
4
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
5
-
<div class="group-open:hidden flex items-center gap-1">
6
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
-
</div>
8
-
<div class="hidden group-open:flex items-center gap-1">
9
-
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
-
</div>
5
+
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
6
+
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
11
7
</summary>
12
8
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
13
9
</details>
14
10
</div>
15
11
{{ end }}
12
+
13
+
{{ define "stepHeader" }}
14
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
15
+
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
16
+
{{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
···
1
+
{{ define "repo/pipelines/fragments/logBlockEnd" }}
2
+
<span
3
+
class="ml-auto text-sm text-gray-500 tabular-nums"
4
+
data-timer="{{ .Id }}"
5
+
data-start="{{ .StartTime.Unix }}"
6
+
data-end="{{ .EndTime.Unix }}"
7
+
hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span>
8
+
{{ end }}
9
+
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
···
12
12
{{ range .Pipelines }}
13
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
14
{{ else }}
15
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
16
-
No pipelines run for this repository.
17
-
</p>
15
+
<div class="py-6 w-fit flex flex-col gap-4 mx-auto">
16
+
<p>
17
+
No pipelines have been run for this repository yet. To get started:
18
+
</p>
19
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
20
+
<p>
21
+
<span class="{{ $bullet }}">1</span>First, choose a spindle in your
22
+
<a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>.
23
+
</p>
24
+
<p>
25
+
<span class="{{ $bullet }}">2</span>Configure your CI/CD
26
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
27
+
</p>
28
+
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
+
</div>
18
30
{{ end }}
19
31
</div>
20
32
</div>
+6
appview/pages/templates/repo/pipelines/workflow.html
+6
appview/pages/templates/repo/pipelines/workflow.html
···
15
15
{{ block "logs" . }} {{ end }}
16
16
</div>
17
17
</section>
18
+
{{ template "fragments/workflow-timers" }}
18
19
{{ end }}
19
20
20
21
{{ define "sidebar" }}
···
58
59
hx-ext="ws"
59
60
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
60
61
<div id="lines" class="flex flex-col gap-2">
62
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded">
63
+
<span class="flex items-center gap-2">
64
+
{{ i "triangle-alert" "size-4" }} No logs for this workflow
65
+
</span>
66
+
</div>
61
67
</div>
62
68
</div>
63
69
{{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
+19
appview/pages/templates/repo/pulls/fragments/og.html
···
1
+
{{ define "repo/pulls/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
3
+
{{ $description := or .Pull.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+81
-72
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-72
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
24
{{ $isUpToDate := .ResubmitCheck.No }}
25
-
<div class="relative w-fit">
26
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
27
-
<button
28
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
29
-
hx-target="#actions-{{$roundNumber}}"
30
-
hx-swap="outerHtml"
31
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
32
-
{{ i "message-square-plus" "w-4 h-4" }}
33
-
<span>comment</span>
34
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
-
</button>
36
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
37
-
{{ $disabled := "" }}
38
-
{{ if $isConflicted }}
39
-
{{ $disabled = "disabled" }}
40
-
{{ end }}
41
-
<button
42
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
43
-
hx-swap="none"
44
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
45
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
46
-
{{ i "git-merge" "w-4 h-4" }}
47
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
48
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
-
</button>
50
-
{{ end }}
25
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative">
26
+
<button
27
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
28
+
hx-target="#actions-{{$roundNumber}}"
29
+
hx-swap="outerHtml"
30
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
31
+
{{ i "message-square-plus" "w-4 h-4" }}
32
+
<span>comment</span>
33
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
34
+
</button>
35
+
{{ if .BranchDeleteStatus }}
36
+
<button
37
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
38
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
39
+
hx-swap="none"
40
+
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">
41
+
{{ i "git-branch" "w-4 h-4" }}
42
+
<span>delete branch</span>
43
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
44
+
</button>
45
+
{{ end }}
46
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
47
+
{{ $disabled := "" }}
48
+
{{ if $isConflicted }}
49
+
{{ $disabled = "disabled" }}
50
+
{{ end }}
51
+
<button
52
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
53
+
hx-swap="none"
54
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
55
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
56
+
{{ i "git-merge" "w-4 h-4" }}
57
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
</button>
60
+
{{ end }}
51
61
52
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
53
-
{{ $disabled := "" }}
54
-
{{ if $isUpToDate }}
55
-
{{ $disabled = "disabled" }}
62
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
63
+
{{ $disabled := "" }}
64
+
{{ if $isUpToDate }}
65
+
{{ $disabled = "disabled" }}
66
+
{{ end }}
67
+
<button id="resubmitBtn"
68
+
{{ if not .Pull.IsPatchBased }}
69
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
70
+
{{ else }}
71
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
72
+
hx-target="#actions-{{$roundNumber}}"
73
+
hx-swap="outerHtml"
56
74
{{ end }}
57
-
<button id="resubmitBtn"
58
-
{{ if not .Pull.IsPatchBased }}
59
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
60
-
{{ else }}
61
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
62
-
hx-target="#actions-{{$roundNumber}}"
63
-
hx-swap="outerHtml"
64
-
{{ end }}
65
75
66
-
hx-disabled-elt="#resubmitBtn"
67
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
68
78
69
-
{{ if $disabled }}
70
-
title="Update this branch to resubmit this pull request"
71
-
{{ else }}
72
-
title="Resubmit this pull request"
73
-
{{ end }}
74
-
>
75
-
{{ i "rotate-ccw" "w-4 h-4" }}
76
-
<span>resubmit</span>
77
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
78
-
</button>
79
-
{{ end }}
79
+
{{ if $disabled }}
80
+
title="Update this branch to resubmit this pull request"
81
+
{{ else }}
82
+
title="Resubmit this pull request"
83
+
{{ end }}
84
+
>
85
+
{{ i "rotate-ccw" "w-4 h-4" }}
86
+
<span>resubmit</span>
87
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
+
</button>
89
+
{{ end }}
80
90
81
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
82
-
<button
83
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
84
-
hx-swap="none"
85
-
class="btn p-2 flex items-center gap-2 group">
86
-
{{ i "ban" "w-4 h-4" }}
87
-
<span>close</span>
88
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
89
-
</button>
90
-
{{ end }}
91
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
92
+
<button
93
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
94
+
hx-swap="none"
95
+
class="btn p-2 flex items-center gap-2 group">
96
+
{{ i "ban" "w-4 h-4" }}
97
+
<span>close</span>
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</button>
100
+
{{ end }}
91
101
92
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
93
-
<button
94
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
95
-
hx-swap="none"
96
-
class="btn p-2 flex items-center gap-2 group">
97
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
98
-
<span>reopen</span>
99
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
-
</button>
101
-
{{ end }}
102
-
</div>
102
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
103
+
<button
104
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
105
+
hx-swap="none"
106
+
class="btn p-2 flex items-center gap-2 group">
107
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
108
+
<span>reopen</span>
109
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
110
+
</button>
111
+
{{ end }}
103
112
</div>
104
113
{{ end }}
105
114
+15
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+15
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
42
42
{{ if not .Pull.IsPatchBased }}
43
43
from
44
44
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
45
-
{{ if .Pull.IsForkBased }}
46
-
{{ if .Pull.PullSource.Repo }}
47
-
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
-
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
-
{{- else -}}
50
-
<span class="italic">[deleted fork]</span>
51
-
{{- end -}}
52
-
{{- end -}}
53
-
{{- .Pull.PullSource.Branch -}}
45
+
{{ if not .Pull.IsForkBased }}
46
+
{{ $repoPath := .RepoInfo.FullName }}
47
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
48
+
{{ else if .Pull.PullSource.Repo }}
49
+
{{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }}
50
+
<a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>:
51
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
52
+
{{ else }}
53
+
<span class="italic">[deleted fork]</span>:
54
+
{{ .Pull.PullSource.Branch }}
55
+
{{ end }}
54
56
</span>
55
57
{{ end }}
56
58
</span>
···
66
68
<div class="flex items-center gap-2 mt-2">
67
69
{{ template "repo/fragments/reactionsPopUp" . }}
68
70
{{ range $kind := . }}
71
+
{{ $reactionData := index $.Reactions $kind }}
69
72
{{
70
73
template "repo/fragments/reaction"
71
74
(dict
72
75
"Kind" $kind
73
-
"Count" (index $.Reactions $kind)
76
+
"Count" $reactionData.Count
74
77
"IsReacted" (index $.UserReacted $kind)
75
-
"ThreadAt" $.Pull.PullAt)
78
+
"ThreadAt" $.Pull.AtUri
79
+
"Users" $reactionData.Users)
76
80
}}
77
81
{{ end }}
78
82
</div>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
3
3
id="pull-comment-card-{{ .RoundNumber }}"
4
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
6
+
{{ resolve .LoggedInUser.Did }}
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
···
28
28
29
29
{{ end }}
30
30
31
-
{{ define "topbarLayout" }}
32
-
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/fragments/topbar" . }}
34
-
</header>
35
-
{{ end }}
36
-
37
31
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex flex-col gap-4">
32
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
39
33
{{ block "contentLayout" . }}
40
34
{{ block "content" . }}{{ end }}
41
35
{{ end }}
···
52
46
{{ end }}
53
47
</div>
54
48
{{ end }}
55
-
56
-
{{ define "footerLayout" }}
57
-
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/fragments/footer" . }}
59
-
</footer>
60
-
{{ end }}
61
-
62
49
63
50
{{ define "contentAfter" }}
64
51
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1
-13
appview/pages/templates/repo/pulls/patch.html
+1
-13
appview/pages/templates/repo/pulls/patch.html
···
34
34
</section>
35
35
{{ end }}
36
36
37
-
{{ define "topbarLayout" }}
38
-
<header class="px-1 col-span-full" style="z-index: 20;">
39
-
{{ template "layouts/fragments/topbar" . }}
40
-
</header>
41
-
{{ end }}
42
-
43
37
{{ define "mainLayout" }}
44
-
<div class="px-1 col-span-full flex flex-col gap-4">
38
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
45
39
{{ block "contentLayout" . }}
46
40
{{ block "content" . }}{{ end }}
47
41
{{ end }}
···
57
51
</div>
58
52
{{ end }}
59
53
</div>
60
-
{{ end }}
61
-
62
-
{{ define "footerLayout" }}
63
-
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/fragments/footer" . }}
65
-
</footer>
66
54
{{ end }}
67
55
68
56
{{ define "contentAfter" }}
+49
-20
appview/pages/templates/repo/pulls/pull.html
+49
-20
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "extrameta" }}
6
-
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
6
+
{{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }}
7
+
{{ end }}
8
8
9
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
9
+
{{ define "repoContentLayout" }}
10
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
11
+
<div class="col-span-1 md:col-span-8">
12
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
13
+
{{ block "repoContent" . }}{{ end }}
14
+
</section>
15
+
{{ block "repoAfter" . }}{{ end }}
16
+
</div>
17
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
18
+
{{ template "repo/fragments/labelPanel"
19
+
(dict "RepoInfo" $.RepoInfo
20
+
"Defs" $.LabelDefs
21
+
"Subject" $.Pull.AtUri
22
+
"State" $.Pull.Labels) }}
23
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
24
+
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
25
+
</div>
26
+
</div>
10
27
{{ end }}
11
-
12
28
13
29
{{ define "repoContent" }}
14
30
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
55
{{ with $item }}
40
56
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
57
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
58
+
<div class="flex flex-wrap gap-2 items-stretch">
43
59
<!-- round number -->
44
60
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
61
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
62
</div>
47
63
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
64
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
65
<span class="gap-1 flex items-center">
50
66
{{ $owner := resolve $.Pull.OwnerDid }}
51
67
{{ $re := "re" }}
···
72
88
<span class="hidden md:inline">diff</span>
73
89
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
90
</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>
91
+
{{ if ne $idx 0 }}
92
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
93
+
hx-boost="true"
94
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
95
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
96
+
<span class="hidden md:inline">interdiff</span>
97
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
98
+
</a>
99
+
{{ end }}
83
100
<span id="interdiff-error-{{.RoundNumber}}"></span>
84
-
{{ end }}
85
101
</div>
86
102
</summary>
87
103
···
146
162
147
163
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
164
{{ 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">
165
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
150
166
{{ if gt $cidx 0 }}
151
167
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
168
{{ end }}
···
169
185
{{ end }}
170
186
171
187
{{ if $.LoggedInUser }}
172
-
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
188
+
{{ template "repo/pulls/fragments/pullActions"
189
+
(dict
190
+
"LoggedInUser" $.LoggedInUser
191
+
"Pull" $.Pull
192
+
"RepoInfo" $.RepoInfo
193
+
"RoundNumber" .RoundNumber
194
+
"MergeCheck" $.MergeCheck
195
+
"ResubmitCheck" $.ResubmitCheck
196
+
"BranchDeleteStatus" $.BranchDeleteStatus
197
+
"Stack" $.Stack) }}
173
198
{{ 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
199
+
<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">
200
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
201
+
sign up
202
+
</a>
203
+
<span class="text-gray-500 dark:text-gray-400">or</span>
204
+
<a href="/login" class="underline">login</a>
205
+
to add to the discussion
177
206
</div>
178
207
{{ end }}
179
208
</div>
+59
-34
appview/pages/templates/repo/pulls/pulls.html
+59
-34
appview/pages/templates/repo/pulls/pulls.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center">
12
-
<div class="flex gap-4">
13
-
<a
14
-
href="?state=open"
15
-
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
-
>
17
-
{{ i "git-pull-request" "w-4 h-4" }}
18
-
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
19
-
</a>
20
-
<a
21
-
href="?state=merged"
22
-
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
-
>
24
-
{{ i "git-merge" "w-4 h-4" }}
25
-
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
26
-
</a>
27
-
<a
28
-
href="?state=closed"
29
-
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
30
-
>
31
-
{{ i "ban" "w-4 h-4" }}
32
-
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
33
-
</a>
34
-
</div>
35
-
<a
36
-
href="/{{ .RepoInfo.FullName }}/pulls/new"
37
-
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
38
-
>
39
-
{{ i "git-pull-request-create" "w-4 h-4" }}
40
-
<span>new</span>
41
-
</a>
11
+
{{ $active := "closed" }}
12
+
{{ if .FilteringBy.IsOpen }}
13
+
{{ $active = "open" }}
14
+
{{ else if .FilteringBy.IsMerged }}
15
+
{{ $active = "merged" }}
16
+
{{ end }}
17
+
{{ $open :=
18
+
(dict
19
+
"Key" "open"
20
+
"Value" "open"
21
+
"Icon" "git-pull-request"
22
+
"Meta" (string .RepoInfo.Stats.PullCount.Open)) }}
23
+
{{ $merged :=
24
+
(dict
25
+
"Key" "merged"
26
+
"Value" "merged"
27
+
"Icon" "git-merge"
28
+
"Meta" (string .RepoInfo.Stats.PullCount.Merged)) }}
29
+
{{ $closed :=
30
+
(dict
31
+
"Key" "closed"
32
+
"Value" "closed"
33
+
"Icon" "ban"
34
+
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
35
+
{{ $values := list $open $merged $closed }}
36
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
37
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
38
+
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
39
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
40
+
{{ i "search" "w-4 h-4" }}
41
+
</div>
42
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
43
+
<a
44
+
href="?state={{ .FilteringBy.String }}"
45
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
46
+
>
47
+
{{ i "x" "w-4 h-4" }}
48
+
</a>
49
+
</form>
50
+
<div class="sm:row-start-1">
51
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
42
52
</div>
43
-
<div class="error" id="pulls"></div>
53
+
<a
54
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
55
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
56
+
>
57
+
{{ i "git-pull-request-create" "w-4 h-4" }}
58
+
<span>new</span>
59
+
</a>
60
+
</div>
61
+
<div class="error" id="pulls"></div>
44
62
{{ end }}
45
63
46
64
{{ define "repoAfter" }}
···
108
126
<span class="before:content-['·']"></span>
109
127
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
128
{{ end }}
129
+
130
+
{{ $state := .Labels }}
131
+
{{ range $k, $d := $.LabelDefs }}
132
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
133
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
134
+
{{ end }}
135
+
{{ end }}
111
136
</div>
112
137
</div>
113
138
{{ if .StackId }}
···
126
151
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
127
152
</div>
128
153
</summary>
129
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
154
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
130
155
</details>
131
156
{{ end }}
132
157
{{ end }}
···
135
160
</div>
136
161
{{ end }}
137
162
138
-
{{ define "pullList" }}
163
+
{{ define "stackedPullList" }}
139
164
{{ $list := index . 0 }}
140
165
{{ $root := index . 1 }}
141
166
<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">
+17
-8
appview/pages/templates/repo/settings/access.html
+17
-8
appview/pages/templates/repo/settings/access.html
···
66
66
<div
67
67
id="add-collaborator-modal"
68
68
popover
69
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
69
+
class="
70
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
71
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
72
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
70
73
{{ template "addCollaboratorModal" . }}
71
74
</div>
72
75
{{ end }}
···
82
85
ADD COLLABORATOR
83
86
</label>
84
87
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
-
<input
86
-
type="text"
87
-
id="add-collaborator"
88
-
name="collaborator"
89
-
required
90
-
placeholder="@foo.bsky.social"
91
-
/>
88
+
<actor-typeahead>
89
+
<input
90
+
autocapitalize="none"
91
+
autocorrect="off"
92
+
autocomplete="off"
93
+
type="text"
94
+
id="add-collaborator"
95
+
name="collaborator"
96
+
required
97
+
placeholder="user.tngl.sh"
98
+
class="w-full"
99
+
/>
100
+
</actor-typeahead>
92
101
<div class="flex gap-2 pt-2">
93
102
<button
94
103
type="button"
+47
appview/pages/templates/repo/settings/general.html
+47
appview/pages/templates/repo/settings/general.html
···
6
6
{{ template "repo/settings/fragments/sidebar" . }}
7
7
</div>
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "baseSettings" . }}
9
10
{{ template "branchSettings" . }}
10
11
{{ template "defaultLabelSettings" . }}
11
12
{{ template "customLabelSettings" . }}
···
13
14
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
15
</div>
15
16
</section>
17
+
{{ end }}
18
+
19
+
{{ define "baseSettings" }}
20
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none">
21
+
<fieldset
22
+
class=""
23
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
24
+
>
25
+
<h2 class="text-sm pb-2 uppercase font-bold">Description</h2>
26
+
<textarea
27
+
rows="3"
28
+
class="w-full mb-2"
29
+
id="base-form-description"
30
+
name="description"
31
+
>{{ .RepoInfo.Description }}</textarea>
32
+
<h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2>
33
+
<input
34
+
type="text"
35
+
class="w-full mb-2"
36
+
id="base-form-website"
37
+
name="website"
38
+
value="{{ .RepoInfo.Website }}"
39
+
>
40
+
<h2 class="text-sm pb-2 uppercase font-bold">Topics</h2>
41
+
<p class="text-gray-500 dark:text-gray-400">
42
+
List of topics separated by spaces.
43
+
</p>
44
+
<textarea
45
+
rows="2"
46
+
class="w-full my-2"
47
+
id="base-form-topics"
48
+
name="topics"
49
+
>{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea>
50
+
<div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div>
51
+
<div class="flex justify-end pt-2">
52
+
<button
53
+
type="submit"
54
+
class="btn-create flex items-center gap-2 group"
55
+
>
56
+
{{ i "save" "w-4 h-4" }}
57
+
save
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
</button>
60
+
</div>
61
+
<fieldset>
62
+
</form>
16
63
{{ end }}
17
64
18
65
{{ define "branchSettings" }}
+1
-1
appview/pages/templates/repo/tree.html
+1
-1
appview/pages/templates/repo/tree.html
+16
-8
appview/pages/templates/spindles/fragments/addMemberModal.html
+16
-8
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
17
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
+2
-2
appview/pages/templates/strings/put.html
+2
-2
appview/pages/templates/strings/put.html
···
3
3
{{ define "content" }}
4
4
<div class="px-6 py-2 mb-4">
5
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
8
{{ else }}
9
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
10
{{ end }}
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
47
47
</span>
48
48
</section>
49
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
50
-
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
50
+
<div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
51
51
<span>
52
52
{{ .String.Filename }}
53
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+5
-7
appview/pages/templates/strings/timeline.html
+5
-7
appview/pages/templates/strings/timeline.html
···
26
26
{{ end }}
27
27
28
28
{{ define "stringCard" }}
29
+
{{ $resolved := resolve .Did.String }}
29
30
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
-
<div class="font-medium dark:text-white flex gap-2 items-center">
31
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
31
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
32
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
33
+
<span class="select-none">/</span>
34
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
32
35
</div>
33
36
{{ with .Description }}
34
37
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
42
45
43
46
{{ define "stringCardInfo" }}
44
47
{{ $stat := .Stats }}
45
-
{{ $resolved := resolve .Did.String }}
46
48
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
-
{{ template "user/fragments/picHandle" $resolved }}
49
-
</a>
50
-
<span class="select-none [&:before]:content-['·']"></span>
51
49
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
50
<span class="select-none [&:before]:content-['·']"></span>
53
51
{{ with .Edited }}
+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 }}
+2
-2
appview/pages/templates/timeline/fragments/hero.html
+2
-2
appview/pages/templates/timeline/fragments/hero.html
···
4
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
5
6
6
<p class="text-lg">
7
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
7
+
Tangled is a decentralized Git hosting and collaboration platform.
8
8
</p>
9
9
<p class="text-lg">
10
-
we envision a place where developers have complete ownership of their
10
+
We envision a place where developers have complete ownership of their
11
11
code, open source communities can freely self-govern and most
12
12
importantly, coding can be social and fun again.
13
13
</p>
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
···
82
82
{{ $event := index . 1 }}
83
83
{{ $follow := $event.Follow }}
84
84
{{ $profile := $event.Profile }}
85
-
{{ $stat := $event.FollowStats }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
86
87
87
88
{{ $userHandle := resolve $follow.UserDid }}
88
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
92
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
93
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
94
95
</div>
95
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
96
-
<div class="flex items-center gap-4 flex-1">
97
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
98
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
99
-
</div>
100
-
101
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
102
-
<a href="/{{ $subjectHandle }}">
103
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
104
-
</a>
105
-
{{ with $profile }}
106
-
{{ with .Description }}
107
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
108
-
{{ end }}
109
-
{{ end }}
110
-
{{ with $stat }}
111
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
112
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
113
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
114
-
<span class="select-none after:content-['·']"></span>
115
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
116
-
</div>
117
-
{{ end }}
118
-
</div>
119
-
</div>
120
-
121
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
122
-
<div class="flex-shrink-0 w-fit ml-auto">
123
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
124
-
</div>
125
-
{{ end }}
126
-
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
127
104
{{ end }}
+1
appview/pages/templates/timeline/home.html
+1
appview/pages/templates/timeline/home.html
···
12
12
<div class="flex flex-col gap-4">
13
13
{{ template "timeline/fragments/hero" . }}
14
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
15
16
{{ template "timeline/fragments/trending" . }}
16
17
{{ template "timeline/fragments/timeline" . }}
17
18
<div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/completeSignup.html
+8
-1
appview/pages/templates/user/followers.html
+8
-1
appview/pages/templates/user/followers.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
23
{{ end }}
+11
appview/pages/templates/user/fragments/editBio.html
+11
appview/pages/templates/user/fragments/editBio.html
···
20
20
</div>
21
21
22
22
<div class="flex flex-col gap-1">
23
+
<label class="m-0 p-0" for="pronouns">pronouns</label>
24
+
<div class="flex items-center gap-2 w-full">
25
+
{{ $pronouns := "" }}
26
+
{{ if and .Profile .Profile.Pronouns }}
27
+
{{ $pronouns = .Profile.Pronouns }}
28
+
{{ end }}
29
+
<input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}">
30
+
</div>
31
+
</div>
32
+
33
+
<div class="flex flex-col gap-1">
23
34
<label class="m-0 p-0" for="location">location</label>
24
35
<div class="flex items-center gap-2 w-full">
25
36
{{ $location := "" }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 flex gap-2 items-center group"
3
+
class="btn w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
6
hx-post="/follow?subject={{.UserDid}}"
···
12
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
13
hx-swap="outerHTML"
14
14
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
16
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
21
</button>
18
22
{{ end }}
+21
-18
appview/pages/templates/user/fragments/followCard.html
+21
-18
appview/pages/templates/user/fragments/followCard.html
···
1
1
{{ define "user/fragments/followCard" }}
2
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
7
</div>
8
8
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['·']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
+
<a href="/{{ $userIdent }}">
12
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
+
</a>
14
+
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
+
{{ end }}
17
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
19
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
20
+
<span class="select-none after:content-['·']"></span>
21
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
22
+
</div>
19
23
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
25
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
24
26
{{ template "user/fragments/follow" . }}
25
27
</div>
26
-
{{ end }}
28
+
{{ end }}
29
+
</div>
27
30
</div>
28
31
</div>
29
-
{{ end }}
32
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
···
1
1
{{ define "user/fragments/picHandleLink" }}
2
-
{{ $resolved := resolve . }}
3
-
<a href="/{{ $resolved }}" class="flex items-center">
4
-
{{ template "user/fragments/picHandle" $resolved }}
2
+
<a href="/{{ resolve . }}" class="flex items-center gap-1">
3
+
{{ template "user/fragments/picHandle" . }}
5
4
</a>
6
5
{{ end }}
+19
-6
appview/pages/templates/user/fragments/profileCard.html
+19
-6
appview/pages/templates/user/fragments/profileCard.html
···
12
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
13
{{ $userIdent }}
14
14
</p>
15
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
15
+
{{ with .Profile }}
16
+
{{ if .Pronouns }}
17
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
+
{{ end }}
19
+
{{ end }}
16
20
</div>
17
21
18
22
<div class="md:hidden">
···
67
71
{{ end }}
68
72
</div>
69
73
{{ end }}
70
-
{{ if ne .FollowStatus.String "IsSelf" }}
71
-
{{ template "user/fragments/follow" . }}
72
-
{{ else }}
74
+
75
+
<div class="flex mt-2 items-center gap-2">
76
+
{{ if ne .FollowStatus.String "IsSelf" }}
77
+
{{ template "user/fragments/follow" . }}
78
+
{{ else }}
73
79
<button id="editBtn"
74
-
class="btn mt-2 w-full flex items-center gap-2 group"
80
+
class="btn w-full flex items-center gap-2 group"
75
81
hx-target="#profile-bio"
76
82
hx-get="/profile/edit-bio"
77
83
hx-swap="innerHTML">
···
79
85
edit
80
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
87
</button>
82
-
{{ end }}
88
+
{{ end }}
89
+
90
+
<a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
91
+
href="/{{ $userIdent }}/feed.atom">
92
+
{{ i "rss" "size-4" }}
93
+
</a>
94
+
</div>
95
+
83
96
</div>
84
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
85
98
</div>
+10
-10
appview/pages/templates/user/fragments/repoCard.html
+10
-10
appview/pages/templates/user/fragments/repoCard.html
···
14
14
{{ with $repo }}
15
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
16
<div class="font-medium dark:text-white flex items-center justify-between">
17
-
<div class="flex items-center">
18
-
{{ if .Source }}
19
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
-
{{ else }}
21
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
-
{{ end }}
23
-
17
+
<div class="flex items-center min-w-0 flex-1 mr-2">
18
+
{{ if .Source }}
19
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
+
{{ else }}
21
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
+
{{ end }}
24
23
{{ $repoOwner := resolve .Did }}
25
24
{{- if $fullName -}}
26
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
25
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
27
26
{{- else -}}
28
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
27
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
29
28
{{- end -}}
30
29
</div>
31
-
32
30
{{ if and $starButton $root.LoggedInUser }}
31
+
<div class="shrink-0">
33
32
{{ template "repo/fragments/repoStar" $starData }}
33
+
</div>
34
34
{{ end }}
35
35
</div>
36
36
{{ with .Description }}
+25
-3
appview/pages/templates/user/login.html
+25
-3
appview/pages/templates/user/login.html
···
8
8
<meta property="og:url" content="https://tangled.org/login" />
9
9
<meta property="og:description" content="login to for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>login · tangled</title>
13
14
</head>
14
15
<body class="flex items-center justify-center min-h-screen">
15
-
<main class="max-w-md px-6 -mt-4">
16
+
<main class="max-w-md px-7 mt-4">
16
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
17
18
{{ template "fragments/logotype" }}
18
19
</h1>
···
20
21
tightly-knit social coding.
21
22
</h2>
22
23
<form
23
-
class="mt-4 max-w-sm mx-auto"
24
+
class="mt-4"
24
25
hx-post="/login"
25
26
hx-swap="none"
26
27
hx-disabled-elt="#login-button"
···
28
29
<div class="flex flex-col">
29
30
<label for="handle">handle</label>
30
31
<input
32
+
autocapitalize="none"
33
+
autocorrect="off"
34
+
autocomplete="username"
31
35
type="text"
32
36
id="handle"
33
37
name="handle"
···
36
40
placeholder="akshay.tngl.sh"
37
41
/>
38
42
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
43
+
Use your <a href="https://atproto.com">AT Protocol</a>
40
44
handle to log in. If you're unsure, this is likely
41
45
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
46
</span>
···
52
56
<span>login</span>
53
57
</button>
54
58
</form>
59
+
{{ if .ErrorCode }}
60
+
<div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300">
61
+
<span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span>
62
+
<div>
63
+
<h5 class="font-medium">Login error</h5>
64
+
<p class="text-sm">
65
+
{{ if eq .ErrorCode "access_denied" }}
66
+
You have not authorized the app.
67
+
{{ else if eq .ErrorCode "session" }}
68
+
Server failed to create user session.
69
+
{{ else }}
70
+
Internal Server error.
71
+
{{ end }}
72
+
Please try again.
73
+
</p>
74
+
</div>
75
+
</div>
76
+
{{ end }}
55
77
<p class="text-sm text-gray-500">
56
78
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
57
79
</p>
+187
appview/pages/templates/user/settings/notifications.html
+187
appview/pages/templates/user/settings/notifications.html
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "notificationSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "notificationSettings" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
25
+
</p>
26
+
</div>
27
+
</div>
28
+
29
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
30
+
31
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
32
+
<div class="flex items-center justify-between p-2">
33
+
<div class="flex items-center gap-2">
34
+
<div class="flex flex-col gap-1">
35
+
<span class="font-bold">Repository starred</span>
36
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
37
+
<span>When someone stars your repository.</span>
38
+
</div>
39
+
</div>
40
+
</div>
41
+
<label class="flex items-center gap-2">
42
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
43
+
</label>
44
+
</div>
45
+
46
+
<div class="flex items-center justify-between p-2">
47
+
<div class="flex items-center gap-2">
48
+
<div class="flex flex-col gap-1">
49
+
<span class="font-bold">New issues</span>
50
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
51
+
<span>When someone creates an issue on your repository.</span>
52
+
</div>
53
+
</div>
54
+
</div>
55
+
<label class="flex items-center gap-2">
56
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
57
+
</label>
58
+
</div>
59
+
60
+
<div class="flex items-center justify-between p-2">
61
+
<div class="flex items-center gap-2">
62
+
<div class="flex flex-col gap-1">
63
+
<span class="font-bold">Issue comments</span>
64
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
65
+
<span>When someone comments on an issue you're involved with.</span>
66
+
</div>
67
+
</div>
68
+
</div>
69
+
<label class="flex items-center gap-2">
70
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
71
+
</label>
72
+
</div>
73
+
74
+
<div class="flex items-center justify-between p-2">
75
+
<div class="flex items-center gap-2">
76
+
<div class="flex flex-col gap-1">
77
+
<span class="font-bold">Issue closed</span>
78
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
79
+
<span>When an issue on your repository is closed.</span>
80
+
</div>
81
+
</div>
82
+
</div>
83
+
<label class="flex items-center gap-2">
84
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
85
+
</label>
86
+
</div>
87
+
88
+
<div class="flex items-center justify-between p-2">
89
+
<div class="flex items-center gap-2">
90
+
<div class="flex flex-col gap-1">
91
+
<span class="font-bold">New pull requests</span>
92
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
93
+
<span>When someone creates a pull request on your repository.</span>
94
+
</div>
95
+
</div>
96
+
</div>
97
+
<label class="flex items-center gap-2">
98
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
99
+
</label>
100
+
</div>
101
+
102
+
<div class="flex items-center justify-between p-2">
103
+
<div class="flex items-center gap-2">
104
+
<div class="flex flex-col gap-1">
105
+
<span class="font-bold">Pull request comments</span>
106
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
107
+
<span>When someone comments on a pull request you're involved with.</span>
108
+
</div>
109
+
</div>
110
+
</div>
111
+
<label class="flex items-center gap-2">
112
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
113
+
</label>
114
+
</div>
115
+
116
+
<div class="flex items-center justify-between p-2">
117
+
<div class="flex items-center gap-2">
118
+
<div class="flex flex-col gap-1">
119
+
<span class="font-bold">Pull request merged</span>
120
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
121
+
<span>When your pull request is merged.</span>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
<label class="flex items-center gap-2">
126
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
127
+
</label>
128
+
</div>
129
+
130
+
<div class="flex items-center justify-between p-2">
131
+
<div class="flex items-center gap-2">
132
+
<div class="flex flex-col gap-1">
133
+
<span class="font-bold">New followers</span>
134
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
135
+
<span>When someone follows you.</span>
136
+
</div>
137
+
</div>
138
+
</div>
139
+
<label class="flex items-center gap-2">
140
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
141
+
</label>
142
+
</div>
143
+
144
+
<div class="flex items-center justify-between p-2">
145
+
<div class="flex items-center gap-2">
146
+
<div class="flex flex-col gap-1">
147
+
<span class="font-bold">Mentions</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>When someone mentions you.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
158
+
<div class="flex items-center justify-between p-2">
159
+
<div class="flex items-center gap-2">
160
+
<div class="flex flex-col gap-1">
161
+
<span class="font-bold">Email notifications</span>
162
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
163
+
<span>Receive notifications via email in addition to in-app notifications.</span>
164
+
</div>
165
+
</div>
166
+
</div>
167
+
<label class="flex items-center gap-2">
168
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
169
+
</label>
170
+
</div>
171
+
</div>
172
+
173
+
<div class="flex justify-end pt-2">
174
+
<button
175
+
type="submit"
176
+
class="btn-create flex items-center gap-2 group"
177
+
>
178
+
{{ i "save" "w-4 h-4" }}
179
+
save
180
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
181
+
</button>
182
+
</div>
183
+
<div id="settings-notifications-success"></div>
184
+
185
+
<div id="settings-notifications-error" class="error"></div>
186
+
</form>
187
+
{{ end }}
+1
-3
appview/pages/templates/user/settings/profile.html
+1
-3
appview/pages/templates/user/settings/profile.html
···
33
33
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
34
<span>Handle</span>
35
35
</div>
36
-
{{ if .LoggedInUser.Handle }}
37
36
<span class="font-bold">
38
-
@{{ .LoggedInUser.Handle }}
37
+
{{ resolve .LoggedInUser.Did }}
39
38
</span>
40
-
{{ end }}
41
39
</div>
42
40
</div>
43
41
<div class="flex items-center justify-between p-4">
+7
-1
appview/pages/templates/user/signup.html
+7
-1
appview/pages/templates/user/signup.html
···
8
8
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>sign up · tangled</title>
14
+
15
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
13
16
</head>
14
17
<body class="flex items-center justify-center min-h-screen">
15
18
<main class="max-w-md px-6 -mt-4">
···
39
42
invite code, desired username, and password in the next
40
43
page to complete your registration.
41
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
+
</div>
42
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
43
49
<span>join now</span>
44
50
</button>
45
51
</form>
46
52
<p class="text-sm text-gray-500">
47
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
53
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
48
54
</p>
49
55
50
56
<p id="signup-msg" class="error w-full"></p>
+47
-1
appview/pagination/page.go
+47
-1
appview/pagination/page.go
···
1
1
package pagination
2
2
3
+
import "context"
4
+
3
5
type Page struct {
4
6
Offset int // where to start from
5
7
Limit int // number of items in a page
···
8
10
func FirstPage() Page {
9
11
return Page{
10
12
Offset: 0,
11
-
Limit: 10,
13
+
Limit: 30,
12
14
}
13
15
}
14
16
17
+
type ctxKey struct{}
18
+
19
+
func IntoContext(ctx context.Context, page Page) context.Context {
20
+
return context.WithValue(ctx, ctxKey{}, page)
21
+
}
22
+
23
+
func FromContext(ctx context.Context) Page {
24
+
if ctx == nil {
25
+
return FirstPage()
26
+
}
27
+
v := ctx.Value(ctxKey{})
28
+
if v == nil {
29
+
return FirstPage()
30
+
}
31
+
page, ok := v.(Page)
32
+
if !ok {
33
+
return FirstPage()
34
+
}
35
+
return page
36
+
}
37
+
15
38
func (p Page) Previous() Page {
16
39
if p.Offset-p.Limit < 0 {
17
40
return FirstPage()
···
29
52
Limit: p.Limit,
30
53
}
31
54
}
55
+
56
+
func IterateAll[T any](
57
+
fetch func(page Page) ([]T, error),
58
+
handle func(items []T) error,
59
+
) error {
60
+
page := FirstPage()
61
+
for {
62
+
items, err := fetch(page)
63
+
if err != nil {
64
+
return err
65
+
}
66
+
67
+
err = handle(items)
68
+
if err != nil {
69
+
return err
70
+
}
71
+
if len(items) < page.Limit {
72
+
break
73
+
}
74
+
page = page.Next()
75
+
}
76
+
return nil
77
+
}
+37
-17
appview/pipelines/pipelines.go
+37
-17
appview/pipelines/pipelines.go
···
16
16
"tangled.org/core/appview/reporesolver"
17
17
"tangled.org/core/eventconsumer"
18
18
"tangled.org/core/idresolver"
19
-
"tangled.org/core/log"
20
19
"tangled.org/core/rbac"
21
20
spindlemodel "tangled.org/core/spindle/models"
22
21
···
36
35
logger *slog.Logger
37
36
}
38
37
38
+
func (p *Pipelines) Router() http.Handler {
39
+
r := chi.NewRouter()
40
+
r.Get("/", p.Index)
41
+
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
42
+
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
43
+
44
+
return r
45
+
}
46
+
39
47
func New(
40
48
oauth *oauth.OAuth,
41
49
repoResolver *reporesolver.RepoResolver,
···
45
53
db *db.DB,
46
54
config *config.Config,
47
55
enforcer *rbac.Enforcer,
56
+
logger *slog.Logger,
48
57
) *Pipelines {
49
-
logger := log.New("pipelines")
50
-
51
-
return &Pipelines{oauth: oauth,
58
+
return &Pipelines{
59
+
oauth: oauth,
52
60
repoResolver: repoResolver,
53
61
pages: pages,
54
62
idResolver: idResolver,
···
228
236
// start a goroutine to read from spindle
229
237
go readLogs(spindleConn, evChan)
230
238
231
-
stepIdx := 0
239
+
stepStartTimes := make(map[int]time.Time)
232
240
var fragment bytes.Buffer
233
241
for {
234
242
select {
···
260
268
261
269
switch logLine.Kind {
262
270
case spindlemodel.LogKindControl:
263
-
// control messages create a new step block
264
-
stepIdx++
265
-
collapsed := false
266
-
if logLine.StepKind == spindlemodel.StepKindSystem {
267
-
collapsed = true
271
+
switch logLine.StepStatus {
272
+
case spindlemodel.StepStatusStart:
273
+
stepStartTimes[logLine.StepId] = logLine.Time
274
+
collapsed := false
275
+
if logLine.StepKind == spindlemodel.StepKindSystem {
276
+
collapsed = true
277
+
}
278
+
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
279
+
Id: logLine.StepId,
280
+
Name: logLine.Content,
281
+
Command: logLine.StepCommand,
282
+
Collapsed: collapsed,
283
+
StartTime: logLine.Time,
284
+
})
285
+
case spindlemodel.StepStatusEnd:
286
+
startTime := stepStartTimes[logLine.StepId]
287
+
endTime := logLine.Time
288
+
err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{
289
+
Id: logLine.StepId,
290
+
StartTime: startTime,
291
+
EndTime: endTime,
292
+
})
268
293
}
269
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
270
-
Id: stepIdx,
271
-
Name: logLine.Content,
272
-
Command: logLine.StepCommand,
273
-
Collapsed: collapsed,
274
-
})
294
+
275
295
case spindlemodel.LogKindData:
276
296
// data messages simply insert new log lines into current step
277
297
err = p.pages.LogLine(&fragment, pages.LogLineParams{
278
-
Id: stepIdx,
298
+
Id: logLine.StepId,
279
299
Content: logLine.Content,
280
300
})
281
301
}
-17
appview/pipelines/router.go
-17
appview/pipelines/router.go
···
1
-
package pipelines
2
-
3
-
import (
4
-
"net/http"
5
-
6
-
"github.com/go-chi/chi/v5"
7
-
"tangled.org/core/appview/middleware"
8
-
)
9
-
10
-
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
11
-
r := chi.NewRouter()
12
-
r.Get("/", p.Index)
13
-
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
14
-
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
15
-
16
-
return r
17
-
}
-164
appview/posthog/notifier.go
-164
appview/posthog/notifier.go
···
1
-
package posthog_service
2
-
3
-
import (
4
-
"context"
5
-
"log"
6
-
7
-
"github.com/posthog/posthog-go"
8
-
"tangled.org/core/appview/models"
9
-
"tangled.org/core/appview/notify"
10
-
)
11
-
12
-
type posthogNotifier struct {
13
-
client posthog.Client
14
-
notify.BaseNotifier
15
-
}
16
-
17
-
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
-
return &posthogNotifier{
19
-
client,
20
-
notify.BaseNotifier{},
21
-
}
22
-
}
23
-
24
-
var _ notify.Notifier = &posthogNotifier{}
25
-
26
-
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
27
-
err := n.client.Enqueue(posthog.Capture{
28
-
DistinctId: repo.Did,
29
-
Event: "new_repo",
30
-
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
-
})
32
-
if err != nil {
33
-
log.Println("failed to enqueue posthog event:", err)
34
-
}
35
-
}
36
-
37
-
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
38
-
err := n.client.Enqueue(posthog.Capture{
39
-
DistinctId: star.StarredByDid,
40
-
Event: "star",
41
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
-
})
43
-
if err != nil {
44
-
log.Println("failed to enqueue posthog event:", err)
45
-
}
46
-
}
47
-
48
-
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
49
-
err := n.client.Enqueue(posthog.Capture{
50
-
DistinctId: star.StarredByDid,
51
-
Event: "unstar",
52
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
-
})
54
-
if err != nil {
55
-
log.Println("failed to enqueue posthog event:", err)
56
-
}
57
-
}
58
-
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
-
err := n.client.Enqueue(posthog.Capture{
61
-
DistinctId: issue.Did,
62
-
Event: "new_issue",
63
-
Properties: posthog.Properties{
64
-
"repo_at": issue.RepoAt.String(),
65
-
"issue_id": issue.IssueId,
66
-
},
67
-
})
68
-
if err != nil {
69
-
log.Println("failed to enqueue posthog event:", err)
70
-
}
71
-
}
72
-
73
-
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
74
-
err := n.client.Enqueue(posthog.Capture{
75
-
DistinctId: pull.OwnerDid,
76
-
Event: "new_pull",
77
-
Properties: posthog.Properties{
78
-
"repo_at": pull.RepoAt,
79
-
"pull_id": pull.PullId,
80
-
},
81
-
})
82
-
if err != nil {
83
-
log.Println("failed to enqueue posthog event:", err)
84
-
}
85
-
}
86
-
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
88
-
err := n.client.Enqueue(posthog.Capture{
89
-
DistinctId: comment.OwnerDid,
90
-
Event: "new_pull_comment",
91
-
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
-
},
95
-
})
96
-
if err != nil {
97
-
log.Println("failed to enqueue posthog event:", err)
98
-
}
99
-
}
100
-
101
-
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
102
-
err := n.client.Enqueue(posthog.Capture{
103
-
DistinctId: follow.UserDid,
104
-
Event: "follow",
105
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
-
})
107
-
if err != nil {
108
-
log.Println("failed to enqueue posthog event:", err)
109
-
}
110
-
}
111
-
112
-
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
113
-
err := n.client.Enqueue(posthog.Capture{
114
-
DistinctId: follow.UserDid,
115
-
Event: "unfollow",
116
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
-
})
118
-
if err != nil {
119
-
log.Println("failed to enqueue posthog event:", err)
120
-
}
121
-
}
122
-
123
-
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
124
-
err := n.client.Enqueue(posthog.Capture{
125
-
DistinctId: profile.Did,
126
-
Event: "edit_profile",
127
-
})
128
-
if err != nil {
129
-
log.Println("failed to enqueue posthog event:", err)
130
-
}
131
-
}
132
-
133
-
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
134
-
err := n.client.Enqueue(posthog.Capture{
135
-
DistinctId: did,
136
-
Event: "delete_string",
137
-
Properties: posthog.Properties{"rkey": rkey},
138
-
})
139
-
if err != nil {
140
-
log.Println("failed to enqueue posthog event:", err)
141
-
}
142
-
}
143
-
144
-
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
145
-
err := n.client.Enqueue(posthog.Capture{
146
-
DistinctId: string.Did.String(),
147
-
Event: "edit_string",
148
-
Properties: posthog.Properties{"rkey": string.Rkey},
149
-
})
150
-
if err != nil {
151
-
log.Println("failed to enqueue posthog event:", err)
152
-
}
153
-
}
154
-
155
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
156
-
err := n.client.Enqueue(posthog.Capture{
157
-
DistinctId: string.Did.String(),
158
-
Event: "create_string",
159
-
Properties: posthog.Properties{"rkey": string.Rkey},
160
-
})
161
-
if err != nil {
162
-
log.Println("failed to enqueue posthog event:", err)
163
-
}
164
-
}
+321
appview/pulls/opengraph.go
+321
appview/pulls/opengraph.go
···
1
+
package pulls
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/ogcard"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
18
+
)
19
+
20
+
func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) {
21
+
width, height := ogcard.DefaultSize()
22
+
mainCard, err := ogcard.NewCard(width, height)
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
// Split: content area (75%) and status/stats area (25%)
28
+
contentCard, statsArea := mainCard.Split(false, 75)
29
+
30
+
// Add padding to content
31
+
contentCard.SetMargin(50)
32
+
33
+
// Split content horizontally: main content (80%) and avatar area (20%)
34
+
mainContent, avatarArea := contentCard.Split(true, 80)
35
+
36
+
// Add margin to main content
37
+
mainContent.SetMargin(10)
38
+
39
+
// Use full main content area for repo name and title
40
+
bounds := mainContent.Img.Bounds()
41
+
startX := bounds.Min.X + mainContent.Margin
42
+
startY := bounds.Min.Y + mainContent.Margin
43
+
44
+
// Draw full repository name at top (owner/repo format)
45
+
var repoOwner string
46
+
owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did)
47
+
if err != nil {
48
+
repoOwner = repo.Did
49
+
} else {
50
+
repoOwner = "@" + owner.Handle.String()
51
+
}
52
+
53
+
fullRepoName := repoOwner + " / " + repo.Name
54
+
if len(fullRepoName) > 60 {
55
+
fullRepoName = fullRepoName[:60] + "…"
56
+
}
57
+
58
+
grayColor := color.RGBA{88, 96, 105, 255}
59
+
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
60
+
if err != nil {
61
+
return nil, err
62
+
}
63
+
64
+
// Draw pull request title below repo name with wrapping
65
+
titleY := startY + 60
66
+
titleX := startX
67
+
68
+
// Truncate title if too long
69
+
pullTitle := pull.Title
70
+
maxTitleLength := 80
71
+
if len(pullTitle) > maxTitleLength {
72
+
pullTitle = pullTitle[:maxTitleLength] + "…"
73
+
}
74
+
75
+
// Create a temporary card for the title area to enable wrapping
76
+
titleBounds := mainContent.Img.Bounds()
77
+
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
78
+
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID
79
+
80
+
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
81
+
titleCard := &ogcard.Card{
82
+
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
83
+
Font: mainContent.Font,
84
+
Margin: 0,
85
+
}
86
+
87
+
// Draw wrapped title
88
+
lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left)
89
+
if err != nil {
90
+
return nil, err
91
+
}
92
+
93
+
// Calculate where title ends (number of lines * line height)
94
+
lineHeight := 60 // Approximate line height for 54pt font
95
+
titleEndY := titleY + (len(lines) * lineHeight) + 10
96
+
97
+
// Draw pull ID in gray below the title
98
+
pullIdText := fmt.Sprintf("#%d", pull.PullId)
99
+
err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
// Get pull author handle (needed for avatar and metadata)
105
+
var authorHandle string
106
+
author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid)
107
+
if err != nil {
108
+
authorHandle = pull.OwnerDid
109
+
} else {
110
+
authorHandle = "@" + author.Handle.String()
111
+
}
112
+
113
+
// Draw avatar circle on the right side
114
+
avatarBounds := avatarArea.Img.Bounds()
115
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
116
+
if avatarSize > 220 {
117
+
avatarSize = 220
118
+
}
119
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
120
+
avatarY := avatarBounds.Min.Y + 20
121
+
122
+
// Get avatar URL for pull author
123
+
avatarURL := s.pages.AvatarUrl(authorHandle, "256")
124
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
125
+
if err != nil {
126
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
127
+
}
128
+
129
+
// Split stats area: left side for status/stats (80%), right side for dolly (20%)
130
+
statusStatsArea, dollyArea := statsArea.Split(true, 80)
131
+
132
+
// Draw status and stats
133
+
statsBounds := statusStatsArea.Img.Bounds()
134
+
statsX := statsBounds.Min.X + 60 // left padding
135
+
statsY := statsBounds.Min.Y
136
+
137
+
iconColor := color.RGBA{88, 96, 105, 255}
138
+
iconSize := 36
139
+
textSize := 36.0
140
+
labelSize := 28.0
141
+
iconBaselineOffset := int(textSize) / 2
142
+
143
+
// Draw status (open/merged/closed) with colored icon and text
144
+
var statusIcon string
145
+
var statusText string
146
+
var statusColor color.RGBA
147
+
148
+
if pull.State.IsOpen() {
149
+
statusIcon = "git-pull-request"
150
+
statusText = "open"
151
+
statusColor = color.RGBA{34, 139, 34, 255} // green
152
+
} else if pull.State.IsMerged() {
153
+
statusIcon = "git-merge"
154
+
statusText = "merged"
155
+
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
+
} else {
157
+
statusIcon = "git-pull-request-closed"
158
+
statusText = "closed"
159
+
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
+
}
161
+
162
+
statusIconSize := 36
163
+
164
+
// Draw icon with status color
165
+
err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
+
if err != nil {
167
+
log.Printf("failed to draw status icon: %v", err)
168
+
}
169
+
170
+
// Draw text with status color
171
+
textX := statsX + statusIconSize + 12
172
+
statusTextSize := 32.0
173
+
err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
174
+
if err != nil {
175
+
log.Printf("failed to draw status text: %v", err)
176
+
}
177
+
178
+
statusTextWidth := len(statusText) * 20
179
+
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
+
181
+
// Draw comment count
182
+
err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
+
if err != nil {
184
+
log.Printf("failed to draw comment icon: %v", err)
185
+
}
186
+
187
+
currentX += iconSize + 15
188
+
commentText := fmt.Sprintf("%d comments", commentCount)
189
+
if commentCount == 1 {
190
+
commentText = "1 comment"
191
+
}
192
+
err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
193
+
if err != nil {
194
+
log.Printf("failed to draw comment text: %v", err)
195
+
}
196
+
197
+
commentTextWidth := len(commentText) * 20
198
+
currentX += commentTextWidth + 40
199
+
200
+
// Draw files changed
201
+
err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
+
if err != nil {
203
+
log.Printf("failed to draw file diff icon: %v", err)
204
+
}
205
+
206
+
currentX += iconSize + 15
207
+
filesText := fmt.Sprintf("%d files", filesChanged)
208
+
if filesChanged == 1 {
209
+
filesText = "1 file"
210
+
}
211
+
err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
212
+
if err != nil {
213
+
log.Printf("failed to draw files text: %v", err)
214
+
}
215
+
216
+
filesTextWidth := len(filesText) * 20
217
+
currentX += filesTextWidth
218
+
219
+
// Draw additions (green +)
220
+
greenColor := color.RGBA{34, 139, 34, 255}
221
+
additionsText := fmt.Sprintf("+%d", diffStats.Insertions)
222
+
err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
223
+
if err != nil {
224
+
log.Printf("failed to draw additions text: %v", err)
225
+
}
226
+
227
+
additionsTextWidth := len(additionsText) * 20
228
+
currentX += additionsTextWidth + 30
229
+
230
+
// Draw deletions (red -) right next to additions
231
+
redColor := color.RGBA{220, 20, 60, 255}
232
+
deletionsText := fmt.Sprintf("-%d", diffStats.Deletions)
233
+
err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
234
+
if err != nil {
235
+
log.Printf("failed to draw deletions text: %v", err)
236
+
}
237
+
238
+
// Draw dolly logo on the right side
239
+
dollyBounds := dollyArea.Img.Bounds()
240
+
dollySize := 90
241
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
245
+
if err != nil {
246
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
+
}
248
+
249
+
// Draw "opened by @author" and date at the bottom with more spacing
250
+
labelY := statsY + iconSize + 30
251
+
252
+
// Format the opened date
253
+
openedDate := pull.Created.Format("Jan 2, 2006")
254
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
255
+
256
+
err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
257
+
if err != nil {
258
+
log.Printf("failed to draw metadata: %v", err)
259
+
}
260
+
261
+
return mainCard, nil
262
+
}
263
+
264
+
func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
265
+
f, err := s.repoResolver.Resolve(r)
266
+
if err != nil {
267
+
log.Println("failed to get repo and knot", err)
268
+
return
269
+
}
270
+
271
+
pull, ok := r.Context().Value("pull").(*models.Pull)
272
+
if !ok {
273
+
log.Println("pull not found in context")
274
+
http.Error(w, "pull not found", http.StatusNotFound)
275
+
return
276
+
}
277
+
278
+
// Get comment count from database
279
+
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
+
if err != nil {
281
+
log.Printf("failed to get pull comments: %v", err)
282
+
}
283
+
commentCount := len(comments)
284
+
285
+
// Calculate diff stats from latest submission using patchutil
286
+
var diffStats types.DiffStat
287
+
filesChanged := 0
288
+
if len(pull.Submissions) > 0 {
289
+
latestSubmission := pull.Submissions[len(pull.Submissions)-1]
290
+
niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
291
+
diffStats.Insertions = int64(niceDiff.Stat.Insertions)
292
+
diffStats.Deletions = int64(niceDiff.Stat.Deletions)
293
+
filesChanged = niceDiff.Stat.FilesChanged
294
+
}
295
+
296
+
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297
+
if err != nil {
298
+
log.Println("failed to draw pull summary card", err)
299
+
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
300
+
return
301
+
}
302
+
303
+
var imageBuffer bytes.Buffer
304
+
err = png.Encode(&imageBuffer, card.Img)
305
+
if err != nil {
306
+
log.Println("failed to encode pull summary card", err)
307
+
http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
308
+
return
309
+
}
310
+
311
+
imageBytes := imageBuffer.Bytes()
312
+
313
+
w.Header().Set("Content-Type", "image/png")
314
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
315
+
w.WriteHeader(http.StatusOK)
316
+
_, err = w.Write(imageBytes)
317
+
if err != nil {
318
+
log.Println("failed to write pull summary card", err)
319
+
return
320
+
}
321
+
}
+289
-194
appview/pulls/pulls.go
+289
-194
appview/pulls/pulls.go
···
6
6
"errors"
7
7
"fmt"
8
8
"log"
9
+
"log/slog"
9
10
"net/http"
11
+
"slices"
10
12
"sort"
11
13
"strconv"
12
14
"strings"
···
15
17
"tangled.org/core/api/tangled"
16
18
"tangled.org/core/appview/config"
17
19
"tangled.org/core/appview/db"
20
+
pulls_indexer "tangled.org/core/appview/indexer/pulls"
18
21
"tangled.org/core/appview/models"
19
22
"tangled.org/core/appview/notify"
20
23
"tangled.org/core/appview/oauth"
21
24
"tangled.org/core/appview/pages"
22
25
"tangled.org/core/appview/pages/markup"
23
26
"tangled.org/core/appview/reporesolver"
27
+
"tangled.org/core/appview/validator"
24
28
"tangled.org/core/appview/xrpcclient"
25
29
"tangled.org/core/idresolver"
26
30
"tangled.org/core/patchutil"
31
+
"tangled.org/core/rbac"
27
32
"tangled.org/core/tid"
28
33
"tangled.org/core/types"
29
34
30
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
31
35
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
+
"github.com/bluesky-social/indigo/atproto/syntax"
32
37
lexutil "github.com/bluesky-social/indigo/lex/util"
33
38
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
34
39
"github.com/go-chi/chi/v5"
···
43
48
db *db.DB
44
49
config *config.Config
45
50
notifier notify.Notifier
51
+
enforcer *rbac.Enforcer
52
+
logger *slog.Logger
53
+
validator *validator.Validator
54
+
indexer *pulls_indexer.Indexer
46
55
}
47
56
48
57
func New(
···
53
62
db *db.DB,
54
63
config *config.Config,
55
64
notifier notify.Notifier,
65
+
enforcer *rbac.Enforcer,
66
+
validator *validator.Validator,
67
+
indexer *pulls_indexer.Indexer,
68
+
logger *slog.Logger,
56
69
) *Pulls {
57
70
return &Pulls{
58
71
oauth: oauth,
···
62
75
db: db,
63
76
config: config,
64
77
notifier: notifier,
78
+
enforcer: enforcer,
79
+
logger: logger,
80
+
validator: validator,
81
+
indexer: indexer,
65
82
}
66
83
}
67
84
···
98
115
}
99
116
100
117
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
118
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
101
119
resubmitResult := pages.Unknown
102
120
if user.Did == pull.OwnerDid {
103
121
resubmitResult = s.resubmitCheck(r, f, pull, stack)
104
122
}
105
123
106
124
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,
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(user),
127
+
Pull: pull,
128
+
RoundNumber: roundNumber,
129
+
MergeCheck: mergeCheckResponse,
130
+
ResubmitCheck: resubmitResult,
131
+
BranchDeleteStatus: branchDeleteStatus,
132
+
Stack: stack,
114
133
})
115
134
return
116
135
}
···
135
154
stack, _ := r.Context().Value("stack").(models.Stack)
136
155
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
137
156
138
-
totalIdents := 1
139
-
for _, submission := range pull.Submissions {
140
-
totalIdents += len(submission.Comments)
141
-
}
142
-
143
-
identsToResolve := make([]string, totalIdents)
144
-
145
-
// populate idents
146
-
identsToResolve[0] = pull.OwnerDid
147
-
idx := 1
148
-
for _, submission := range pull.Submissions {
149
-
for _, comment := range submission.Comments {
150
-
identsToResolve[idx] = comment.OwnerDid
151
-
idx += 1
152
-
}
153
-
}
154
-
155
157
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
158
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
156
159
resubmitResult := pages.Unknown
157
160
if user != nil && user.Did == pull.OwnerDid {
158
161
resubmitResult = s.resubmitCheck(r, f, pull, stack)
···
189
192
m[p.Sha] = p
190
193
}
191
194
192
-
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
195
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
193
196
if err != nil {
194
197
log.Println("failed to get pull reactions")
195
198
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
197
200
198
201
userReactions := map[models.ReactionKind]bool{}
199
202
if user != nil {
200
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
203
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
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
201
220
}
202
221
203
222
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,
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,
212
232
213
233
OrderedReactionKinds: models.OrderedReactionKinds,
214
-
Reactions: reactionCountMap,
234
+
Reactions: reactionMap,
215
235
UserReacted: userReactions,
236
+
237
+
LabelDefs: defs,
216
238
})
217
239
}
218
240
···
283
305
return result
284
306
}
285
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
+
// deleted fork
333
+
if repo == nil {
334
+
return nil
335
+
}
336
+
337
+
// user can only delete branch if they are a collaborator in the repo that the branch belongs to
338
+
perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo())
339
+
if !slices.Contains(perms, "repo:push") {
340
+
return nil
341
+
}
342
+
343
+
scheme := "http"
344
+
if !s.config.Core.Dev {
345
+
scheme = "https"
346
+
}
347
+
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
348
+
xrpcc := &indigoxrpc.Client{
349
+
Host: host,
350
+
}
351
+
352
+
resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name))
353
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
354
+
return nil
355
+
}
356
+
357
+
return &models.BranchDeleteStatus{
358
+
Repo: repo,
359
+
Branch: resp.Name,
360
+
}
361
+
}
362
+
286
363
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
287
364
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
288
365
return pages.Unknown
···
330
407
331
408
targetBranch := branchResp
332
409
333
-
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
410
+
latestSourceRev := pull.LatestSha()
334
411
335
412
if pull.IsStacked() && stack != nil {
336
413
top := stack[0]
337
-
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
414
+
latestSourceRev = top.LatestSha()
338
415
}
339
416
340
417
if latestSourceRev != targetBranch.Hash {
···
374
451
return
375
452
}
376
453
377
-
patch := pull.Submissions[roundIdInt].Patch
454
+
patch := pull.Submissions[roundIdInt].CombinedPatch()
378
455
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
379
456
380
457
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
···
425
502
return
426
503
}
427
504
428
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
505
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
429
506
if err != nil {
430
507
log.Println("failed to interdiff; current patch malformed")
431
508
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
432
509
return
433
510
}
434
511
435
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
512
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
436
513
if err != nil {
437
514
log.Println("failed to interdiff; previous patch malformed")
438
515
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
···
472
549
}
473
550
474
551
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
552
+
l := s.logger.With("handler", "RepoPulls")
553
+
475
554
user := s.oauth.GetUser(r)
476
555
params := r.URL.Query()
477
556
···
489
568
return
490
569
}
491
570
571
+
keyword := params.Get("q")
572
+
573
+
var ids []int64
574
+
searchOpts := models.PullSearchOptions{
575
+
Keyword: keyword,
576
+
RepoAt: f.RepoAt().String(),
577
+
State: state,
578
+
// Page: page,
579
+
}
580
+
l.Debug("searching with", "searchOpts", searchOpts)
581
+
if keyword != "" {
582
+
res, err := s.indexer.Search(r.Context(), searchOpts)
583
+
if err != nil {
584
+
l.Error("failed to search for pulls", "err", err)
585
+
return
586
+
}
587
+
ids = res.Hits
588
+
l.Debug("searched pulls with indexer", "count", len(ids))
589
+
} else {
590
+
ids, err = db.GetPullIDs(s.db, searchOpts)
591
+
if err != nil {
592
+
l.Error("failed to get all pull ids", "err", err)
593
+
return
594
+
}
595
+
l.Debug("indexed all pulls from the db", "count", len(ids))
596
+
}
597
+
492
598
pulls, err := db.GetPulls(
493
599
s.db,
494
-
db.FilterEq("repo_at", f.RepoAt()),
495
-
db.FilterEq("state", state),
600
+
db.FilterIn("id", ids),
496
601
)
497
602
if err != nil {
498
603
log.Println("failed to get pulls", err)
···
557
662
m[p.Sha] = p
558
663
}
559
664
665
+
labelDefs, err := db.GetLabelDefinitions(
666
+
s.db,
667
+
db.FilterIn("at_uri", f.Repo.Labels),
668
+
db.FilterContains("scope", tangled.RepoPullNSID),
669
+
)
670
+
if err != nil {
671
+
log.Println("failed to fetch labels", err)
672
+
s.pages.Error503(w)
673
+
return
674
+
}
675
+
676
+
defs := make(map[string]*models.LabelDefinition)
677
+
for _, l := range labelDefs {
678
+
defs[l.AtUri().String()] = &l
679
+
}
680
+
560
681
s.pages.RepoPulls(w, pages.RepoPullsParams{
561
682
LoggedInUser: s.oauth.GetUser(r),
562
683
RepoInfo: f.RepoInfo(user),
563
684
Pulls: pulls,
685
+
LabelDefs: defs,
564
686
FilteringBy: state,
687
+
FilterQuery: keyword,
565
688
Stacks: stacks,
566
689
Pipelines: m,
567
690
})
568
691
}
569
692
570
693
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
694
+
l := s.logger.With("handler", "PullComment")
571
695
user := s.oauth.GetUser(r)
572
696
f, err := s.repoResolver.Resolve(r)
573
697
if err != nil {
···
617
741
618
742
createdAt := time.Now().Format(time.RFC3339)
619
743
620
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
621
-
if err != nil {
622
-
log.Println("failed to get pull at", err)
623
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
624
-
return
625
-
}
626
-
627
744
client, err := s.oauth.AuthorizedClient(r)
628
745
if err != nil {
629
746
log.Println("failed to get authorized client", err)
630
747
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
631
748
return
632
749
}
633
-
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
750
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
634
751
Collection: tangled.RepoPullCommentNSID,
635
752
Repo: user.Did,
636
753
Rkey: tid.TID(),
637
754
Record: &lexutil.LexiconTypeDecoder{
638
755
Val: &tangled.RepoPullComment{
639
-
Pull: string(pullAt),
756
+
Pull: pull.AtUri().String(),
640
757
Body: body,
641
758
CreatedAt: createdAt,
642
759
},
···
672
789
return
673
790
}
674
791
675
-
s.notifier.NewPullComment(r.Context(), comment)
792
+
rawMentions := markup.FindUserMentions(comment.Body)
793
+
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
794
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
795
+
var mentions []syntax.DID
796
+
for _, ident := range idents {
797
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
798
+
mentions = append(mentions, ident.DID)
799
+
}
800
+
}
801
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
676
802
677
803
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
678
804
return
···
884
1010
}
885
1011
886
1012
sourceRev := comparison.Rev2
887
-
patch := comparison.Patch
1013
+
patch := comparison.FormatPatchRaw
1014
+
combined := comparison.CombinedPatchRaw
888
1015
889
-
if !patchutil.IsPatchValid(patch) {
1016
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1017
+
s.logger.Error("failed to validate patch", "err", err)
890
1018
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
891
1019
return
892
1020
}
···
899
1027
Sha: comparison.Rev2,
900
1028
}
901
1029
902
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1030
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
903
1031
}
904
1032
905
1033
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
906
-
if !patchutil.IsPatchValid(patch) {
1034
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1035
+
s.logger.Error("patch validation failed", "err", err)
907
1036
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
908
1037
return
909
1038
}
910
1039
911
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
1040
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
912
1041
}
913
1042
914
1043
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
···
991
1120
}
992
1121
993
1122
sourceRev := comparison.Rev2
994
-
patch := comparison.Patch
1123
+
patch := comparison.FormatPatchRaw
1124
+
combined := comparison.CombinedPatchRaw
995
1125
996
-
if !patchutil.IsPatchValid(patch) {
1126
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1127
+
s.logger.Error("failed to validate patch", "err", err)
997
1128
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
998
1129
return
999
1130
}
···
1011
1142
Sha: sourceRev,
1012
1143
}
1013
1144
1014
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1145
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1015
1146
}
1016
1147
1017
1148
func (s *Pulls) createPullRequest(
···
1021
1152
user *oauth.User,
1022
1153
title, body, targetBranch string,
1023
1154
patch string,
1155
+
combined string,
1024
1156
sourceRev string,
1025
1157
pullSource *models.PullSource,
1026
1158
recordPullSource *tangled.RepoPull_Source,
···
1058
1190
1059
1191
// We've already checked earlier if it's diff-based and title is empty,
1060
1192
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1061
-
if title == "" {
1193
+
if title == "" || body == "" {
1062
1194
formatPatches, err := patchutil.ExtractPatches(patch)
1063
1195
if err != nil {
1064
1196
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1069
1201
return
1070
1202
}
1071
1203
1072
-
title = formatPatches[0].Title
1073
-
body = formatPatches[0].Body
1204
+
if title == "" {
1205
+
title = formatPatches[0].Title
1206
+
}
1207
+
if body == "" {
1208
+
body = formatPatches[0].Body
1209
+
}
1074
1210
}
1075
1211
1076
1212
rkey := tid.TID()
1077
1213
initialSubmission := models.PullSubmission{
1078
1214
Patch: patch,
1215
+
Combined: combined,
1079
1216
SourceRev: sourceRev,
1080
1217
}
1081
1218
pull := &models.Pull{
···
1103
1240
return
1104
1241
}
1105
1242
1106
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1243
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1107
1244
Collection: tangled.RepoPullNSID,
1108
1245
Repo: user.Did,
1109
1246
Rkey: rkey,
···
1114
1251
Repo: string(f.RepoAt()),
1115
1252
Branch: targetBranch,
1116
1253
},
1117
-
Patch: patch,
1118
-
Source: recordPullSource,
1254
+
Patch: patch,
1255
+
Source: recordPullSource,
1256
+
CreatedAt: time.Now().Format(time.RFC3339),
1119
1257
},
1120
1258
},
1121
1259
})
···
1200
1338
}
1201
1339
writes = append(writes, &write)
1202
1340
}
1203
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1341
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1204
1342
Repo: user.Did,
1205
1343
Writes: writes,
1206
1344
})
···
1250
1388
return
1251
1389
}
1252
1390
1253
-
if patch == "" || !patchutil.IsPatchValid(patch) {
1391
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1392
+
s.logger.Error("faield to validate patch", "err", err)
1254
1393
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1255
1394
return
1256
1395
}
···
1504
1643
1505
1644
patch := r.FormValue("patch")
1506
1645
1507
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1646
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1508
1647
}
1509
1648
1510
1649
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
···
1565
1704
}
1566
1705
1567
1706
sourceRev := comparison.Rev2
1568
-
patch := comparison.Patch
1707
+
patch := comparison.FormatPatchRaw
1708
+
combined := comparison.CombinedPatchRaw
1569
1709
1570
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1710
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1571
1711
}
1572
1712
1573
1713
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
···
1599
1739
return
1600
1740
}
1601
1741
1602
-
// extract patch by performing compare
1603
-
forkScheme := "http"
1604
-
if !s.config.Core.Dev {
1605
-
forkScheme = "https"
1606
-
}
1607
-
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1608
-
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1609
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1610
-
if err != nil {
1611
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1612
-
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1613
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1614
-
return
1615
-
}
1616
-
log.Printf("failed to compare branches: %s", err)
1617
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1618
-
return
1619
-
}
1620
-
1621
-
var forkComparison types.RepoFormatPatchResponse
1622
-
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1623
-
log.Println("failed to decode XRPC compare response for fork", err)
1624
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1625
-
return
1626
-
}
1627
-
1628
1742
// update the hidden tracking branch to latest
1629
1743
client, err := s.oauth.ServiceClient(
1630
1744
r,
···
1656
1770
return
1657
1771
}
1658
1772
1659
-
// Use the fork comparison we already made
1660
-
comparison := forkComparison
1661
-
1662
-
sourceRev := comparison.Rev2
1663
-
patch := comparison.Patch
1664
-
1665
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1666
-
}
1667
-
1668
-
// validate a resubmission against a pull request
1669
-
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1670
-
if patch == "" {
1671
-
return fmt.Errorf("Patch is empty.")
1773
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1774
+
// extract patch by performing compare
1775
+
forkScheme := "http"
1776
+
if !s.config.Core.Dev {
1777
+
forkScheme = "https"
1672
1778
}
1673
-
1674
-
if patch == pull.LatestPatch() {
1675
-
return fmt.Errorf("Patch is identical to previous submission.")
1779
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1780
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1781
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1782
+
if err != nil {
1783
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1784
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1785
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1786
+
return
1787
+
}
1788
+
log.Printf("failed to compare branches: %s", err)
1789
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1790
+
return
1676
1791
}
1677
1792
1678
-
if !patchutil.IsPatchValid(patch) {
1679
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1793
+
var forkComparison types.RepoFormatPatchResponse
1794
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1795
+
log.Println("failed to decode XRPC compare response for fork", err)
1796
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1797
+
return
1680
1798
}
1681
1799
1682
-
return nil
1800
+
// Use the fork comparison we already made
1801
+
comparison := forkComparison
1802
+
1803
+
sourceRev := comparison.Rev2
1804
+
patch := comparison.FormatPatchRaw
1805
+
combined := comparison.CombinedPatchRaw
1806
+
1807
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1683
1808
}
1684
1809
1685
1810
func (s *Pulls) resubmitPullHelper(
···
1689
1814
user *oauth.User,
1690
1815
pull *models.Pull,
1691
1816
patch string,
1817
+
combined string,
1692
1818
sourceRev string,
1693
1819
) {
1694
1820
if pull.IsStacked() {
···
1697
1823
return
1698
1824
}
1699
1825
1700
-
if err := validateResubmittedPatch(pull, patch); err != nil {
1826
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1701
1827
s.pages.Notice(w, "resubmit-error", err.Error())
1702
1828
return
1703
1829
}
1704
1830
1831
+
if patch == pull.LatestPatch() {
1832
+
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1833
+
return
1834
+
}
1835
+
1705
1836
// validate sourceRev if branch/fork based
1706
1837
if pull.IsBranchBased() || pull.IsForkBased() {
1707
-
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1838
+
if sourceRev == pull.LatestSha() {
1708
1839
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1709
1840
return
1710
1841
}
···
1718
1849
}
1719
1850
defer tx.Rollback()
1720
1851
1721
-
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1852
+
pullAt := pull.AtUri()
1853
+
newRoundNumber := len(pull.Submissions)
1854
+
newPatch := patch
1855
+
newSourceRev := sourceRev
1856
+
combinedPatch := combined
1857
+
err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1722
1858
if err != nil {
1723
1859
log.Println("failed to create pull request", err)
1724
1860
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1731
1867
return
1732
1868
}
1733
1869
1734
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1870
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1735
1871
if err != nil {
1736
1872
// failed to get record
1737
1873
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
···
1754
1890
}
1755
1891
}
1756
1892
1757
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1893
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1758
1894
Collection: tangled.RepoPullNSID,
1759
1895
Repo: user.Did,
1760
1896
Rkey: pull.Rkey,
···
1766
1902
Repo: string(f.RepoAt()),
1767
1903
Branch: pull.TargetBranch,
1768
1904
},
1769
-
Patch: patch, // new patch
1770
-
Source: recordPullSource,
1905
+
Patch: patch, // new patch
1906
+
Source: recordPullSource,
1907
+
CreatedAt: time.Now().Format(time.RFC3339),
1771
1908
},
1772
1909
},
1773
1910
})
···
1818
1955
// commits that got deleted: corresponding pull is closed
1819
1956
// commits that got added: new pull is created
1820
1957
// commits that got updated: corresponding pull is resubmitted & new round begins
1821
-
//
1822
-
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1823
1958
additions := make(map[string]*models.Pull)
1824
1959
deletions := make(map[string]*models.Pull)
1825
-
unchanged := make(map[string]struct{})
1826
1960
updated := make(map[string]struct{})
1827
1961
1828
1962
// pulls in orignal stack but not in new one
···
1844
1978
for _, np := range newStack {
1845
1979
if op, ok := origById[np.ChangeId]; ok {
1846
1980
// pull exists in both stacks
1847
-
// TODO: can we avoid reparse?
1848
-
origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1849
-
newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1850
-
1851
-
origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1852
-
newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1853
-
1854
-
patchutil.SortPatch(newFiles)
1855
-
patchutil.SortPatch(origFiles)
1856
-
1857
-
// text content of patch may be identical, but a jj rebase might have forwarded it
1858
-
//
1859
-
// we still need to update the hash in submission.Patch and submission.SourceRev
1860
-
if patchutil.Equal(newFiles, origFiles) &&
1861
-
origHeader.Title == newHeader.Title &&
1862
-
origHeader.Body == newHeader.Body {
1863
-
unchanged[op.ChangeId] = struct{}{}
1864
-
} else {
1865
-
updated[op.ChangeId] = struct{}{}
1866
-
}
1981
+
updated[op.ChangeId] = struct{}{}
1867
1982
}
1868
1983
}
1869
1984
···
1930
2045
continue
1931
2046
}
1932
2047
1933
-
submission := np.Submissions[np.LastRoundNumber()]
1934
-
1935
-
// resubmit the old pull
1936
-
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1937
-
1938
-
if err != nil {
1939
-
log.Println("failed to update pull", err, op.PullId)
1940
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1941
-
return
1942
-
}
1943
-
1944
-
record := op.AsRecord()
1945
-
record.Patch = submission.Patch
1946
-
1947
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1948
-
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1949
-
Collection: tangled.RepoPullNSID,
1950
-
Rkey: op.Rkey,
1951
-
Value: &lexutil.LexiconTypeDecoder{
1952
-
Val: &record,
1953
-
},
1954
-
},
1955
-
})
1956
-
}
1957
-
1958
-
// unchanged pulls are edited without starting a new round
1959
-
//
1960
-
// update source-revs & patches without advancing rounds
1961
-
for changeId := range unchanged {
1962
-
op, _ := origById[changeId]
1963
-
np, _ := newById[changeId]
1964
-
1965
-
origSubmission := op.Submissions[op.LastRoundNumber()]
1966
-
newSubmission := np.Submissions[np.LastRoundNumber()]
1967
-
1968
-
log.Println("moving unchanged change id : ", changeId)
1969
-
1970
-
err := db.UpdatePull(
1971
-
tx,
1972
-
newSubmission.Patch,
1973
-
newSubmission.SourceRev,
1974
-
db.FilterEq("id", origSubmission.ID),
1975
-
)
1976
-
2048
+
// resubmit the new pull
2049
+
pullAt := op.AtUri()
2050
+
newRoundNumber := len(op.Submissions)
2051
+
newPatch := np.LatestPatch()
2052
+
combinedPatch := np.LatestSubmission().Combined
2053
+
newSourceRev := np.LatestSha()
2054
+
err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1977
2055
if err != nil {
1978
2056
log.Println("failed to update pull", err, op.PullId)
1979
2057
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1980
2058
return
1981
2059
}
1982
2060
1983
-
record := op.AsRecord()
1984
-
record.Patch = newSubmission.Patch
2061
+
record := np.AsRecord()
1985
2062
1986
2063
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1987
2064
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
2026
2103
return
2027
2104
}
2028
2105
2029
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
2106
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2030
2107
Repo: user.Did,
2031
2108
Writes: writes,
2032
2109
})
···
2040
2117
}
2041
2118
2042
2119
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2120
+
user := s.oauth.GetUser(r)
2043
2121
f, err := s.repoResolver.Resolve(r)
2044
2122
if err != nil {
2045
2123
log.Println("failed to resolve repo:", err)
···
2137
2215
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2138
2216
return
2139
2217
}
2218
+
p.State = models.PullMerged
2140
2219
}
2141
2220
2142
2221
err = tx.Commit()
···
2147
2226
return
2148
2227
}
2149
2228
2229
+
// notify about the pull merge
2230
+
for _, p := range pullsToMerge {
2231
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2232
+
}
2233
+
2150
2234
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2151
2235
}
2152
2236
···
2205
2289
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2206
2290
return
2207
2291
}
2292
+
p.State = models.PullClosed
2208
2293
}
2209
2294
2210
2295
// Commit the transaction
···
2214
2299
return
2215
2300
}
2216
2301
2302
+
for _, p := range pullsToClose {
2303
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2304
+
}
2305
+
2217
2306
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2218
2307
}
2219
2308
···
2273
2362
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2274
2363
return
2275
2364
}
2365
+
p.State = models.PullOpen
2276
2366
}
2277
2367
2278
2368
// Commit the transaction
···
2280
2370
log.Println("failed to commit transaction", err)
2281
2371
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2282
2372
return
2373
+
}
2374
+
2375
+
for _, p := range pullsToReopen {
2376
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2283
2377
}
2284
2378
2285
2379
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2313
2407
initialSubmission := models.PullSubmission{
2314
2408
Patch: fp.Raw,
2315
2409
SourceRev: fp.SHA,
2410
+
Combined: fp.Raw,
2316
2411
}
2317
2412
pull := models.Pull{
2318
2413
Title: title,
+1
appview/pulls/router.go
+1
appview/pulls/router.go
+49
appview/repo/archive.go
+49
appview/repo/archive.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
9
+
"tangled.org/core/api/tangled"
10
+
xrpcclient "tangled.org/core/appview/xrpcclient"
11
+
12
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/go-chi/chi/v5"
14
+
"github.com/go-git/go-git/v5/plumbing"
15
+
)
16
+
17
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "DownloadArchive")
19
+
ref := chi.URLParam(r, "ref")
20
+
ref, _ = url.PathUnescape(ref)
21
+
f, err := rp.repoResolver.Resolve(r)
22
+
if err != nil {
23
+
l.Error("failed to get repo and knot", "err", err)
24
+
return
25
+
}
26
+
scheme := "http"
27
+
if !rp.config.Core.Dev {
28
+
scheme = "https"
29
+
}
30
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
31
+
xrpcc := &indigoxrpc.Client{
32
+
Host: host,
33
+
}
34
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
35
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
36
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
+
rp.pages.Error503(w)
39
+
return
40
+
}
41
+
// Set headers for file download, just pass along whatever the knot specifies
42
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
43
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
44
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
45
+
w.Header().Set("Content-Type", "application/gzip")
46
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
47
+
// Write the archive data directly
48
+
w.Write(archiveBytes)
49
+
}
+11
-10
appview/repo/artifact.go
+11
-10
appview/repo/artifact.go
···
10
10
"net/url"
11
11
"time"
12
12
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
-
"github.com/dustin/go-humanize"
17
-
"github.com/go-chi/chi/v5"
18
-
"github.com/go-git/go-git/v5/plumbing"
19
-
"github.com/ipfs/go-cid"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview/db"
22
15
"tangled.org/core/appview/models"
···
25
18
"tangled.org/core/appview/xrpcclient"
26
19
"tangled.org/core/tid"
27
20
"tangled.org/core/types"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
lexutil "github.com/bluesky-social/indigo/lex/util"
24
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
25
+
"github.com/dustin/go-humanize"
26
+
"github.com/go-chi/chi/v5"
27
+
"github.com/go-git/go-git/v5/plumbing"
28
+
"github.com/ipfs/go-cid"
28
29
)
29
30
30
31
// TODO: proper statuses here on early exit
···
60
61
return
61
62
}
62
63
63
-
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
64
+
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
64
65
if err != nil {
65
66
log.Println("failed to upload blob", err)
66
67
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
72
73
rkey := tid.TID()
73
74
createdAt := time.Now()
74
75
75
-
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
76
+
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
76
77
Collection: tangled.RepoArtifactNSID,
77
78
Repo: user.Did,
78
79
Rkey: rkey,
···
249
250
return
250
251
}
251
252
252
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
253
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
253
254
Collection: tangled.RepoArtifactNSID,
254
255
Repo: user.Did,
255
256
Rkey: artifact.Rkey,
+219
appview/repo/blob.go
+219
appview/repo/blob.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"io"
6
+
"net/http"
7
+
"net/url"
8
+
"path/filepath"
9
+
"slices"
10
+
"strings"
11
+
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pages/markup"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
17
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18
+
"github.com/go-chi/chi/v5"
19
+
)
20
+
21
+
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
22
+
l := rp.logger.With("handler", "RepoBlob")
23
+
f, err := rp.repoResolver.Resolve(r)
24
+
if err != nil {
25
+
l.Error("failed to get repo and knot", "err", err)
26
+
return
27
+
}
28
+
ref := chi.URLParam(r, "ref")
29
+
ref, _ = url.PathUnescape(ref)
30
+
filePath := chi.URLParam(r, "*")
31
+
filePath, _ = url.PathUnescape(filePath)
32
+
scheme := "http"
33
+
if !rp.config.Core.Dev {
34
+
scheme = "https"
35
+
}
36
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
37
+
xrpcc := &indigoxrpc.Client{
38
+
Host: host,
39
+
}
40
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
41
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
42
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
43
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
44
+
rp.pages.Error503(w)
45
+
return
46
+
}
47
+
// Use XRPC response directly instead of converting to internal types
48
+
var breadcrumbs [][]string
49
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
50
+
if filePath != "" {
51
+
for idx, elem := range strings.Split(filePath, "/") {
52
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
53
+
}
54
+
}
55
+
showRendered := false
56
+
renderToggle := false
57
+
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
58
+
renderToggle = true
59
+
showRendered = r.URL.Query().Get("code") != "true"
60
+
}
61
+
var unsupported bool
62
+
var isImage bool
63
+
var isVideo bool
64
+
var contentSrc string
65
+
if resp.IsBinary != nil && *resp.IsBinary {
66
+
ext := strings.ToLower(filepath.Ext(resp.Path))
67
+
switch ext {
68
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
69
+
isImage = true
70
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
71
+
isVideo = true
72
+
default:
73
+
unsupported = true
74
+
}
75
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
76
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
77
+
baseURL := &url.URL{
78
+
Scheme: scheme,
79
+
Host: f.Knot,
80
+
Path: "/xrpc/sh.tangled.repo.blob",
81
+
}
82
+
query := baseURL.Query()
83
+
query.Set("repo", repoName)
84
+
query.Set("ref", ref)
85
+
query.Set("path", filePath)
86
+
query.Set("raw", "true")
87
+
baseURL.RawQuery = query.Encode()
88
+
blobURL := baseURL.String()
89
+
contentSrc = blobURL
90
+
if !rp.config.Core.Dev {
91
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
92
+
}
93
+
}
94
+
lines := 0
95
+
if resp.IsBinary == nil || !*resp.IsBinary {
96
+
lines = strings.Count(resp.Content, "\n") + 1
97
+
}
98
+
var sizeHint uint64
99
+
if resp.Size != nil {
100
+
sizeHint = uint64(*resp.Size)
101
+
} else {
102
+
sizeHint = uint64(len(resp.Content))
103
+
}
104
+
user := rp.oauth.GetUser(r)
105
+
// Determine if content is binary (dereference pointer)
106
+
isBinary := false
107
+
if resp.IsBinary != nil {
108
+
isBinary = *resp.IsBinary
109
+
}
110
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
111
+
LoggedInUser: user,
112
+
RepoInfo: f.RepoInfo(user),
113
+
BreadCrumbs: breadcrumbs,
114
+
ShowRendered: showRendered,
115
+
RenderToggle: renderToggle,
116
+
Unsupported: unsupported,
117
+
IsImage: isImage,
118
+
IsVideo: isVideo,
119
+
ContentSrc: contentSrc,
120
+
RepoBlob_Output: resp,
121
+
Contents: resp.Content,
122
+
Lines: lines,
123
+
SizeHint: sizeHint,
124
+
IsBinary: isBinary,
125
+
})
126
+
}
127
+
128
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
129
+
l := rp.logger.With("handler", "RepoBlobRaw")
130
+
f, err := rp.repoResolver.Resolve(r)
131
+
if err != nil {
132
+
l.Error("failed to get repo and knot", "err", err)
133
+
w.WriteHeader(http.StatusBadRequest)
134
+
return
135
+
}
136
+
ref := chi.URLParam(r, "ref")
137
+
ref, _ = url.PathUnescape(ref)
138
+
filePath := chi.URLParam(r, "*")
139
+
filePath, _ = url.PathUnescape(filePath)
140
+
scheme := "http"
141
+
if !rp.config.Core.Dev {
142
+
scheme = "https"
143
+
}
144
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
145
+
baseURL := &url.URL{
146
+
Scheme: scheme,
147
+
Host: f.Knot,
148
+
Path: "/xrpc/sh.tangled.repo.blob",
149
+
}
150
+
query := baseURL.Query()
151
+
query.Set("repo", repo)
152
+
query.Set("ref", ref)
153
+
query.Set("path", filePath)
154
+
query.Set("raw", "true")
155
+
baseURL.RawQuery = query.Encode()
156
+
blobURL := baseURL.String()
157
+
req, err := http.NewRequest("GET", blobURL, nil)
158
+
if err != nil {
159
+
l.Error("failed to create request", "err", err)
160
+
return
161
+
}
162
+
// forward the If-None-Match header
163
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
164
+
req.Header.Set("If-None-Match", clientETag)
165
+
}
166
+
client := &http.Client{}
167
+
resp, err := client.Do(req)
168
+
if err != nil {
169
+
l.Error("failed to reach knotserver", "err", err)
170
+
rp.pages.Error503(w)
171
+
return
172
+
}
173
+
defer resp.Body.Close()
174
+
// forward 304 not modified
175
+
if resp.StatusCode == http.StatusNotModified {
176
+
w.WriteHeader(http.StatusNotModified)
177
+
return
178
+
}
179
+
if resp.StatusCode != http.StatusOK {
180
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
181
+
w.WriteHeader(resp.StatusCode)
182
+
_, _ = io.Copy(w, resp.Body)
183
+
return
184
+
}
185
+
contentType := resp.Header.Get("Content-Type")
186
+
body, err := io.ReadAll(resp.Body)
187
+
if err != nil {
188
+
l.Error("error reading response body from knotserver", "err", err)
189
+
w.WriteHeader(http.StatusInternalServerError)
190
+
return
191
+
}
192
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
193
+
// serve all textual content as text/plain
194
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
195
+
w.Write(body)
196
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
197
+
// serve images and videos with their original content type
198
+
w.Header().Set("Content-Type", contentType)
199
+
w.Write(body)
200
+
} else {
201
+
w.WriteHeader(http.StatusUnsupportedMediaType)
202
+
w.Write([]byte("unsupported content type"))
203
+
return
204
+
}
205
+
}
206
+
207
+
func isTextualMimeType(mimeType string) bool {
208
+
textualTypes := []string{
209
+
"application/json",
210
+
"application/xml",
211
+
"application/yaml",
212
+
"application/x-yaml",
213
+
"application/toml",
214
+
"application/javascript",
215
+
"application/ecmascript",
216
+
"message/",
217
+
}
218
+
return slices.Contains(textualTypes, mimeType)
219
+
}
+95
appview/repo/branches.go
+95
appview/repo/branches.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/appview/oauth"
10
+
"tangled.org/core/appview/pages"
11
+
xrpcclient "tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/types"
13
+
14
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15
+
)
16
+
17
+
func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "RepoBranches")
19
+
f, err := rp.repoResolver.Resolve(r)
20
+
if err != nil {
21
+
l.Error("failed to get repo and knot", "err", err)
22
+
return
23
+
}
24
+
scheme := "http"
25
+
if !rp.config.Core.Dev {
26
+
scheme = "https"
27
+
}
28
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
29
+
xrpcc := &indigoxrpc.Client{
30
+
Host: host,
31
+
}
32
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
33
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
34
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
35
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
36
+
rp.pages.Error503(w)
37
+
return
38
+
}
39
+
var result types.RepoBranchesResponse
40
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
41
+
l.Error("failed to decode XRPC response", "err", err)
42
+
rp.pages.Error503(w)
43
+
return
44
+
}
45
+
sortBranches(result.Branches)
46
+
user := rp.oauth.GetUser(r)
47
+
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
48
+
LoggedInUser: user,
49
+
RepoInfo: f.RepoInfo(user),
50
+
RepoBranchesResponse: result,
51
+
})
52
+
}
53
+
54
+
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
55
+
l := rp.logger.With("handler", "DeleteBranch")
56
+
f, err := rp.repoResolver.Resolve(r)
57
+
if err != nil {
58
+
l.Error("failed to get repo and knot", "err", err)
59
+
return
60
+
}
61
+
noticeId := "delete-branch-error"
62
+
fail := func(msg string, err error) {
63
+
l.Error(msg, "err", err)
64
+
rp.pages.Notice(w, noticeId, msg)
65
+
}
66
+
branch := r.FormValue("branch")
67
+
if branch == "" {
68
+
fail("No branch provided.", nil)
69
+
return
70
+
}
71
+
client, err := rp.oauth.ServiceClient(
72
+
r,
73
+
oauth.WithService(f.Knot),
74
+
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
75
+
oauth.WithDev(rp.config.Core.Dev),
76
+
)
77
+
if err != nil {
78
+
fail("Failed to connect to knotserver", nil)
79
+
return
80
+
}
81
+
err = tangled.RepoDeleteBranch(
82
+
r.Context(),
83
+
client,
84
+
&tangled.RepoDeleteBranch_Input{
85
+
Branch: branch,
86
+
Repo: f.RepoAt().String(),
87
+
},
88
+
)
89
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
90
+
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
91
+
return
92
+
}
93
+
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
94
+
rp.pages.HxRefresh(w)
95
+
}
+214
appview/repo/compare.go
+214
appview/repo/compare.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
"strings"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/pages"
12
+
xrpcclient "tangled.org/core/appview/xrpcclient"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/types"
15
+
16
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
17
+
"github.com/go-chi/chi/v5"
18
+
)
19
+
20
+
func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) {
21
+
l := rp.logger.With("handler", "RepoCompareNew")
22
+
23
+
user := rp.oauth.GetUser(r)
24
+
f, err := rp.repoResolver.Resolve(r)
25
+
if err != nil {
26
+
l.Error("failed to get repo and knot", "err", err)
27
+
return
28
+
}
29
+
30
+
scheme := "http"
31
+
if !rp.config.Core.Dev {
32
+
scheme = "https"
33
+
}
34
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
35
+
xrpcc := &indigoxrpc.Client{
36
+
Host: host,
37
+
}
38
+
39
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
40
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
41
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
42
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
43
+
rp.pages.Error503(w)
44
+
return
45
+
}
46
+
47
+
var branchResult types.RepoBranchesResponse
48
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
49
+
l.Error("failed to decode XRPC branches response", "err", err)
50
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
51
+
return
52
+
}
53
+
branches := branchResult.Branches
54
+
55
+
sortBranches(branches)
56
+
57
+
var defaultBranch string
58
+
for _, b := range branches {
59
+
if b.IsDefault {
60
+
defaultBranch = b.Name
61
+
}
62
+
}
63
+
64
+
base := defaultBranch
65
+
head := defaultBranch
66
+
67
+
params := r.URL.Query()
68
+
queryBase := params.Get("base")
69
+
queryHead := params.Get("head")
70
+
if queryBase != "" {
71
+
base = queryBase
72
+
}
73
+
if queryHead != "" {
74
+
head = queryHead
75
+
}
76
+
77
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
78
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
79
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
80
+
rp.pages.Error503(w)
81
+
return
82
+
}
83
+
84
+
var tags types.RepoTagsResponse
85
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
86
+
l.Error("failed to decode XRPC tags response", "err", err)
87
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
88
+
return
89
+
}
90
+
91
+
repoinfo := f.RepoInfo(user)
92
+
93
+
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
94
+
LoggedInUser: user,
95
+
RepoInfo: repoinfo,
96
+
Branches: branches,
97
+
Tags: tags.Tags,
98
+
Base: base,
99
+
Head: head,
100
+
})
101
+
}
102
+
103
+
func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) {
104
+
l := rp.logger.With("handler", "RepoCompare")
105
+
106
+
user := rp.oauth.GetUser(r)
107
+
f, err := rp.repoResolver.Resolve(r)
108
+
if err != nil {
109
+
l.Error("failed to get repo and knot", "err", err)
110
+
return
111
+
}
112
+
113
+
var diffOpts types.DiffOpts
114
+
if d := r.URL.Query().Get("diff"); d == "split" {
115
+
diffOpts.Split = true
116
+
}
117
+
118
+
// if user is navigating to one of
119
+
// /compare/{base}/{head}
120
+
// /compare/{base}...{head}
121
+
base := chi.URLParam(r, "base")
122
+
head := chi.URLParam(r, "head")
123
+
if base == "" && head == "" {
124
+
rest := chi.URLParam(r, "*") // master...feature/xyz
125
+
parts := strings.SplitN(rest, "...", 2)
126
+
if len(parts) == 2 {
127
+
base = parts[0]
128
+
head = parts[1]
129
+
}
130
+
}
131
+
132
+
base, _ = url.PathUnescape(base)
133
+
head, _ = url.PathUnescape(head)
134
+
135
+
if base == "" || head == "" {
136
+
l.Error("invalid comparison")
137
+
rp.pages.Error404(w)
138
+
return
139
+
}
140
+
141
+
scheme := "http"
142
+
if !rp.config.Core.Dev {
143
+
scheme = "https"
144
+
}
145
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
146
+
xrpcc := &indigoxrpc.Client{
147
+
Host: host,
148
+
}
149
+
150
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
151
+
152
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
153
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
154
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
155
+
rp.pages.Error503(w)
156
+
return
157
+
}
158
+
159
+
var branches types.RepoBranchesResponse
160
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
161
+
l.Error("failed to decode XRPC branches response", "err", err)
162
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
163
+
return
164
+
}
165
+
166
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
167
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
169
+
rp.pages.Error503(w)
170
+
return
171
+
}
172
+
173
+
var tags types.RepoTagsResponse
174
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
175
+
l.Error("failed to decode XRPC tags response", "err", err)
176
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
177
+
return
178
+
}
179
+
180
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
181
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
+
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
183
+
rp.pages.Error503(w)
184
+
return
185
+
}
186
+
187
+
var formatPatch types.RepoFormatPatchResponse
188
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
189
+
l.Error("failed to decode XRPC compare response", "err", err)
190
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
191
+
return
192
+
}
193
+
194
+
var diff types.NiceDiff
195
+
if formatPatch.CombinedPatchRaw != "" {
196
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
197
+
} else {
198
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
199
+
}
200
+
201
+
repoinfo := f.RepoInfo(user)
202
+
203
+
rp.pages.RepoCompare(w, pages.RepoCompareParams{
204
+
LoggedInUser: user,
205
+
RepoInfo: repoinfo,
206
+
Branches: branches.Branches,
207
+
Tags: tags.Tags,
208
+
Base: base,
209
+
Head: head,
210
+
Diff: &diff,
211
+
DiffOpts: diffOpts,
212
+
})
213
+
214
+
}
+1
-1
appview/repo/feed.go
+1
-1
appview/repo/feed.go
···
146
146
return fmt.Sprintf("%s in %s", base, repoName)
147
147
}
148
148
149
-
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
149
+
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
150
150
f, err := rp.repoResolver.Resolve(r)
151
151
if err != nil {
152
152
log.Println("failed to fully resolve repo:", err)
+37
-39
appview/repo/index.go
+37
-39
appview/repo/index.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log"
6
+
"log/slog"
7
7
"net/http"
8
8
"net/url"
9
9
"slices"
···
22
22
"tangled.org/core/appview/db"
23
23
"tangled.org/core/appview/models"
24
24
"tangled.org/core/appview/pages"
25
-
"tangled.org/core/appview/pages/markup"
26
25
"tangled.org/core/appview/reporesolver"
27
26
"tangled.org/core/appview/xrpcclient"
28
27
"tangled.org/core/types"
···
31
30
"github.com/go-enry/go-enry/v2"
32
31
)
33
32
34
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
+
l := rp.logger.With("handler", "RepoIndex")
35
+
35
36
ref := chi.URLParam(r, "ref")
36
37
ref, _ = url.PathUnescape(ref)
37
38
38
39
f, err := rp.repoResolver.Resolve(r)
39
40
if err != nil {
40
-
log.Println("failed to fully resolve repo", err)
41
+
l.Error("failed to fully resolve repo", "err", err)
41
42
return
42
43
}
43
44
···
57
58
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
58
59
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
59
60
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
60
-
log.Println("failed to call XRPC repo.index", err)
61
+
l.Error("failed to call XRPC repo.index", "err", err)
61
62
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
62
63
LoggedInUser: user,
63
64
NeedsKnotUpgrade: true,
···
67
68
}
68
69
69
70
rp.pages.Error503(w)
70
-
log.Println("failed to build index response", err)
71
+
l.Error("failed to build index response", "err", err)
71
72
return
72
73
}
73
74
···
120
121
emails := uniqueEmails(commitsTrunc)
121
122
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
122
123
if err != nil {
123
-
log.Println("failed to get email to did map", err)
124
+
l.Error("failed to get email to did map", "err", err)
124
125
}
125
126
126
127
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
127
128
if err != nil {
128
-
log.Println(err)
129
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
129
130
}
130
131
131
132
// TODO: a bit dirty
132
-
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
133
+
languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "")
133
134
if err != nil {
134
-
log.Printf("failed to compute language percentages: %s", err)
135
+
l.Warn("failed to compute language percentages", "err", err)
135
136
// non-fatal
136
137
}
137
138
···
141
142
}
142
143
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
143
144
if err != nil {
144
-
log.Printf("failed to fetch pipeline statuses: %s", err)
145
+
l.Error("failed to fetch pipeline statuses", "err", err)
145
146
// non-fatal
146
147
}
147
148
···
153
154
CommitsTrunc: commitsTrunc,
154
155
TagsTrunc: tagsTrunc,
155
156
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
156
-
BranchesTrunc: branchesTrunc,
157
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
158
-
VerifiedCommits: vc,
159
-
Languages: languageInfo,
160
-
Pipelines: pipelines,
157
+
BranchesTrunc: branchesTrunc,
158
+
EmailToDid: emailToDidMap,
159
+
VerifiedCommits: vc,
160
+
Languages: languageInfo,
161
+
Pipelines: pipelines,
161
162
})
162
163
}
163
164
164
165
func (rp *Repo) getLanguageInfo(
165
166
ctx context.Context,
167
+
l *slog.Logger,
166
168
f *reporesolver.ResolvedRepo,
167
169
xrpcc *indigoxrpc.Client,
168
170
currentRef string,
···
181
183
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
182
184
if err != nil {
183
185
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
184
-
log.Println("failed to call XRPC repo.languages", xrpcerr)
186
+
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
185
187
return nil, xrpcerr
186
188
}
187
189
return nil, err
···
201
203
})
202
204
}
203
205
206
+
tx, err := rp.db.Begin()
207
+
if err != nil {
208
+
return nil, err
209
+
}
210
+
defer tx.Rollback()
211
+
204
212
// update appview's cache
205
-
err = db.InsertRepoLanguages(rp.db, langs)
213
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
206
214
if err != nil {
207
215
// non-fatal
208
-
log.Println("failed to cache lang results", err)
216
+
l.Error("failed to cache lang results", "err", err)
217
+
}
218
+
219
+
err = tx.Commit()
220
+
if err != nil {
221
+
return nil, err
209
222
}
210
223
}
211
224
···
328
341
}
329
342
}()
330
343
331
-
// readme content
332
-
wg.Add(1)
333
-
go func() {
334
-
defer wg.Done()
335
-
for _, filename := range markup.ReadmeFilenames {
336
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
337
-
if err != nil {
338
-
continue
339
-
}
340
-
341
-
if blobResp == nil {
342
-
continue
343
-
}
344
-
345
-
readmeContent = blobResp.Content
346
-
readmeFileName = filename
347
-
break
348
-
}
349
-
}()
350
-
351
344
wg.Wait()
352
345
353
346
if errs != nil {
···
374
367
}
375
368
files = append(files, niceFile)
376
369
}
370
+
}
371
+
372
+
if treeResp != nil && treeResp.Readme != nil {
373
+
readmeFileName = treeResp.Readme.Filename
374
+
readmeContent = treeResp.Readme.Contents
377
375
}
378
376
379
377
result := &types.RepoIndexResponse{
+223
appview/repo/log.go
+223
appview/repo/log.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
"strconv"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/commitverify"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/types"
17
+
18
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
+
"github.com/go-chi/chi/v5"
20
+
"github.com/go-git/go-git/v5/plumbing"
21
+
)
22
+
23
+
func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) {
24
+
l := rp.logger.With("handler", "RepoLog")
25
+
26
+
f, err := rp.repoResolver.Resolve(r)
27
+
if err != nil {
28
+
l.Error("failed to fully resolve repo", "err", err)
29
+
return
30
+
}
31
+
32
+
page := 1
33
+
if r.URL.Query().Get("page") != "" {
34
+
page, err = strconv.Atoi(r.URL.Query().Get("page"))
35
+
if err != nil {
36
+
page = 1
37
+
}
38
+
}
39
+
40
+
ref := chi.URLParam(r, "ref")
41
+
ref, _ = url.PathUnescape(ref)
42
+
43
+
scheme := "http"
44
+
if !rp.config.Core.Dev {
45
+
scheme = "https"
46
+
}
47
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
48
+
xrpcc := &indigoxrpc.Client{
49
+
Host: host,
50
+
}
51
+
52
+
limit := int64(60)
53
+
cursor := ""
54
+
if page > 1 {
55
+
// Convert page number to cursor (offset)
56
+
offset := (page - 1) * int(limit)
57
+
cursor = strconv.Itoa(offset)
58
+
}
59
+
60
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
61
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
62
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
63
+
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
64
+
rp.pages.Error503(w)
65
+
return
66
+
}
67
+
68
+
var xrpcResp types.RepoLogResponse
69
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
70
+
l.Error("failed to decode XRPC response", "err", err)
71
+
rp.pages.Error503(w)
72
+
return
73
+
}
74
+
75
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
76
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
77
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
78
+
rp.pages.Error503(w)
79
+
return
80
+
}
81
+
82
+
tagMap := make(map[string][]string)
83
+
if tagBytes != nil {
84
+
var tagResp types.RepoTagsResponse
85
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
86
+
for _, tag := range tagResp.Tags {
87
+
hash := tag.Hash
88
+
if tag.Tag != nil {
89
+
hash = tag.Tag.Target.String()
90
+
}
91
+
tagMap[hash] = append(tagMap[hash], tag.Name)
92
+
}
93
+
}
94
+
}
95
+
96
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
97
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
98
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
99
+
rp.pages.Error503(w)
100
+
return
101
+
}
102
+
103
+
if branchBytes != nil {
104
+
var branchResp types.RepoBranchesResponse
105
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
106
+
for _, branch := range branchResp.Branches {
107
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
108
+
}
109
+
}
110
+
}
111
+
112
+
user := rp.oauth.GetUser(r)
113
+
114
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
115
+
if err != nil {
116
+
l.Error("failed to fetch email to did mapping", "err", err)
117
+
}
118
+
119
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
+
if err != nil {
121
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
+
}
123
+
124
+
repoInfo := f.RepoInfo(user)
125
+
126
+
var shas []string
127
+
for _, c := range xrpcResp.Commits {
128
+
shas = append(shas, c.Hash.String())
129
+
}
130
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
131
+
if err != nil {
132
+
l.Error("failed to getPipelineStatuses", "err", err)
133
+
// non-fatal
134
+
}
135
+
136
+
rp.pages.RepoLog(w, pages.RepoLogParams{
137
+
LoggedInUser: user,
138
+
TagMap: tagMap,
139
+
RepoInfo: repoInfo,
140
+
RepoLogResponse: xrpcResp,
141
+
EmailToDid: emailToDidMap,
142
+
VerifiedCommits: vc,
143
+
Pipelines: pipelines,
144
+
})
145
+
}
146
+
147
+
func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) {
148
+
l := rp.logger.With("handler", "RepoCommit")
149
+
150
+
f, err := rp.repoResolver.Resolve(r)
151
+
if err != nil {
152
+
l.Error("failed to fully resolve repo", "err", err)
153
+
return
154
+
}
155
+
ref := chi.URLParam(r, "ref")
156
+
ref, _ = url.PathUnescape(ref)
157
+
158
+
var diffOpts types.DiffOpts
159
+
if d := r.URL.Query().Get("diff"); d == "split" {
160
+
diffOpts.Split = true
161
+
}
162
+
163
+
if !plumbing.IsHash(ref) {
164
+
rp.pages.Error404(w)
165
+
return
166
+
}
167
+
168
+
scheme := "http"
169
+
if !rp.config.Core.Dev {
170
+
scheme = "https"
171
+
}
172
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
173
+
xrpcc := &indigoxrpc.Client{
174
+
Host: host,
175
+
}
176
+
177
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
179
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
180
+
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
181
+
rp.pages.Error503(w)
182
+
return
183
+
}
184
+
185
+
var result types.RepoCommitResponse
186
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
187
+
l.Error("failed to decode XRPC response", "err", err)
188
+
rp.pages.Error503(w)
189
+
return
190
+
}
191
+
192
+
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
193
+
if err != nil {
194
+
l.Error("failed to get email to did mapping", "err", err)
195
+
}
196
+
197
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
198
+
if err != nil {
199
+
l.Error("failed to GetVerifiedCommits", "err", err)
200
+
}
201
+
202
+
user := rp.oauth.GetUser(r)
203
+
repoInfo := f.RepoInfo(user)
204
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
205
+
if err != nil {
206
+
l.Error("failed to getPipelineStatuses", "err", err)
207
+
// non-fatal
208
+
}
209
+
var pipeline *models.Pipeline
210
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
211
+
pipeline = &p
212
+
}
213
+
214
+
rp.pages.RepoCommit(w, pages.RepoCommitParams{
215
+
LoggedInUser: user,
216
+
RepoInfo: f.RepoInfo(user),
217
+
RepoCommitResponse: result,
218
+
EmailToDid: emailToDidMap,
219
+
VerifiedCommit: vc,
220
+
Pipeline: pipeline,
221
+
DiffOpts: diffOpts,
222
+
})
223
+
}
+402
appview/repo/opengraph.go
+402
appview/repo/opengraph.go
···
1
+
package repo
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/hex"
7
+
"fmt"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
"sort"
13
+
"strings"
14
+
15
+
"github.com/go-enry/go-enry/v2"
16
+
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/models"
18
+
"tangled.org/core/appview/ogcard"
19
+
"tangled.org/core/types"
20
+
)
21
+
22
+
func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) {
23
+
width, height := ogcard.DefaultSize()
24
+
mainCard, err := ogcard.NewCard(width, height)
25
+
if err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
// Split: content area (75%) and language bar + icons (25%)
30
+
contentCard, bottomArea := mainCard.Split(false, 75)
31
+
32
+
// Add padding to content
33
+
contentCard.SetMargin(50)
34
+
35
+
// Split content horizontally: main content (80%) and avatar area (20%)
36
+
mainContent, avatarArea := contentCard.Split(true, 80)
37
+
38
+
// Use main content area for both repo name and description to allow dynamic wrapping.
39
+
mainContent.SetMargin(10)
40
+
41
+
var ownerHandle string
42
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
43
+
if err != nil {
44
+
ownerHandle = repo.Did
45
+
} else {
46
+
ownerHandle = "@" + owner.Handle.String()
47
+
}
48
+
49
+
bounds := mainContent.Img.Bounds()
50
+
startX := bounds.Min.X + mainContent.Margin
51
+
startY := bounds.Min.Y + mainContent.Margin
52
+
currentX := startX
53
+
currentY := startY
54
+
lineHeight := 64 // Font size 54 + padding
55
+
textColor := color.RGBA{88, 96, 105, 255}
56
+
57
+
// Draw owner handle
58
+
ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left)
59
+
if err != nil {
60
+
return nil, err
61
+
}
62
+
currentX += ownerWidth
63
+
64
+
// Draw separator
65
+
sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left)
66
+
if err != nil {
67
+
return nil, err
68
+
}
69
+
currentX += sepWidth
70
+
71
+
words := strings.Fields(repo.Name)
72
+
spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left)
73
+
if spaceWidth == 0 {
74
+
spaceWidth = 15
75
+
}
76
+
77
+
for _, word := range words {
78
+
// estimate bold width by measuring regular width and adding a multiplier
79
+
regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left)
80
+
estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text
81
+
82
+
if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) {
83
+
currentX = startX
84
+
currentY += lineHeight
85
+
}
86
+
87
+
_, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left)
88
+
if err != nil {
89
+
return nil, err
90
+
}
91
+
currentX += estimatedBoldWidth + spaceWidth
92
+
}
93
+
94
+
// update Y position for the description
95
+
currentY += lineHeight
96
+
97
+
// draw description
98
+
if currentY < bounds.Max.Y-mainContent.Margin {
99
+
totalHeight := float64(bounds.Dy())
100
+
repoNameHeight := float64(currentY - bounds.Min.Y)
101
+
102
+
if totalHeight > 0 && repoNameHeight < totalHeight {
103
+
repoNamePercent := (repoNameHeight / totalHeight) * 100
104
+
if repoNamePercent < 95 { // Ensure there's space left for description
105
+
_, descriptionCard := mainContent.Split(false, int(repoNamePercent))
106
+
descriptionCard.SetMargin(8)
107
+
108
+
description := repo.Description
109
+
if len(description) > 70 {
110
+
description = description[:70] + "…"
111
+
}
112
+
113
+
_, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left)
114
+
if err != nil {
115
+
log.Printf("failed to draw description: %v", err)
116
+
}
117
+
}
118
+
}
119
+
}
120
+
121
+
// Draw avatar circle on the right side
122
+
avatarBounds := avatarArea.Img.Bounds()
123
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
124
+
if avatarSize > 220 {
125
+
avatarSize = 220
126
+
}
127
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
128
+
avatarY := avatarBounds.Min.Y + 20
129
+
130
+
// Get avatar URL and draw it
131
+
avatarURL := rp.pages.AvatarUrl(ownerHandle, "256")
132
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
133
+
if err != nil {
134
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
135
+
}
136
+
137
+
// Split bottom area: icons area (65%) and language bar (35%)
138
+
iconsArea, languageBarCard := bottomArea.Split(false, 75)
139
+
140
+
// Split icons area: left side for stats (80%), right side for dolly (20%)
141
+
statsArea, dollyArea := iconsArea.Split(true, 80)
142
+
143
+
// Draw stats with icons in the stats area
144
+
starsText := repo.RepoStats.StarCount
145
+
issuesText := repo.RepoStats.IssueCount.Open
146
+
pullRequestsText := repo.RepoStats.PullCount.Open
147
+
148
+
iconColor := color.RGBA{88, 96, 105, 255}
149
+
iconSize := 36
150
+
textSize := 36.0
151
+
152
+
// Position stats in the middle of the stats area
153
+
statsBounds := statsArea.Img.Bounds()
154
+
statsX := statsBounds.Min.X + 60 // left padding
155
+
statsY := statsBounds.Min.Y
156
+
currentX = statsX
157
+
labelSize := 22.0
158
+
// Draw star icon, count, and label
159
+
// Align icon baseline with text baseline
160
+
iconBaselineOffset := int(textSize) / 2
161
+
err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
+
if err != nil {
163
+
log.Printf("failed to draw star icon: %v", err)
164
+
}
165
+
starIconX := currentX
166
+
currentX += iconSize + 15
167
+
168
+
starText := fmt.Sprintf("%d", starsText)
169
+
err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
170
+
if err != nil {
171
+
log.Printf("failed to draw star text: %v", err)
172
+
}
173
+
starTextWidth := len(starText) * 20
174
+
starGroupWidth := iconSize + 15 + starTextWidth
175
+
176
+
// Draw "stars" label below and centered under the icon+text group
177
+
labelY := statsY + iconSize + 15
178
+
labelX := starIconX + starGroupWidth/2
179
+
err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
180
+
if err != nil {
181
+
log.Printf("failed to draw stars label: %v", err)
182
+
}
183
+
184
+
currentX += starTextWidth + 50
185
+
186
+
// Draw issues icon, count, and label
187
+
issueStartX := currentX
188
+
err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
+
if err != nil {
190
+
log.Printf("failed to draw circle-dot icon: %v", err)
191
+
}
192
+
currentX += iconSize + 15
193
+
194
+
issueText := fmt.Sprintf("%d", issuesText)
195
+
err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
196
+
if err != nil {
197
+
log.Printf("failed to draw issue text: %v", err)
198
+
}
199
+
issueTextWidth := len(issueText) * 20
200
+
issueGroupWidth := iconSize + 15 + issueTextWidth
201
+
202
+
// Draw "issues" label below and centered under the icon+text group
203
+
labelX = issueStartX + issueGroupWidth/2
204
+
err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
205
+
if err != nil {
206
+
log.Printf("failed to draw issues label: %v", err)
207
+
}
208
+
209
+
currentX += issueTextWidth + 50
210
+
211
+
// Draw pull request icon, count, and label
212
+
prStartX := currentX
213
+
err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
+
if err != nil {
215
+
log.Printf("failed to draw git-pull-request icon: %v", err)
216
+
}
217
+
currentX += iconSize + 15
218
+
219
+
prText := fmt.Sprintf("%d", pullRequestsText)
220
+
err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
221
+
if err != nil {
222
+
log.Printf("failed to draw PR text: %v", err)
223
+
}
224
+
prTextWidth := len(prText) * 20
225
+
prGroupWidth := iconSize + 15 + prTextWidth
226
+
227
+
// Draw "pulls" label below and centered under the icon+text group
228
+
labelX = prStartX + prGroupWidth/2
229
+
err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
230
+
if err != nil {
231
+
log.Printf("failed to draw pulls label: %v", err)
232
+
}
233
+
234
+
dollyBounds := dollyArea.Img.Bounds()
235
+
dollySize := 90
236
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
240
+
if err != nil {
241
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
+
}
243
+
244
+
// Draw language bar at bottom
245
+
err = drawLanguagesCard(languageBarCard, languageStats)
246
+
if err != nil {
247
+
log.Printf("failed to draw language bar: %v", err)
248
+
return nil, err
249
+
}
250
+
251
+
return mainCard, nil
252
+
}
253
+
254
+
// hexToColor converts a hex color to a go color
255
+
func hexToColor(colorStr string) (*color.RGBA, error) {
256
+
colorStr = strings.TrimLeft(colorStr, "#")
257
+
258
+
b, err := hex.DecodeString(colorStr)
259
+
if err != nil {
260
+
return nil, err
261
+
}
262
+
263
+
if len(b) < 3 {
264
+
return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
265
+
}
266
+
267
+
clr := color.RGBA{b[0], b[1], b[2], 255}
268
+
269
+
return &clr, nil
270
+
}
271
+
272
+
func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error {
273
+
bounds := card.Img.Bounds()
274
+
cardWidth := bounds.Dx()
275
+
276
+
if len(languageStats) == 0 {
277
+
// Draw a light gray bar if no languages detected
278
+
card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255})
279
+
return nil
280
+
}
281
+
282
+
// Limit to top 5 languages for the visual bar
283
+
displayLanguages := languageStats
284
+
if len(displayLanguages) > 5 {
285
+
displayLanguages = displayLanguages[:5]
286
+
}
287
+
288
+
currentX := bounds.Min.X
289
+
290
+
for _, lang := range displayLanguages {
291
+
var langColor *color.RGBA
292
+
var err error
293
+
294
+
if lang.Color != "" {
295
+
langColor, err = hexToColor(lang.Color)
296
+
if err != nil {
297
+
// Fallback to a default color
298
+
langColor = &color.RGBA{149, 157, 165, 255}
299
+
}
300
+
} else {
301
+
// Default color if no color specified
302
+
langColor = &color.RGBA{149, 157, 165, 255}
303
+
}
304
+
305
+
langWidth := float32(cardWidth) * (lang.Percentage / 100)
306
+
card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor)
307
+
currentX += int(langWidth)
308
+
}
309
+
310
+
// Fill remaining space with the last color (if any gap due to rounding)
311
+
if currentX < bounds.Max.X && len(displayLanguages) > 0 {
312
+
lastLang := displayLanguages[len(displayLanguages)-1]
313
+
var lastColor *color.RGBA
314
+
var err error
315
+
316
+
if lastLang.Color != "" {
317
+
lastColor, err = hexToColor(lastLang.Color)
318
+
if err != nil {
319
+
lastColor = &color.RGBA{149, 157, 165, 255}
320
+
}
321
+
} else {
322
+
lastColor = &color.RGBA{149, 157, 165, 255}
323
+
}
324
+
card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor)
325
+
}
326
+
327
+
return nil
328
+
}
329
+
330
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
+
f, err := rp.repoResolver.Resolve(r)
332
+
if err != nil {
333
+
log.Println("failed to get repo and knot", err)
334
+
return
335
+
}
336
+
337
+
// Get language stats directly from database
338
+
var languageStats []types.RepoLanguageDetails
339
+
langs, err := db.GetRepoLanguages(
340
+
rp.db,
341
+
db.FilterEq("repo_at", f.RepoAt()),
342
+
db.FilterEq("is_default_ref", 1),
343
+
)
344
+
if err != nil {
345
+
log.Printf("failed to get language stats from db: %v", err)
346
+
// non-fatal, continue without language stats
347
+
} else if len(langs) > 0 {
348
+
var total int64
349
+
for _, l := range langs {
350
+
total += l.Bytes
351
+
}
352
+
353
+
for _, l := range langs {
354
+
percentage := float32(l.Bytes) / float32(total) * 100
355
+
color := enry.GetColor(l.Language)
356
+
languageStats = append(languageStats, types.RepoLanguageDetails{
357
+
Name: l.Language,
358
+
Percentage: percentage,
359
+
Color: color,
360
+
})
361
+
}
362
+
363
+
sort.Slice(languageStats, func(i, j int) bool {
364
+
if languageStats[i].Name == enry.OtherLanguage {
365
+
return false
366
+
}
367
+
if languageStats[j].Name == enry.OtherLanguage {
368
+
return true
369
+
}
370
+
if languageStats[i].Percentage != languageStats[j].Percentage {
371
+
return languageStats[i].Percentage > languageStats[j].Percentage
372
+
}
373
+
return languageStats[i].Name < languageStats[j].Name
374
+
})
375
+
}
376
+
377
+
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
378
+
if err != nil {
379
+
log.Println("failed to draw repo summary card", err)
380
+
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
381
+
return
382
+
}
383
+
384
+
var imageBuffer bytes.Buffer
385
+
err = png.Encode(&imageBuffer, card.Img)
386
+
if err != nil {
387
+
log.Println("failed to encode repo summary card", err)
388
+
http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError)
389
+
return
390
+
}
391
+
392
+
imageBytes := imageBuffer.Bytes()
393
+
394
+
w.Header().Set("Content-Type", "image/png")
395
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
396
+
w.WriteHeader(http.StatusOK)
397
+
_, err = w.Write(imageBytes)
398
+
if err != nil {
399
+
log.Println("failed to write repo summary card", err)
400
+
return
401
+
}
402
+
}
+66
-1369
appview/repo/repo.go
+66
-1369
appview/repo/repo.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"encoding/json"
7
6
"errors"
8
7
"fmt"
9
-
"io"
10
-
"log"
11
8
"log/slog"
12
9
"net/http"
13
10
"net/url"
14
-
"path/filepath"
15
11
"slices"
16
-
"strconv"
17
12
"strings"
18
13
"time"
19
14
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
15
"tangled.org/core/api/tangled"
24
-
"tangled.org/core/appview/commitverify"
25
16
"tangled.org/core/appview/config"
26
17
"tangled.org/core/appview/db"
27
18
"tangled.org/core/appview/models"
28
19
"tangled.org/core/appview/notify"
29
20
"tangled.org/core/appview/oauth"
30
21
"tangled.org/core/appview/pages"
31
-
"tangled.org/core/appview/pages/markup"
32
22
"tangled.org/core/appview/reporesolver"
33
23
"tangled.org/core/appview/validator"
34
24
xrpcclient "tangled.org/core/appview/xrpcclient"
35
25
"tangled.org/core/eventconsumer"
36
26
"tangled.org/core/idresolver"
37
-
"tangled.org/core/patchutil"
38
27
"tangled.org/core/rbac"
39
28
"tangled.org/core/tid"
40
-
"tangled.org/core/types"
41
29
"tangled.org/core/xrpc/serviceauth"
42
30
31
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
32
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
33
+
"github.com/bluesky-social/indigo/atproto/syntax"
34
+
lexutil "github.com/bluesky-social/indigo/lex/util"
43
35
securejoin "github.com/cyphar/filepath-securejoin"
44
36
"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
37
)
49
38
50
39
type Repo struct {
···
89
78
}
90
79
}
91
80
92
-
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
93
-
ref := chi.URLParam(r, "ref")
94
-
ref, _ = url.PathUnescape(ref)
95
-
96
-
f, err := rp.repoResolver.Resolve(r)
97
-
if err != nil {
98
-
log.Println("failed to get repo and knot", err)
99
-
return
100
-
}
101
-
102
-
scheme := "http"
103
-
if !rp.config.Core.Dev {
104
-
scheme = "https"
105
-
}
106
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
107
-
xrpcc := &indigoxrpc.Client{
108
-
Host: host,
109
-
}
110
-
111
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
112
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
113
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
114
-
log.Println("failed to call XRPC repo.archive", xrpcerr)
115
-
rp.pages.Error503(w)
116
-
return
117
-
}
118
-
119
-
// Set headers for file download, just pass along whatever the knot specifies
120
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
121
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
122
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
123
-
w.Header().Set("Content-Type", "application/gzip")
124
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
125
-
126
-
// Write the archive data directly
127
-
w.Write(archiveBytes)
128
-
}
129
-
130
-
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
131
-
f, err := rp.repoResolver.Resolve(r)
132
-
if err != nil {
133
-
log.Println("failed to fully resolve repo", err)
134
-
return
135
-
}
136
-
137
-
page := 1
138
-
if r.URL.Query().Get("page") != "" {
139
-
page, err = strconv.Atoi(r.URL.Query().Get("page"))
140
-
if err != nil {
141
-
page = 1
142
-
}
143
-
}
144
-
145
-
ref := chi.URLParam(r, "ref")
146
-
ref, _ = url.PathUnescape(ref)
147
-
148
-
scheme := "http"
149
-
if !rp.config.Core.Dev {
150
-
scheme = "https"
151
-
}
152
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
153
-
xrpcc := &indigoxrpc.Client{
154
-
Host: host,
155
-
}
156
-
157
-
limit := int64(60)
158
-
cursor := ""
159
-
if page > 1 {
160
-
// Convert page number to cursor (offset)
161
-
offset := (page - 1) * int(limit)
162
-
cursor = strconv.Itoa(offset)
163
-
}
164
-
165
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
166
-
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
167
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
-
log.Println("failed to call XRPC repo.log", xrpcerr)
169
-
rp.pages.Error503(w)
170
-
return
171
-
}
172
-
173
-
var xrpcResp types.RepoLogResponse
174
-
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
175
-
log.Println("failed to decode XRPC response", err)
176
-
rp.pages.Error503(w)
177
-
return
178
-
}
179
-
180
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
181
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
183
-
rp.pages.Error503(w)
184
-
return
185
-
}
186
-
187
-
tagMap := make(map[string][]string)
188
-
if tagBytes != nil {
189
-
var tagResp types.RepoTagsResponse
190
-
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
191
-
for _, tag := range tagResp.Tags {
192
-
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
193
-
}
194
-
}
195
-
}
196
-
197
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
198
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
199
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
200
-
rp.pages.Error503(w)
201
-
return
202
-
}
203
-
204
-
if branchBytes != nil {
205
-
var branchResp types.RepoBranchesResponse
206
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
207
-
for _, branch := range branchResp.Branches {
208
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
209
-
}
210
-
}
211
-
}
212
-
213
-
user := rp.oauth.GetUser(r)
214
-
215
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
216
-
if err != nil {
217
-
log.Println("failed to fetch email to did mapping", err)
218
-
}
219
-
220
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
221
-
if err != nil {
222
-
log.Println(err)
223
-
}
224
-
225
-
repoInfo := f.RepoInfo(user)
226
-
227
-
var shas []string
228
-
for _, c := range xrpcResp.Commits {
229
-
shas = append(shas, c.Hash.String())
230
-
}
231
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
232
-
if err != nil {
233
-
log.Println(err)
234
-
// non-fatal
235
-
}
236
-
237
-
rp.pages.RepoLog(w, pages.RepoLogParams{
238
-
LoggedInUser: user,
239
-
TagMap: tagMap,
240
-
RepoInfo: repoInfo,
241
-
RepoLogResponse: xrpcResp,
242
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
243
-
VerifiedCommits: vc,
244
-
Pipelines: pipelines,
245
-
})
246
-
}
247
-
248
-
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
249
-
f, err := rp.repoResolver.Resolve(r)
250
-
if err != nil {
251
-
log.Println("failed to get repo and knot", err)
252
-
w.WriteHeader(http.StatusBadRequest)
253
-
return
254
-
}
255
-
256
-
user := rp.oauth.GetUser(r)
257
-
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
258
-
RepoInfo: f.RepoInfo(user),
259
-
})
260
-
}
261
-
262
-
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
263
-
f, err := rp.repoResolver.Resolve(r)
264
-
if err != nil {
265
-
log.Println("failed to get repo and knot", err)
266
-
w.WriteHeader(http.StatusBadRequest)
267
-
return
268
-
}
269
-
270
-
repoAt := f.RepoAt()
271
-
rkey := repoAt.RecordKey().String()
272
-
if rkey == "" {
273
-
log.Println("invalid aturi for repo", err)
274
-
w.WriteHeader(http.StatusInternalServerError)
275
-
return
276
-
}
277
-
278
-
user := rp.oauth.GetUser(r)
279
-
280
-
switch r.Method {
281
-
case http.MethodGet:
282
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
283
-
RepoInfo: f.RepoInfo(user),
284
-
})
285
-
return
286
-
case http.MethodPut:
287
-
newDescription := r.FormValue("description")
288
-
client, err := rp.oauth.AuthorizedClient(r)
289
-
if err != nil {
290
-
log.Println("failed to get client")
291
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
292
-
return
293
-
}
294
-
295
-
// optimistic update
296
-
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
297
-
if err != nil {
298
-
log.Println("failed to perferom update-description query", err)
299
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
300
-
return
301
-
}
302
-
303
-
newRepo := f.Repo
304
-
newRepo.Description = newDescription
305
-
record := newRepo.AsRecord()
306
-
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,
320
-
SwapRecord: ex.Cid,
321
-
Record: &lexutil.LexiconTypeDecoder{
322
-
Val: &record,
323
-
},
324
-
})
325
-
326
-
if err != nil {
327
-
log.Println("failed to perferom update-description query", err)
328
-
// failed to get record
329
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
330
-
return
331
-
}
332
-
333
-
newRepoInfo := f.RepoInfo(user)
334
-
newRepoInfo.Description = newDescription
335
-
336
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
337
-
RepoInfo: newRepoInfo,
338
-
})
339
-
return
340
-
}
341
-
}
342
-
343
-
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
344
-
f, err := rp.repoResolver.Resolve(r)
345
-
if err != nil {
346
-
log.Println("failed to fully resolve repo", err)
347
-
return
348
-
}
349
-
ref := chi.URLParam(r, "ref")
350
-
ref, _ = url.PathUnescape(ref)
351
-
352
-
var diffOpts types.DiffOpts
353
-
if d := r.URL.Query().Get("diff"); d == "split" {
354
-
diffOpts.Split = true
355
-
}
356
-
357
-
if !plumbing.IsHash(ref) {
358
-
rp.pages.Error404(w)
359
-
return
360
-
}
361
-
362
-
scheme := "http"
363
-
if !rp.config.Core.Dev {
364
-
scheme = "https"
365
-
}
366
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
367
-
xrpcc := &indigoxrpc.Client{
368
-
Host: host,
369
-
}
370
-
371
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
372
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
373
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
374
-
log.Println("failed to call XRPC repo.diff", xrpcerr)
375
-
rp.pages.Error503(w)
376
-
return
377
-
}
378
-
379
-
var result types.RepoCommitResponse
380
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
381
-
log.Println("failed to decode XRPC response", err)
382
-
rp.pages.Error503(w)
383
-
return
384
-
}
385
-
386
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
387
-
if err != nil {
388
-
log.Println("failed to get email to did mapping:", err)
389
-
}
390
-
391
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
392
-
if err != nil {
393
-
log.Println(err)
394
-
}
395
-
396
-
user := rp.oauth.GetUser(r)
397
-
repoInfo := f.RepoInfo(user)
398
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
399
-
if err != nil {
400
-
log.Println(err)
401
-
// non-fatal
402
-
}
403
-
var pipeline *models.Pipeline
404
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
405
-
pipeline = &p
406
-
}
407
-
408
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
409
-
LoggedInUser: user,
410
-
RepoInfo: f.RepoInfo(user),
411
-
RepoCommitResponse: result,
412
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
413
-
VerifiedCommit: vc,
414
-
Pipeline: pipeline,
415
-
DiffOpts: diffOpts,
416
-
})
417
-
}
418
-
419
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
420
-
f, err := rp.repoResolver.Resolve(r)
421
-
if err != nil {
422
-
log.Println("failed to fully resolve repo", err)
423
-
return
424
-
}
425
-
426
-
ref := chi.URLParam(r, "ref")
427
-
ref, _ = url.PathUnescape(ref)
428
-
429
-
// if the tree path has a trailing slash, let's strip it
430
-
// so we don't 404
431
-
treePath := chi.URLParam(r, "*")
432
-
treePath, _ = url.PathUnescape(treePath)
433
-
treePath = strings.TrimSuffix(treePath, "/")
434
-
435
-
scheme := "http"
436
-
if !rp.config.Core.Dev {
437
-
scheme = "https"
438
-
}
439
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
440
-
xrpcc := &indigoxrpc.Client{
441
-
Host: host,
442
-
}
443
-
444
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
445
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
446
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
447
-
log.Println("failed to call XRPC repo.tree", xrpcerr)
448
-
rp.pages.Error503(w)
449
-
return
450
-
}
451
-
452
-
// readme content
453
-
var (
454
-
readmeContent string
455
-
readmeFileName string
456
-
)
457
-
458
-
for _, filename := range markup.ReadmeFilenames {
459
-
path := fmt.Sprintf("%s/%s", treePath, filename)
460
-
blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo)
461
-
if err != nil {
462
-
continue
463
-
}
464
-
465
-
if blobResp == nil {
466
-
continue
467
-
}
468
-
469
-
readmeContent = blobResp.Content
470
-
readmeFileName = path
471
-
break
472
-
}
473
-
474
-
// Convert XRPC response to internal types.RepoTreeResponse
475
-
files := make([]types.NiceTree, len(xrpcResp.Files))
476
-
for i, xrpcFile := range xrpcResp.Files {
477
-
file := types.NiceTree{
478
-
Name: xrpcFile.Name,
479
-
Mode: xrpcFile.Mode,
480
-
Size: int64(xrpcFile.Size),
481
-
IsFile: xrpcFile.Is_file,
482
-
IsSubtree: xrpcFile.Is_subtree,
483
-
}
484
-
485
-
// Convert last commit info if present
486
-
if xrpcFile.Last_commit != nil {
487
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
488
-
file.LastCommit = &types.LastCommitInfo{
489
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
490
-
Message: xrpcFile.Last_commit.Message,
491
-
When: commitWhen,
492
-
}
493
-
}
494
-
495
-
files[i] = file
496
-
}
497
-
498
-
result := types.RepoTreeResponse{
499
-
Ref: xrpcResp.Ref,
500
-
Files: files,
501
-
}
502
-
503
-
if xrpcResp.Parent != nil {
504
-
result.Parent = *xrpcResp.Parent
505
-
}
506
-
if xrpcResp.Dotdot != nil {
507
-
result.DotDot = *xrpcResp.Dotdot
508
-
}
509
-
510
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
511
-
// so we can safely redirect to the "parent" (which is the same file).
512
-
if len(result.Files) == 0 && result.Parent == treePath {
513
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
514
-
http.Redirect(w, r, redirectTo, http.StatusFound)
515
-
return
516
-
}
517
-
518
-
user := rp.oauth.GetUser(r)
519
-
520
-
var breadcrumbs [][]string
521
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
522
-
if treePath != "" {
523
-
for idx, elem := range strings.Split(treePath, "/") {
524
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
525
-
}
526
-
}
527
-
528
-
sortFiles(result.Files)
529
-
530
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
531
-
LoggedInUser: user,
532
-
BreadCrumbs: breadcrumbs,
533
-
TreePath: treePath,
534
-
RepoInfo: f.RepoInfo(user),
535
-
Readme: readmeContent,
536
-
ReadmeFileName: readmeFileName,
537
-
RepoTreeResponse: result,
538
-
})
539
-
}
540
-
541
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
542
-
f, err := rp.repoResolver.Resolve(r)
543
-
if err != nil {
544
-
log.Println("failed to get repo and knot", err)
545
-
return
546
-
}
547
-
548
-
scheme := "http"
549
-
if !rp.config.Core.Dev {
550
-
scheme = "https"
551
-
}
552
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
553
-
xrpcc := &indigoxrpc.Client{
554
-
Host: host,
555
-
}
556
-
557
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
558
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
559
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
560
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
561
-
rp.pages.Error503(w)
562
-
return
563
-
}
564
-
565
-
var result types.RepoTagsResponse
566
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
567
-
log.Println("failed to decode XRPC response", err)
568
-
rp.pages.Error503(w)
569
-
return
570
-
}
571
-
572
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
573
-
if err != nil {
574
-
log.Println("failed grab artifacts", err)
575
-
return
576
-
}
577
-
578
-
// convert artifacts to map for easy UI building
579
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
580
-
for _, a := range artifacts {
581
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
582
-
}
583
-
584
-
var danglingArtifacts []models.Artifact
585
-
for _, a := range artifacts {
586
-
found := false
587
-
for _, t := range result.Tags {
588
-
if t.Tag != nil {
589
-
if t.Tag.Hash == a.Tag {
590
-
found = true
591
-
}
592
-
}
593
-
}
594
-
595
-
if !found {
596
-
danglingArtifacts = append(danglingArtifacts, a)
597
-
}
598
-
}
599
-
600
-
user := rp.oauth.GetUser(r)
601
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
602
-
LoggedInUser: user,
603
-
RepoInfo: f.RepoInfo(user),
604
-
RepoTagsResponse: result,
605
-
ArtifactMap: artifactMap,
606
-
DanglingArtifacts: danglingArtifacts,
607
-
})
608
-
}
609
-
610
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
611
-
f, err := rp.repoResolver.Resolve(r)
612
-
if err != nil {
613
-
log.Println("failed to get repo and knot", err)
614
-
return
615
-
}
616
-
617
-
scheme := "http"
618
-
if !rp.config.Core.Dev {
619
-
scheme = "https"
620
-
}
621
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
622
-
xrpcc := &indigoxrpc.Client{
623
-
Host: host,
624
-
}
625
-
626
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
627
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
628
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
629
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
630
-
rp.pages.Error503(w)
631
-
return
632
-
}
633
-
634
-
var result types.RepoBranchesResponse
635
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
636
-
log.Println("failed to decode XRPC response", err)
637
-
rp.pages.Error503(w)
638
-
return
639
-
}
640
-
641
-
sortBranches(result.Branches)
642
-
643
-
user := rp.oauth.GetUser(r)
644
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
645
-
LoggedInUser: user,
646
-
RepoInfo: f.RepoInfo(user),
647
-
RepoBranchesResponse: result,
648
-
})
649
-
}
650
-
651
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
652
-
f, err := rp.repoResolver.Resolve(r)
653
-
if err != nil {
654
-
log.Println("failed to get repo and knot", err)
655
-
return
656
-
}
657
-
658
-
ref := chi.URLParam(r, "ref")
659
-
ref, _ = url.PathUnescape(ref)
660
-
661
-
filePath := chi.URLParam(r, "*")
662
-
filePath, _ = url.PathUnescape(filePath)
663
-
664
-
scheme := "http"
665
-
if !rp.config.Core.Dev {
666
-
scheme = "https"
667
-
}
668
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
669
-
xrpcc := &indigoxrpc.Client{
670
-
Host: host,
671
-
}
672
-
673
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
674
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
675
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
676
-
log.Println("failed to call XRPC repo.blob", xrpcerr)
677
-
rp.pages.Error503(w)
678
-
return
679
-
}
680
-
681
-
// Use XRPC response directly instead of converting to internal types
682
-
683
-
var breadcrumbs [][]string
684
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
685
-
if filePath != "" {
686
-
for idx, elem := range strings.Split(filePath, "/") {
687
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
688
-
}
689
-
}
690
-
691
-
showRendered := false
692
-
renderToggle := false
693
-
694
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
695
-
renderToggle = true
696
-
showRendered = r.URL.Query().Get("code") != "true"
697
-
}
698
-
699
-
var unsupported bool
700
-
var isImage bool
701
-
var isVideo bool
702
-
var contentSrc string
703
-
704
-
if resp.IsBinary != nil && *resp.IsBinary {
705
-
ext := strings.ToLower(filepath.Ext(resp.Path))
706
-
switch ext {
707
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
708
-
isImage = true
709
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
710
-
isVideo = true
711
-
default:
712
-
unsupported = true
713
-
}
714
-
715
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
716
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
717
-
718
-
baseURL := &url.URL{
719
-
Scheme: scheme,
720
-
Host: f.Knot,
721
-
Path: "/xrpc/sh.tangled.repo.blob",
722
-
}
723
-
query := baseURL.Query()
724
-
query.Set("repo", repoName)
725
-
query.Set("ref", ref)
726
-
query.Set("path", filePath)
727
-
query.Set("raw", "true")
728
-
baseURL.RawQuery = query.Encode()
729
-
blobURL := baseURL.String()
730
-
731
-
contentSrc = blobURL
732
-
if !rp.config.Core.Dev {
733
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
734
-
}
735
-
}
736
-
737
-
lines := 0
738
-
if resp.IsBinary == nil || !*resp.IsBinary {
739
-
lines = strings.Count(resp.Content, "\n") + 1
740
-
}
741
-
742
-
var sizeHint uint64
743
-
if resp.Size != nil {
744
-
sizeHint = uint64(*resp.Size)
745
-
} else {
746
-
sizeHint = uint64(len(resp.Content))
747
-
}
748
-
749
-
user := rp.oauth.GetUser(r)
750
-
751
-
// Determine if content is binary (dereference pointer)
752
-
isBinary := false
753
-
if resp.IsBinary != nil {
754
-
isBinary = *resp.IsBinary
755
-
}
756
-
757
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
758
-
LoggedInUser: user,
759
-
RepoInfo: f.RepoInfo(user),
760
-
BreadCrumbs: breadcrumbs,
761
-
ShowRendered: showRendered,
762
-
RenderToggle: renderToggle,
763
-
Unsupported: unsupported,
764
-
IsImage: isImage,
765
-
IsVideo: isVideo,
766
-
ContentSrc: contentSrc,
767
-
RepoBlob_Output: resp,
768
-
Contents: resp.Content,
769
-
Lines: lines,
770
-
SizeHint: sizeHint,
771
-
IsBinary: isBinary,
772
-
})
773
-
}
774
-
775
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
776
-
f, err := rp.repoResolver.Resolve(r)
777
-
if err != nil {
778
-
log.Println("failed to get repo and knot", err)
779
-
w.WriteHeader(http.StatusBadRequest)
780
-
return
781
-
}
782
-
783
-
ref := chi.URLParam(r, "ref")
784
-
ref, _ = url.PathUnescape(ref)
785
-
786
-
filePath := chi.URLParam(r, "*")
787
-
filePath, _ = url.PathUnescape(filePath)
788
-
789
-
scheme := "http"
790
-
if !rp.config.Core.Dev {
791
-
scheme = "https"
792
-
}
793
-
794
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
795
-
baseURL := &url.URL{
796
-
Scheme: scheme,
797
-
Host: f.Knot,
798
-
Path: "/xrpc/sh.tangled.repo.blob",
799
-
}
800
-
query := baseURL.Query()
801
-
query.Set("repo", repo)
802
-
query.Set("ref", ref)
803
-
query.Set("path", filePath)
804
-
query.Set("raw", "true")
805
-
baseURL.RawQuery = query.Encode()
806
-
blobURL := baseURL.String()
807
-
808
-
req, err := http.NewRequest("GET", blobURL, nil)
809
-
if err != nil {
810
-
log.Println("failed to create request", err)
811
-
return
812
-
}
813
-
814
-
// forward the If-None-Match header
815
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
816
-
req.Header.Set("If-None-Match", clientETag)
817
-
}
818
-
819
-
client := &http.Client{}
820
-
resp, err := client.Do(req)
821
-
if err != nil {
822
-
log.Println("failed to reach knotserver", err)
823
-
rp.pages.Error503(w)
824
-
return
825
-
}
826
-
defer resp.Body.Close()
827
-
828
-
// forward 304 not modified
829
-
if resp.StatusCode == http.StatusNotModified {
830
-
w.WriteHeader(http.StatusNotModified)
831
-
return
832
-
}
833
-
834
-
if resp.StatusCode != http.StatusOK {
835
-
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
836
-
w.WriteHeader(resp.StatusCode)
837
-
_, _ = io.Copy(w, resp.Body)
838
-
return
839
-
}
840
-
841
-
contentType := resp.Header.Get("Content-Type")
842
-
body, err := io.ReadAll(resp.Body)
843
-
if err != nil {
844
-
log.Printf("error reading response body from knotserver: %v", err)
845
-
w.WriteHeader(http.StatusInternalServerError)
846
-
return
847
-
}
848
-
849
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
850
-
// serve all textual content as text/plain
851
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
852
-
w.Write(body)
853
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
854
-
// serve images and videos with their original content type
855
-
w.Header().Set("Content-Type", contentType)
856
-
w.Write(body)
857
-
} else {
858
-
w.WriteHeader(http.StatusUnsupportedMediaType)
859
-
w.Write([]byte("unsupported content type"))
860
-
return
861
-
}
862
-
}
863
-
864
81
// isTextualMimeType returns true if the MIME type represents textual content
865
-
// that should be served as text/plain
866
-
func isTextualMimeType(mimeType string) bool {
867
-
textualTypes := []string{
868
-
"application/json",
869
-
"application/xml",
870
-
"application/yaml",
871
-
"application/x-yaml",
872
-
"application/toml",
873
-
"application/javascript",
874
-
"application/ecmascript",
875
-
"message/",
876
-
}
877
-
878
-
return slices.Contains(textualTypes, mimeType)
879
-
}
880
82
881
83
// modify the spindle configured for this repo
882
84
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
883
85
user := rp.oauth.GetUser(r)
884
86
l := rp.logger.With("handler", "EditSpindle")
885
87
l = l.With("did", user.Did)
886
-
l = l.With("handle", user.Handle)
887
88
888
89
errorId := "operation-error"
889
90
fail := func(msg string, err error) {
···
936
137
return
937
138
}
938
139
939
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
140
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
940
141
if err != nil {
941
142
fail("Failed to update spindle, no record found on PDS.", err)
942
143
return
943
144
}
944
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
145
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
945
146
Collection: tangled.RepoNSID,
946
147
Repo: newRepo.Did,
947
148
Rkey: newRepo.Rkey,
···
971
172
user := rp.oauth.GetUser(r)
972
173
l := rp.logger.With("handler", "AddLabel")
973
174
l = l.With("did", user.Did)
974
-
l = l.With("handle", user.Handle)
975
175
976
176
f, err := rp.repoResolver.Resolve(r)
977
177
if err != nil {
···
1040
240
1041
241
// emit a labelRecord
1042
242
labelRecord := label.AsRecord()
1043
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
243
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1044
244
Collection: tangled.LabelDefinitionNSID,
1045
245
Repo: label.Did,
1046
246
Rkey: label.Rkey,
···
1063
263
newRepo.Labels = append(newRepo.Labels, aturi)
1064
264
repoRecord := newRepo.AsRecord()
1065
265
1066
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
266
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1067
267
if err != nil {
1068
268
fail("Failed to update labels, no record found on PDS.", err)
1069
269
return
1070
270
}
1071
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
271
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1072
272
Collection: tangled.RepoNSID,
1073
273
Repo: newRepo.Did,
1074
274
Rkey: newRepo.Rkey,
···
1131
331
user := rp.oauth.GetUser(r)
1132
332
l := rp.logger.With("handler", "DeleteLabel")
1133
333
l = l.With("did", user.Did)
1134
-
l = l.With("handle", user.Handle)
1135
334
1136
335
f, err := rp.repoResolver.Resolve(r)
1137
336
if err != nil {
···
1161
360
}
1162
361
1163
362
// delete label record from PDS
1164
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
363
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1165
364
Collection: tangled.LabelDefinitionNSID,
1166
365
Repo: label.Did,
1167
366
Rkey: label.Rkey,
···
1183
382
newRepo.Labels = updated
1184
383
repoRecord := newRepo.AsRecord()
1185
384
1186
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
385
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1187
386
if err != nil {
1188
387
fail("Failed to update labels, no record found on PDS.", err)
1189
388
return
1190
389
}
1191
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
390
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1192
391
Collection: tangled.RepoNSID,
1193
392
Repo: newRepo.Did,
1194
393
Rkey: newRepo.Rkey,
···
1240
439
user := rp.oauth.GetUser(r)
1241
440
l := rp.logger.With("handler", "SubscribeLabel")
1242
441
l = l.With("did", user.Did)
1243
-
l = l.With("handle", user.Handle)
1244
442
1245
443
f, err := rp.repoResolver.Resolve(r)
1246
444
if err != nil {
···
1281
479
return
1282
480
}
1283
481
1284
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
482
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1285
483
if err != nil {
1286
484
fail("Failed to update labels, no record found on PDS.", err)
1287
485
return
1288
486
}
1289
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
487
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1290
488
Collection: tangled.RepoNSID,
1291
489
Repo: newRepo.Did,
1292
490
Rkey: newRepo.Rkey,
···
1327
525
user := rp.oauth.GetUser(r)
1328
526
l := rp.logger.With("handler", "UnsubscribeLabel")
1329
527
l = l.With("did", user.Did)
1330
-
l = l.With("handle", user.Handle)
1331
528
1332
529
f, err := rp.repoResolver.Resolve(r)
1333
530
if err != nil {
···
1370
567
return
1371
568
}
1372
569
1373
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
570
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1374
571
if err != nil {
1375
572
fail("Failed to update labels, no record found on PDS.", err)
1376
573
return
1377
574
}
1378
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
575
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1379
576
Collection: tangled.RepoNSID,
1380
577
Repo: newRepo.Did,
1381
578
Rkey: newRepo.Rkey,
···
1421
618
db.FilterContains("scope", subject.Collection().String()),
1422
619
)
1423
620
if err != nil {
1424
-
log.Println("failed to fetch label defs", err)
621
+
l.Error("failed to fetch label defs", "err", err)
1425
622
return
1426
623
}
1427
624
···
1432
629
1433
630
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1434
631
if err != nil {
1435
-
log.Println("failed to build label state", err)
632
+
l.Error("failed to build label state", "err", err)
1436
633
return
1437
634
}
1438
635
state := states[subject]
···
1469
666
db.FilterContains("scope", subject.Collection().String()),
1470
667
)
1471
668
if err != nil {
1472
-
log.Println("failed to fetch labels", err)
669
+
l.Error("failed to fetch labels", "err", err)
1473
670
return
1474
671
}
1475
672
···
1480
677
1481
678
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1482
679
if err != nil {
1483
-
log.Println("failed to build label state", err)
680
+
l.Error("failed to build label state", "err", err)
1484
681
return
1485
682
}
1486
683
state := states[subject]
···
1499
696
user := rp.oauth.GetUser(r)
1500
697
l := rp.logger.With("handler", "AddCollaborator")
1501
698
l = l.With("did", user.Did)
1502
-
l = l.With("handle", user.Handle)
1503
699
1504
700
f, err := rp.repoResolver.Resolve(r)
1505
701
if err != nil {
···
1546
742
currentUser := rp.oauth.GetUser(r)
1547
743
rkey := tid.TID()
1548
744
createdAt := time.Now()
1549
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
745
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1550
746
Collection: tangled.RepoCollaboratorNSID,
1551
747
Repo: currentUser.Did,
1552
748
Rkey: rkey,
···
1628
824
1629
825
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1630
826
user := rp.oauth.GetUser(r)
827
+
l := rp.logger.With("handler", "DeleteRepo")
1631
828
1632
829
noticeId := "operation-error"
1633
830
f, err := rp.repoResolver.Resolve(r)
1634
831
if err != nil {
1635
-
log.Println("failed to get repo and knot", err)
832
+
l.Error("failed to get repo and knot", "err", err)
1636
833
return
1637
834
}
1638
835
1639
836
// remove record from pds
1640
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
837
+
atpClient, err := rp.oauth.AuthorizedClient(r)
1641
838
if err != nil {
1642
-
log.Println("failed to get authorized client", err)
839
+
l.Error("failed to get authorized client", "err", err)
1643
840
return
1644
841
}
1645
-
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
842
+
_, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1646
843
Collection: tangled.RepoNSID,
1647
844
Repo: user.Did,
1648
845
Rkey: f.Rkey,
1649
846
})
1650
847
if err != nil {
1651
-
log.Printf("failed to delete record: %s", err)
848
+
l.Error("failed to delete record", "err", err)
1652
849
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1653
850
return
1654
851
}
1655
-
log.Println("removed repo record ", f.RepoAt().String())
852
+
l.Info("removed repo record", "aturi", f.RepoAt().String())
1656
853
1657
854
client, err := rp.oauth.ServiceClient(
1658
855
r,
···
1661
858
oauth.WithDev(rp.config.Core.Dev),
1662
859
)
1663
860
if err != nil {
1664
-
log.Println("failed to connect to knot server:", err)
861
+
l.Error("failed to connect to knot server", "err", err)
1665
862
return
1666
863
}
1667
864
···
1678
875
rp.pages.Notice(w, noticeId, err.Error())
1679
876
return
1680
877
}
1681
-
log.Println("deleted repo from knot")
878
+
l.Info("deleted repo from knot")
1682
879
1683
880
tx, err := rp.db.BeginTx(r.Context(), nil)
1684
881
if err != nil {
1685
-
log.Println("failed to start tx")
882
+
l.Error("failed to start tx")
1686
883
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1687
884
return
1688
885
}
···
1690
887
tx.Rollback()
1691
888
err = rp.enforcer.E.LoadPolicy()
1692
889
if err != nil {
1693
-
log.Println("failed to rollback policies")
890
+
l.Error("failed to rollback policies")
1694
891
}
1695
892
}()
1696
893
···
1704
901
did := c[0]
1705
902
rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1706
903
}
1707
-
log.Println("removed collaborators")
904
+
l.Info("removed collaborators")
1708
905
1709
906
// remove repo RBAC
1710
907
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
···
1719
916
rp.pages.Notice(w, noticeId, "Failed to update appview")
1720
917
return
1721
918
}
1722
-
log.Println("removed repo from db")
919
+
l.Info("removed repo from db")
1723
920
1724
921
err = tx.Commit()
1725
922
if err != nil {
1726
-
log.Println("failed to commit changes", err)
923
+
l.Error("failed to commit changes", "err", err)
1727
924
http.Error(w, err.Error(), http.StatusInternalServerError)
1728
925
return
1729
926
}
1730
927
1731
928
err = rp.enforcer.E.SavePolicy()
1732
929
if err != nil {
1733
-
log.Println("failed to update ACLs", err)
930
+
l.Error("failed to update ACLs", "err", err)
1734
931
http.Error(w, err.Error(), http.StatusInternalServerError)
1735
932
return
1736
933
}
···
1738
935
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1739
936
}
1740
937
1741
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1742
-
f, err := rp.repoResolver.Resolve(r)
1743
-
if err != nil {
1744
-
log.Println("failed to get repo and knot", err)
1745
-
return
1746
-
}
1747
-
1748
-
noticeId := "operation-error"
1749
-
branch := r.FormValue("branch")
1750
-
if branch == "" {
1751
-
http.Error(w, "malformed form", http.StatusBadRequest)
1752
-
return
1753
-
}
938
+
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
939
+
l := rp.logger.With("handler", "SyncRepoFork")
1754
940
1755
-
client, err := rp.oauth.ServiceClient(
1756
-
r,
1757
-
oauth.WithService(f.Knot),
1758
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1759
-
oauth.WithDev(rp.config.Core.Dev),
1760
-
)
1761
-
if err != nil {
1762
-
log.Println("failed to connect to knot server:", err)
1763
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1764
-
return
1765
-
}
1766
-
1767
-
xe := tangled.RepoSetDefaultBranch(
1768
-
r.Context(),
1769
-
client,
1770
-
&tangled.RepoSetDefaultBranch_Input{
1771
-
Repo: f.RepoAt().String(),
1772
-
DefaultBranch: branch,
1773
-
},
1774
-
)
1775
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1776
-
log.Println("xrpc failed", "err", xe)
1777
-
rp.pages.Notice(w, noticeId, err.Error())
1778
-
return
1779
-
}
1780
-
1781
-
rp.pages.HxRefresh(w)
1782
-
}
1783
-
1784
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1785
-
user := rp.oauth.GetUser(r)
1786
-
l := rp.logger.With("handler", "Secrets")
1787
-
l = l.With("handle", user.Handle)
1788
-
l = l.With("did", user.Did)
1789
-
1790
-
f, err := rp.repoResolver.Resolve(r)
1791
-
if err != nil {
1792
-
log.Println("failed to get repo and knot", err)
1793
-
return
1794
-
}
1795
-
1796
-
if f.Spindle == "" {
1797
-
log.Println("empty spindle cannot add/rm secret", err)
1798
-
return
1799
-
}
1800
-
1801
-
lxm := tangled.RepoAddSecretNSID
1802
-
if r.Method == http.MethodDelete {
1803
-
lxm = tangled.RepoRemoveSecretNSID
1804
-
}
1805
-
1806
-
spindleClient, err := rp.oauth.ServiceClient(
1807
-
r,
1808
-
oauth.WithService(f.Spindle),
1809
-
oauth.WithLxm(lxm),
1810
-
oauth.WithExp(60),
1811
-
oauth.WithDev(rp.config.Core.Dev),
1812
-
)
1813
-
if err != nil {
1814
-
log.Println("failed to create spindle client", err)
1815
-
return
1816
-
}
1817
-
1818
-
key := r.FormValue("key")
1819
-
if key == "" {
1820
-
w.WriteHeader(http.StatusBadRequest)
1821
-
return
1822
-
}
1823
-
1824
-
switch r.Method {
1825
-
case http.MethodPut:
1826
-
errorId := "add-secret-error"
1827
-
1828
-
value := r.FormValue("value")
1829
-
if value == "" {
1830
-
w.WriteHeader(http.StatusBadRequest)
1831
-
return
1832
-
}
1833
-
1834
-
err = tangled.RepoAddSecret(
1835
-
r.Context(),
1836
-
spindleClient,
1837
-
&tangled.RepoAddSecret_Input{
1838
-
Repo: f.RepoAt().String(),
1839
-
Key: key,
1840
-
Value: value,
1841
-
},
1842
-
)
1843
-
if err != nil {
1844
-
l.Error("Failed to add secret.", "err", err)
1845
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
1846
-
return
1847
-
}
1848
-
1849
-
case http.MethodDelete:
1850
-
errorId := "operation-error"
1851
-
1852
-
err = tangled.RepoRemoveSecret(
1853
-
r.Context(),
1854
-
spindleClient,
1855
-
&tangled.RepoRemoveSecret_Input{
1856
-
Repo: f.RepoAt().String(),
1857
-
Key: key,
1858
-
},
1859
-
)
1860
-
if err != nil {
1861
-
l.Error("Failed to delete secret.", "err", err)
1862
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1863
-
return
1864
-
}
1865
-
}
1866
-
1867
-
rp.pages.HxRefresh(w)
1868
-
}
1869
-
1870
-
type tab = map[string]any
1871
-
1872
-
var (
1873
-
// would be great to have ordered maps right about now
1874
-
settingsTabs []tab = []tab{
1875
-
{"Name": "general", "Icon": "sliders-horizontal"},
1876
-
{"Name": "access", "Icon": "users"},
1877
-
{"Name": "pipelines", "Icon": "layers-2"},
1878
-
}
1879
-
)
1880
-
1881
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1882
-
tabVal := r.URL.Query().Get("tab")
1883
-
if tabVal == "" {
1884
-
tabVal = "general"
1885
-
}
1886
-
1887
-
switch tabVal {
1888
-
case "general":
1889
-
rp.generalSettings(w, r)
1890
-
1891
-
case "access":
1892
-
rp.accessSettings(w, r)
1893
-
1894
-
case "pipelines":
1895
-
rp.pipelineSettings(w, r)
1896
-
}
1897
-
}
1898
-
1899
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1900
-
f, err := rp.repoResolver.Resolve(r)
1901
-
user := rp.oauth.GetUser(r)
1902
-
1903
-
scheme := "http"
1904
-
if !rp.config.Core.Dev {
1905
-
scheme = "https"
1906
-
}
1907
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1908
-
xrpcc := &indigoxrpc.Client{
1909
-
Host: host,
1910
-
}
1911
-
1912
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1913
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1914
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1915
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
1916
-
rp.pages.Error503(w)
1917
-
return
1918
-
}
1919
-
1920
-
var result types.RepoBranchesResponse
1921
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1922
-
log.Println("failed to decode XRPC response", err)
1923
-
rp.pages.Error503(w)
1924
-
return
1925
-
}
1926
-
1927
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1928
-
if err != nil {
1929
-
log.Println("failed to fetch labels", err)
1930
-
rp.pages.Error503(w)
1931
-
return
1932
-
}
1933
-
1934
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1935
-
if err != nil {
1936
-
log.Println("failed to fetch labels", err)
1937
-
rp.pages.Error503(w)
1938
-
return
1939
-
}
1940
-
// remove default labels from the labels list, if present
1941
-
defaultLabelMap := make(map[string]bool)
1942
-
for _, dl := range defaultLabels {
1943
-
defaultLabelMap[dl.AtUri().String()] = true
1944
-
}
1945
-
n := 0
1946
-
for _, l := range labels {
1947
-
if !defaultLabelMap[l.AtUri().String()] {
1948
-
labels[n] = l
1949
-
n++
1950
-
}
1951
-
}
1952
-
labels = labels[:n]
1953
-
1954
-
subscribedLabels := make(map[string]struct{})
1955
-
for _, l := range f.Repo.Labels {
1956
-
subscribedLabels[l] = struct{}{}
1957
-
}
1958
-
1959
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1960
-
// if all default labels are subbed, show the "unsubscribe all" button
1961
-
shouldSubscribeAll := false
1962
-
for _, dl := range defaultLabels {
1963
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1964
-
// one of the default labels is not subscribed to
1965
-
shouldSubscribeAll = true
1966
-
break
1967
-
}
1968
-
}
1969
-
1970
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1971
-
LoggedInUser: user,
1972
-
RepoInfo: f.RepoInfo(user),
1973
-
Branches: result.Branches,
1974
-
Labels: labels,
1975
-
DefaultLabels: defaultLabels,
1976
-
SubscribedLabels: subscribedLabels,
1977
-
ShouldSubscribeAll: shouldSubscribeAll,
1978
-
Tabs: settingsTabs,
1979
-
Tab: "general",
1980
-
})
1981
-
}
1982
-
1983
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1984
-
f, err := rp.repoResolver.Resolve(r)
1985
-
user := rp.oauth.GetUser(r)
1986
-
1987
-
repoCollaborators, err := f.Collaborators(r.Context())
1988
-
if err != nil {
1989
-
log.Println("failed to get collaborators", err)
1990
-
}
1991
-
1992
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1993
-
LoggedInUser: user,
1994
-
RepoInfo: f.RepoInfo(user),
1995
-
Tabs: settingsTabs,
1996
-
Tab: "access",
1997
-
Collaborators: repoCollaborators,
1998
-
})
1999
-
}
2000
-
2001
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2002
-
f, err := rp.repoResolver.Resolve(r)
2003
-
user := rp.oauth.GetUser(r)
2004
-
2005
-
// all spindles that the repo owner is a member of
2006
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2007
-
if err != nil {
2008
-
log.Println("failed to fetch spindles", err)
2009
-
return
2010
-
}
2011
-
2012
-
var secrets []*tangled.RepoListSecrets_Secret
2013
-
if f.Spindle != "" {
2014
-
if spindleClient, err := rp.oauth.ServiceClient(
2015
-
r,
2016
-
oauth.WithService(f.Spindle),
2017
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
2018
-
oauth.WithExp(60),
2019
-
oauth.WithDev(rp.config.Core.Dev),
2020
-
); err != nil {
2021
-
log.Println("failed to create spindle client", err)
2022
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2023
-
log.Println("failed to fetch secrets", err)
2024
-
} else {
2025
-
secrets = resp.Secrets
2026
-
}
2027
-
}
2028
-
2029
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2030
-
return strings.Compare(a.Key, b.Key)
2031
-
})
2032
-
2033
-
var dids []string
2034
-
for _, s := range secrets {
2035
-
dids = append(dids, s.CreatedBy)
2036
-
}
2037
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2038
-
2039
-
// convert to a more manageable form
2040
-
var niceSecret []map[string]any
2041
-
for id, s := range secrets {
2042
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2043
-
niceSecret = append(niceSecret, map[string]any{
2044
-
"Id": id,
2045
-
"Key": s.Key,
2046
-
"CreatedAt": when,
2047
-
"CreatedBy": resolvedIdents[id].Handle.String(),
2048
-
})
2049
-
}
2050
-
2051
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2052
-
LoggedInUser: user,
2053
-
RepoInfo: f.RepoInfo(user),
2054
-
Tabs: settingsTabs,
2055
-
Tab: "pipelines",
2056
-
Spindles: spindles,
2057
-
CurrentSpindle: f.Spindle,
2058
-
Secrets: niceSecret,
2059
-
})
2060
-
}
2061
-
2062
-
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2063
941
ref := chi.URLParam(r, "ref")
2064
942
ref, _ = url.PathUnescape(ref)
2065
943
2066
944
user := rp.oauth.GetUser(r)
2067
945
f, err := rp.repoResolver.Resolve(r)
2068
946
if err != nil {
2069
-
log.Printf("failed to resolve source repo: %v", err)
947
+
l.Error("failed to resolve source repo", "err", err)
2070
948
return
2071
949
}
2072
950
···
2110
988
}
2111
989
2112
990
func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
991
+
l := rp.logger.With("handler", "ForkRepo")
992
+
2113
993
user := rp.oauth.GetUser(r)
2114
994
f, err := rp.repoResolver.Resolve(r)
2115
995
if err != nil {
2116
-
log.Printf("failed to resolve source repo: %v", err)
996
+
l.Error("failed to resolve source repo", "err", err)
2117
997
return
2118
998
}
2119
999
···
2149
1029
}
2150
1030
2151
1031
// choose a name for a fork
2152
-
forkName := f.Name
1032
+
forkName := r.FormValue("repo_name")
1033
+
if forkName == "" {
1034
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
1035
+
return
1036
+
}
1037
+
2153
1038
// this check is *only* to see if the forked repo name already exists
2154
1039
// in the user's account.
2155
1040
existingRepo, err := db.GetRepo(
2156
1041
rp.db,
2157
1042
db.FilterEq("did", user.Did),
2158
-
db.FilterEq("name", f.Name),
1043
+
db.FilterEq("name", forkName),
2159
1044
)
2160
1045
if err != nil {
2161
-
if errors.Is(err, sql.ErrNoRows) {
2162
-
// no existing repo with this name found, we can use the name as is
2163
-
} else {
2164
-
log.Println("error fetching existing repo from db", "err", err)
1046
+
if !errors.Is(err, sql.ErrNoRows) {
1047
+
l.Error("error fetching existing repo from db", "err", err)
2165
1048
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2166
1049
return
2167
1050
}
2168
1051
} else if existingRepo != nil {
2169
-
// repo with this name already exists, append random string
2170
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1052
+
// repo with this name already exists
1053
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
1054
+
return
2171
1055
}
2172
1056
l = l.With("forkName", forkName)
2173
1057
···
2189
1073
Knot: targetKnot,
2190
1074
Rkey: rkey,
2191
1075
Source: sourceAt,
2192
-
Description: existingRepo.Description,
1076
+
Description: f.Repo.Description,
2193
1077
Created: time.Now(),
2194
-
Labels: models.DefaultLabelDefs(),
1078
+
Labels: rp.config.Label.DefaultLabelDefs,
2195
1079
}
2196
1080
record := repo.AsRecord()
2197
1081
2198
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1082
+
atpClient, err := rp.oauth.AuthorizedClient(r)
2199
1083
if err != nil {
2200
1084
l.Error("failed to create xrpcclient", "err", err)
2201
1085
rp.pages.Notice(w, "repo", "Failed to fork repository.")
2202
1086
return
2203
1087
}
2204
1088
2205
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1089
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2206
1090
Collection: tangled.RepoNSID,
2207
1091
Repo: user.Did,
2208
1092
Rkey: rkey,
···
2234
1118
rollback := func() {
2235
1119
err1 := tx.Rollback()
2236
1120
err2 := rp.enforcer.E.LoadPolicy()
2237
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1121
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
2238
1122
2239
1123
// ignore txn complete errors, this is okay
2240
1124
if errors.Is(err1, sql.ErrTxDone) {
···
2275
1159
2276
1160
err = db.AddRepo(tx, repo)
2277
1161
if err != nil {
2278
-
log.Println(err)
1162
+
l.Error("failed to AddRepo", "err", err)
2279
1163
rp.pages.Notice(w, "repo", "Failed to save repository information.")
2280
1164
return
2281
1165
}
···
2284
1168
p, _ := securejoin.SecureJoin(user.Did, forkName)
2285
1169
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
2286
1170
if err != nil {
2287
-
log.Println(err)
1171
+
l.Error("failed to add ACLs", "err", err)
2288
1172
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2289
1173
return
2290
1174
}
2291
1175
2292
1176
err = tx.Commit()
2293
1177
if err != nil {
2294
-
log.Println("failed to commit changes", err)
1178
+
l.Error("failed to commit changes", "err", err)
2295
1179
http.Error(w, err.Error(), http.StatusInternalServerError)
2296
1180
return
2297
1181
}
2298
1182
2299
1183
err = rp.enforcer.E.SavePolicy()
2300
1184
if err != nil {
2301
-
log.Println("failed to update ACLs", err)
1185
+
l.Error("failed to update ACLs", "err", err)
2302
1186
http.Error(w, err.Error(), http.StatusInternalServerError)
2303
1187
return
2304
1188
}
···
2307
1191
aturi = ""
2308
1192
2309
1193
rp.notifier.NewRepo(r.Context(), repo)
2310
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1194
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
2311
1195
}
2312
1196
}
2313
1197
2314
1198
// this is used to rollback changes made to the PDS
2315
1199
//
2316
1200
// it is a no-op if the provided ATURI is empty
2317
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1201
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2318
1202
if aturi == "" {
2319
1203
return nil
2320
1204
}
···
2325
1209
repo := parsed.Authority().String()
2326
1210
rkey := parsed.RecordKey().String()
2327
1211
2328
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1212
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2329
1213
Collection: collection,
2330
1214
Repo: repo,
2331
1215
Rkey: rkey,
2332
1216
})
2333
1217
return err
2334
1218
}
2335
-
2336
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2337
-
user := rp.oauth.GetUser(r)
2338
-
f, err := rp.repoResolver.Resolve(r)
2339
-
if err != nil {
2340
-
log.Println("failed to get repo and knot", err)
2341
-
return
2342
-
}
2343
-
2344
-
scheme := "http"
2345
-
if !rp.config.Core.Dev {
2346
-
scheme = "https"
2347
-
}
2348
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2349
-
xrpcc := &indigoxrpc.Client{
2350
-
Host: host,
2351
-
}
2352
-
2353
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2354
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2355
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2356
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
2357
-
rp.pages.Error503(w)
2358
-
return
2359
-
}
2360
-
2361
-
var branchResult types.RepoBranchesResponse
2362
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2363
-
log.Println("failed to decode XRPC branches response", err)
2364
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2365
-
return
2366
-
}
2367
-
branches := branchResult.Branches
2368
-
2369
-
sortBranches(branches)
2370
-
2371
-
var defaultBranch string
2372
-
for _, b := range branches {
2373
-
if b.IsDefault {
2374
-
defaultBranch = b.Name
2375
-
}
2376
-
}
2377
-
2378
-
base := defaultBranch
2379
-
head := defaultBranch
2380
-
2381
-
params := r.URL.Query()
2382
-
queryBase := params.Get("base")
2383
-
queryHead := params.Get("head")
2384
-
if queryBase != "" {
2385
-
base = queryBase
2386
-
}
2387
-
if queryHead != "" {
2388
-
head = queryHead
2389
-
}
2390
-
2391
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2392
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2393
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
2394
-
rp.pages.Error503(w)
2395
-
return
2396
-
}
2397
-
2398
-
var tags types.RepoTagsResponse
2399
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2400
-
log.Println("failed to decode XRPC tags response", err)
2401
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2402
-
return
2403
-
}
2404
-
2405
-
repoinfo := f.RepoInfo(user)
2406
-
2407
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2408
-
LoggedInUser: user,
2409
-
RepoInfo: repoinfo,
2410
-
Branches: branches,
2411
-
Tags: tags.Tags,
2412
-
Base: base,
2413
-
Head: head,
2414
-
})
2415
-
}
2416
-
2417
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2418
-
user := rp.oauth.GetUser(r)
2419
-
f, err := rp.repoResolver.Resolve(r)
2420
-
if err != nil {
2421
-
log.Println("failed to get repo and knot", err)
2422
-
return
2423
-
}
2424
-
2425
-
var diffOpts types.DiffOpts
2426
-
if d := r.URL.Query().Get("diff"); d == "split" {
2427
-
diffOpts.Split = true
2428
-
}
2429
-
2430
-
// if user is navigating to one of
2431
-
// /compare/{base}/{head}
2432
-
// /compare/{base}...{head}
2433
-
base := chi.URLParam(r, "base")
2434
-
head := chi.URLParam(r, "head")
2435
-
if base == "" && head == "" {
2436
-
rest := chi.URLParam(r, "*") // master...feature/xyz
2437
-
parts := strings.SplitN(rest, "...", 2)
2438
-
if len(parts) == 2 {
2439
-
base = parts[0]
2440
-
head = parts[1]
2441
-
}
2442
-
}
2443
-
2444
-
base, _ = url.PathUnescape(base)
2445
-
head, _ = url.PathUnescape(head)
2446
-
2447
-
if base == "" || head == "" {
2448
-
log.Printf("invalid comparison")
2449
-
rp.pages.Error404(w)
2450
-
return
2451
-
}
2452
-
2453
-
scheme := "http"
2454
-
if !rp.config.Core.Dev {
2455
-
scheme = "https"
2456
-
}
2457
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2458
-
xrpcc := &indigoxrpc.Client{
2459
-
Host: host,
2460
-
}
2461
-
2462
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2463
-
2464
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2465
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2466
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
2467
-
rp.pages.Error503(w)
2468
-
return
2469
-
}
2470
-
2471
-
var branches types.RepoBranchesResponse
2472
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2473
-
log.Println("failed to decode XRPC branches response", err)
2474
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2475
-
return
2476
-
}
2477
-
2478
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2479
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2480
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
2481
-
rp.pages.Error503(w)
2482
-
return
2483
-
}
2484
-
2485
-
var tags types.RepoTagsResponse
2486
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2487
-
log.Println("failed to decode XRPC tags response", err)
2488
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2489
-
return
2490
-
}
2491
-
2492
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2493
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2494
-
log.Println("failed to call XRPC repo.compare", xrpcerr)
2495
-
rp.pages.Error503(w)
2496
-
return
2497
-
}
2498
-
2499
-
var formatPatch types.RepoFormatPatchResponse
2500
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2501
-
log.Println("failed to decode XRPC compare response", err)
2502
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2503
-
return
2504
-
}
2505
-
2506
-
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2507
-
2508
-
repoinfo := f.RepoInfo(user)
2509
-
2510
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2511
-
LoggedInUser: user,
2512
-
RepoInfo: repoinfo,
2513
-
Branches: branches.Branches,
2514
-
Tags: tags.Tags,
2515
-
Base: base,
2516
-
Head: head,
2517
-
Diff: &diff,
2518
-
DiffOpts: diffOpts,
2519
-
})
2520
-
2521
-
}
-35
appview/repo/repo_util.go
-35
appview/repo/repo_util.go
···
1
1
package repo
2
2
3
3
import (
4
-
"context"
5
4
"crypto/rand"
6
-
"fmt"
7
5
"math/big"
8
6
"slices"
9
7
"sort"
···
90
88
}
91
89
92
90
return
93
-
}
94
-
95
-
// emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid
96
-
// and resolves all dids to handles and returns a new map[string]string
97
-
func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string {
98
-
if emailToDidMap == nil {
99
-
return nil
100
-
}
101
-
102
-
var dids []string
103
-
for _, v := range emailToDidMap {
104
-
dids = append(dids, v)
105
-
}
106
-
resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids)
107
-
108
-
didHandleMap := make(map[string]string)
109
-
for _, identity := range resolvedIdents {
110
-
if !identity.Handle.IsInvalidHandle() {
111
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
112
-
} else {
113
-
didHandleMap[identity.DID.String()] = identity.DID.String()
114
-
}
115
-
}
116
-
117
-
// Create map of email to didOrHandle for commit display
118
-
emailToDidOrHandle := make(map[string]string)
119
-
for email, did := range emailToDidMap {
120
-
if didOrHandle, ok := didHandleMap[did]; ok {
121
-
emailToDidOrHandle[email] = didOrHandle
122
-
}
123
-
}
124
-
125
-
return emailToDidOrHandle
126
91
}
127
92
128
93
func randomString(n int) string {
+16
-19
appview/repo/router.go
+16
-19
appview/repo/router.go
···
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
-
r.Get("/", rp.RepoIndex)
13
-
r.Get("/feed.atom", rp.RepoAtomFeed)
14
-
r.Get("/commits/{ref}", rp.RepoLog)
12
+
r.Get("/", rp.Index)
13
+
r.Get("/opengraph", rp.Opengraph)
14
+
r.Get("/feed.atom", rp.AtomFeed)
15
+
r.Get("/commits/{ref}", rp.Log)
15
16
r.Route("/tree/{ref}", func(r chi.Router) {
16
-
r.Get("/", rp.RepoIndex)
17
-
r.Get("/*", rp.RepoTree)
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
18
19
})
19
-
r.Get("/commit/{ref}", rp.RepoCommit)
20
-
r.Get("/branches", rp.RepoBranches)
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
+
r.Delete("/branches", rp.DeleteBranch)
21
23
r.Route("/tags", func(r chi.Router) {
22
-
r.Get("/", rp.RepoTags)
24
+
r.Get("/", rp.Tags)
23
25
r.Route("/{tag}", func(r chi.Router) {
24
26
r.Get("/download/{file}", rp.DownloadArtifact)
25
27
···
35
37
})
36
38
})
37
39
})
38
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
40
+
r.Get("/blob/{ref}/*", rp.Blob)
39
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
40
42
41
43
// intentionally doesn't use /* as this isn't
···
52
54
})
53
55
54
56
r.Route("/compare", func(r chi.Router) {
55
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
57
+
r.Get("/", rp.CompareNew) // start an new comparison
56
58
57
59
// we have to wildcard here since we want to support GitHub's compare syntax
58
60
// /compare/{ref1}...{ref2}
59
61
// for example:
60
62
// /compare/master...some/feature
61
63
// /compare/master...example.com:another/feature <- this is a fork
62
-
r.Get("/{base}/{head}", rp.RepoCompare)
63
-
r.Get("/*", rp.RepoCompare)
64
+
r.Get("/{base}/{head}", rp.Compare)
65
+
r.Get("/*", rp.Compare)
64
66
})
65
67
66
68
// label panel in issues/pulls/discussions/tasks
···
72
74
// settings routes, needs auth
73
75
r.Group(func(r chi.Router) {
74
76
r.Use(middleware.AuthMiddleware(rp.oauth))
75
-
// repo description can only be edited by owner
76
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) {
77
-
r.Put("/", rp.RepoDescription)
78
-
r.Get("/", rp.RepoDescription)
79
-
r.Get("/edit", rp.RepoDescriptionEdit)
80
-
})
81
77
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
82
-
r.Get("/", rp.RepoSettings)
78
+
r.Get("/", rp.Settings)
79
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
83
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
84
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
85
82
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+442
appview/repo/settings.go
+442
appview/repo/settings.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"slices"
8
+
"strings"
9
+
"time"
10
+
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/oauth"
14
+
"tangled.org/core/appview/pages"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/types"
17
+
18
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
19
+
lexutil "github.com/bluesky-social/indigo/lex/util"
20
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
21
+
)
22
+
23
+
type tab = map[string]any
24
+
25
+
var (
26
+
// would be great to have ordered maps right about now
27
+
settingsTabs []tab = []tab{
28
+
{"Name": "general", "Icon": "sliders-horizontal"},
29
+
{"Name": "access", "Icon": "users"},
30
+
{"Name": "pipelines", "Icon": "layers-2"},
31
+
}
32
+
)
33
+
34
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
35
+
l := rp.logger.With("handler", "SetDefaultBranch")
36
+
37
+
f, err := rp.repoResolver.Resolve(r)
38
+
if err != nil {
39
+
l.Error("failed to get repo and knot", "err", err)
40
+
return
41
+
}
42
+
43
+
noticeId := "operation-error"
44
+
branch := r.FormValue("branch")
45
+
if branch == "" {
46
+
http.Error(w, "malformed form", http.StatusBadRequest)
47
+
return
48
+
}
49
+
50
+
client, err := rp.oauth.ServiceClient(
51
+
r,
52
+
oauth.WithService(f.Knot),
53
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
54
+
oauth.WithDev(rp.config.Core.Dev),
55
+
)
56
+
if err != nil {
57
+
l.Error("failed to connect to knot server", "err", err)
58
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
59
+
return
60
+
}
61
+
62
+
xe := tangled.RepoSetDefaultBranch(
63
+
r.Context(),
64
+
client,
65
+
&tangled.RepoSetDefaultBranch_Input{
66
+
Repo: f.RepoAt().String(),
67
+
DefaultBranch: branch,
68
+
},
69
+
)
70
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
71
+
l.Error("xrpc failed", "err", xe)
72
+
rp.pages.Notice(w, noticeId, err.Error())
73
+
return
74
+
}
75
+
76
+
rp.pages.HxRefresh(w)
77
+
}
78
+
79
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
80
+
user := rp.oauth.GetUser(r)
81
+
l := rp.logger.With("handler", "Secrets")
82
+
l = l.With("did", user.Did)
83
+
84
+
f, err := rp.repoResolver.Resolve(r)
85
+
if err != nil {
86
+
l.Error("failed to get repo and knot", "err", err)
87
+
return
88
+
}
89
+
90
+
if f.Spindle == "" {
91
+
l.Error("empty spindle cannot add/rm secret", "err", err)
92
+
return
93
+
}
94
+
95
+
lxm := tangled.RepoAddSecretNSID
96
+
if r.Method == http.MethodDelete {
97
+
lxm = tangled.RepoRemoveSecretNSID
98
+
}
99
+
100
+
spindleClient, err := rp.oauth.ServiceClient(
101
+
r,
102
+
oauth.WithService(f.Spindle),
103
+
oauth.WithLxm(lxm),
104
+
oauth.WithExp(60),
105
+
oauth.WithDev(rp.config.Core.Dev),
106
+
)
107
+
if err != nil {
108
+
l.Error("failed to create spindle client", "err", err)
109
+
return
110
+
}
111
+
112
+
key := r.FormValue("key")
113
+
if key == "" {
114
+
w.WriteHeader(http.StatusBadRequest)
115
+
return
116
+
}
117
+
118
+
switch r.Method {
119
+
case http.MethodPut:
120
+
errorId := "add-secret-error"
121
+
122
+
value := r.FormValue("value")
123
+
if value == "" {
124
+
w.WriteHeader(http.StatusBadRequest)
125
+
return
126
+
}
127
+
128
+
err = tangled.RepoAddSecret(
129
+
r.Context(),
130
+
spindleClient,
131
+
&tangled.RepoAddSecret_Input{
132
+
Repo: f.RepoAt().String(),
133
+
Key: key,
134
+
Value: value,
135
+
},
136
+
)
137
+
if err != nil {
138
+
l.Error("Failed to add secret.", "err", err)
139
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
140
+
return
141
+
}
142
+
143
+
case http.MethodDelete:
144
+
errorId := "operation-error"
145
+
146
+
err = tangled.RepoRemoveSecret(
147
+
r.Context(),
148
+
spindleClient,
149
+
&tangled.RepoRemoveSecret_Input{
150
+
Repo: f.RepoAt().String(),
151
+
Key: key,
152
+
},
153
+
)
154
+
if err != nil {
155
+
l.Error("Failed to delete secret.", "err", err)
156
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
157
+
return
158
+
}
159
+
}
160
+
161
+
rp.pages.HxRefresh(w)
162
+
}
163
+
164
+
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
165
+
tabVal := r.URL.Query().Get("tab")
166
+
if tabVal == "" {
167
+
tabVal = "general"
168
+
}
169
+
170
+
switch tabVal {
171
+
case "general":
172
+
rp.generalSettings(w, r)
173
+
174
+
case "access":
175
+
rp.accessSettings(w, r)
176
+
177
+
case "pipelines":
178
+
rp.pipelineSettings(w, r)
179
+
}
180
+
}
181
+
182
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
183
+
l := rp.logger.With("handler", "generalSettings")
184
+
185
+
f, err := rp.repoResolver.Resolve(r)
186
+
user := rp.oauth.GetUser(r)
187
+
188
+
scheme := "http"
189
+
if !rp.config.Core.Dev {
190
+
scheme = "https"
191
+
}
192
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
193
+
xrpcc := &indigoxrpc.Client{
194
+
Host: host,
195
+
}
196
+
197
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
198
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
199
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
200
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
201
+
rp.pages.Error503(w)
202
+
return
203
+
}
204
+
205
+
var result types.RepoBranchesResponse
206
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
207
+
l.Error("failed to decode XRPC response", "err", err)
208
+
rp.pages.Error503(w)
209
+
return
210
+
}
211
+
212
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
213
+
if err != nil {
214
+
l.Error("failed to fetch labels", "err", err)
215
+
rp.pages.Error503(w)
216
+
return
217
+
}
218
+
219
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
220
+
if err != nil {
221
+
l.Error("failed to fetch labels", "err", err)
222
+
rp.pages.Error503(w)
223
+
return
224
+
}
225
+
// remove default labels from the labels list, if present
226
+
defaultLabelMap := make(map[string]bool)
227
+
for _, dl := range defaultLabels {
228
+
defaultLabelMap[dl.AtUri().String()] = true
229
+
}
230
+
n := 0
231
+
for _, l := range labels {
232
+
if !defaultLabelMap[l.AtUri().String()] {
233
+
labels[n] = l
234
+
n++
235
+
}
236
+
}
237
+
labels = labels[:n]
238
+
239
+
subscribedLabels := make(map[string]struct{})
240
+
for _, l := range f.Repo.Labels {
241
+
subscribedLabels[l] = struct{}{}
242
+
}
243
+
244
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
245
+
// if all default labels are subbed, show the "unsubscribe all" button
246
+
shouldSubscribeAll := false
247
+
for _, dl := range defaultLabels {
248
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
249
+
// one of the default labels is not subscribed to
250
+
shouldSubscribeAll = true
251
+
break
252
+
}
253
+
}
254
+
255
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
256
+
LoggedInUser: user,
257
+
RepoInfo: f.RepoInfo(user),
258
+
Branches: result.Branches,
259
+
Labels: labels,
260
+
DefaultLabels: defaultLabels,
261
+
SubscribedLabels: subscribedLabels,
262
+
ShouldSubscribeAll: shouldSubscribeAll,
263
+
Tabs: settingsTabs,
264
+
Tab: "general",
265
+
})
266
+
}
267
+
268
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
269
+
l := rp.logger.With("handler", "accessSettings")
270
+
271
+
f, err := rp.repoResolver.Resolve(r)
272
+
user := rp.oauth.GetUser(r)
273
+
274
+
repoCollaborators, err := f.Collaborators(r.Context())
275
+
if err != nil {
276
+
l.Error("failed to get collaborators", "err", err)
277
+
}
278
+
279
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
280
+
LoggedInUser: user,
281
+
RepoInfo: f.RepoInfo(user),
282
+
Tabs: settingsTabs,
283
+
Tab: "access",
284
+
Collaborators: repoCollaborators,
285
+
})
286
+
}
287
+
288
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
289
+
l := rp.logger.With("handler", "pipelineSettings")
290
+
291
+
f, err := rp.repoResolver.Resolve(r)
292
+
user := rp.oauth.GetUser(r)
293
+
294
+
// all spindles that the repo owner is a member of
295
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
296
+
if err != nil {
297
+
l.Error("failed to fetch spindles", "err", err)
298
+
return
299
+
}
300
+
301
+
var secrets []*tangled.RepoListSecrets_Secret
302
+
if f.Spindle != "" {
303
+
if spindleClient, err := rp.oauth.ServiceClient(
304
+
r,
305
+
oauth.WithService(f.Spindle),
306
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
307
+
oauth.WithExp(60),
308
+
oauth.WithDev(rp.config.Core.Dev),
309
+
); err != nil {
310
+
l.Error("failed to create spindle client", "err", err)
311
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
312
+
l.Error("failed to fetch secrets", "err", err)
313
+
} else {
314
+
secrets = resp.Secrets
315
+
}
316
+
}
317
+
318
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
319
+
return strings.Compare(a.Key, b.Key)
320
+
})
321
+
322
+
var dids []string
323
+
for _, s := range secrets {
324
+
dids = append(dids, s.CreatedBy)
325
+
}
326
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
327
+
328
+
// convert to a more manageable form
329
+
var niceSecret []map[string]any
330
+
for id, s := range secrets {
331
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
332
+
niceSecret = append(niceSecret, map[string]any{
333
+
"Id": id,
334
+
"Key": s.Key,
335
+
"CreatedAt": when,
336
+
"CreatedBy": resolvedIdents[id].Handle.String(),
337
+
})
338
+
}
339
+
340
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
341
+
LoggedInUser: user,
342
+
RepoInfo: f.RepoInfo(user),
343
+
Tabs: settingsTabs,
344
+
Tab: "pipelines",
345
+
Spindles: spindles,
346
+
CurrentSpindle: f.Spindle,
347
+
Secrets: niceSecret,
348
+
})
349
+
}
350
+
351
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
352
+
l := rp.logger.With("handler", "EditBaseSettings")
353
+
354
+
noticeId := "repo-base-settings-error"
355
+
356
+
f, err := rp.repoResolver.Resolve(r)
357
+
if err != nil {
358
+
l.Error("failed to get repo and knot", "err", err)
359
+
w.WriteHeader(http.StatusBadRequest)
360
+
return
361
+
}
362
+
363
+
client, err := rp.oauth.AuthorizedClient(r)
364
+
if err != nil {
365
+
l.Error("failed to get client")
366
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
367
+
return
368
+
}
369
+
370
+
var (
371
+
description = r.FormValue("description")
372
+
website = r.FormValue("website")
373
+
topicStr = r.FormValue("topics")
374
+
)
375
+
376
+
err = rp.validator.ValidateURI(website)
377
+
if website != "" && err != nil {
378
+
l.Error("invalid uri", "err", err)
379
+
rp.pages.Notice(w, noticeId, err.Error())
380
+
return
381
+
}
382
+
383
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
384
+
if err != nil {
385
+
l.Error("invalid topics", "err", err)
386
+
rp.pages.Notice(w, noticeId, err.Error())
387
+
return
388
+
}
389
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
390
+
391
+
newRepo := f.Repo
392
+
newRepo.Description = description
393
+
newRepo.Website = website
394
+
newRepo.Topics = topics
395
+
record := newRepo.AsRecord()
396
+
397
+
tx, err := rp.db.BeginTx(r.Context(), nil)
398
+
if err != nil {
399
+
l.Error("failed to begin transaction", "err", err)
400
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
401
+
return
402
+
}
403
+
defer tx.Rollback()
404
+
405
+
err = db.PutRepo(tx, newRepo)
406
+
if err != nil {
407
+
l.Error("failed to update repository", "err", err)
408
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
409
+
return
410
+
}
411
+
412
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
413
+
if err != nil {
414
+
// failed to get record
415
+
l.Error("failed to get repo record", "err", err)
416
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
417
+
return
418
+
}
419
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
420
+
Collection: tangled.RepoNSID,
421
+
Repo: newRepo.Did,
422
+
Rkey: newRepo.Rkey,
423
+
SwapRecord: ex.Cid,
424
+
Record: &lexutil.LexiconTypeDecoder{
425
+
Val: &record,
426
+
},
427
+
})
428
+
429
+
if err != nil {
430
+
l.Error("failed to perferom update-repo query", "err", err)
431
+
// failed to get record
432
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
433
+
return
434
+
}
435
+
436
+
err = tx.Commit()
437
+
if err != nil {
438
+
l.Error("failed to commit", "err", err)
439
+
}
440
+
441
+
rp.pages.HxRefresh(w)
442
+
}
+107
appview/repo/tree.go
+107
appview/repo/tree.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
"time"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/pages"
12
+
xrpcclient "tangled.org/core/appview/xrpcclient"
13
+
"tangled.org/core/types"
14
+
15
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
+
"github.com/go-chi/chi/v5"
17
+
"github.com/go-git/go-git/v5/plumbing"
18
+
)
19
+
20
+
func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) {
21
+
l := rp.logger.With("handler", "RepoTree")
22
+
f, err := rp.repoResolver.Resolve(r)
23
+
if err != nil {
24
+
l.Error("failed to fully resolve repo", "err", err)
25
+
return
26
+
}
27
+
ref := chi.URLParam(r, "ref")
28
+
ref, _ = url.PathUnescape(ref)
29
+
// if the tree path has a trailing slash, let's strip it
30
+
// so we don't 404
31
+
treePath := chi.URLParam(r, "*")
32
+
treePath, _ = url.PathUnescape(treePath)
33
+
treePath = strings.TrimSuffix(treePath, "/")
34
+
scheme := "http"
35
+
if !rp.config.Core.Dev {
36
+
scheme = "https"
37
+
}
38
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
39
+
xrpcc := &indigoxrpc.Client{
40
+
Host: host,
41
+
}
42
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
43
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
44
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
45
+
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
46
+
rp.pages.Error503(w)
47
+
return
48
+
}
49
+
// Convert XRPC response to internal types.RepoTreeResponse
50
+
files := make([]types.NiceTree, len(xrpcResp.Files))
51
+
for i, xrpcFile := range xrpcResp.Files {
52
+
file := types.NiceTree{
53
+
Name: xrpcFile.Name,
54
+
Mode: xrpcFile.Mode,
55
+
Size: int64(xrpcFile.Size),
56
+
IsFile: xrpcFile.Is_file,
57
+
IsSubtree: xrpcFile.Is_subtree,
58
+
}
59
+
// Convert last commit info if present
60
+
if xrpcFile.Last_commit != nil {
61
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
62
+
file.LastCommit = &types.LastCommitInfo{
63
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
64
+
Message: xrpcFile.Last_commit.Message,
65
+
When: commitWhen,
66
+
}
67
+
}
68
+
files[i] = file
69
+
}
70
+
result := types.RepoTreeResponse{
71
+
Ref: xrpcResp.Ref,
72
+
Files: files,
73
+
}
74
+
if xrpcResp.Parent != nil {
75
+
result.Parent = *xrpcResp.Parent
76
+
}
77
+
if xrpcResp.Dotdot != nil {
78
+
result.DotDot = *xrpcResp.Dotdot
79
+
}
80
+
if xrpcResp.Readme != nil {
81
+
result.ReadmeFileName = xrpcResp.Readme.Filename
82
+
result.Readme = xrpcResp.Readme.Contents
83
+
}
84
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
85
+
// so we can safely redirect to the "parent" (which is the same file).
86
+
if len(result.Files) == 0 && result.Parent == treePath {
87
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
88
+
http.Redirect(w, r, redirectTo, http.StatusFound)
89
+
return
90
+
}
91
+
user := rp.oauth.GetUser(r)
92
+
var breadcrumbs [][]string
93
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
94
+
if treePath != "" {
95
+
for idx, elem := range strings.Split(treePath, "/") {
96
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
97
+
}
98
+
}
99
+
sortFiles(result.Files)
100
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
101
+
LoggedInUser: user,
102
+
BreadCrumbs: breadcrumbs,
103
+
TreePath: treePath,
104
+
RepoInfo: f.RepoInfo(user),
105
+
RepoTreeResponse: result,
106
+
})
107
+
}
+2
appview/reporesolver/resolver.go
+2
appview/reporesolver/resolver.go
+55
-2
appview/settings/settings.go
+55
-2
appview/settings/settings.go
···
22
22
"tangled.org/core/tid"
23
23
24
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
26
lexutil "github.com/bluesky-social/indigo/lex/util"
26
27
"github.com/gliderlabs/ssh"
27
28
"github.com/google/uuid"
···
41
42
{"Name": "profile", "Icon": "user"},
42
43
{"Name": "keys", "Icon": "key"},
43
44
{"Name": "emails", "Icon": "mail"},
45
+
{"Name": "notifications", "Icon": "bell"},
44
46
}
45
47
)
46
48
···
66
68
r.Get("/verify", s.emailsVerify)
67
69
r.Post("/verify/resend", s.emailsVerifyResend)
68
70
r.Post("/primary", s.emailsPrimary)
71
+
})
72
+
73
+
r.Route("/notifications", func(r chi.Router) {
74
+
r.Get("/", s.notificationsSettings)
75
+
r.Put("/", s.updateNotificationPreferences)
69
76
})
70
77
71
78
return r
···
79
86
Tabs: settingsTabs,
80
87
Tab: "profile",
81
88
})
89
+
}
90
+
91
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
92
+
user := s.OAuth.GetUser(r)
93
+
did := s.OAuth.GetDid(r)
94
+
95
+
prefs, err := db.GetNotificationPreference(s.Db, did)
96
+
if err != nil {
97
+
log.Printf("failed to get notification preferences: %s", err)
98
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
99
+
return
100
+
}
101
+
102
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
103
+
LoggedInUser: user,
104
+
Preferences: prefs,
105
+
Tabs: settingsTabs,
106
+
Tab: "notifications",
107
+
})
108
+
}
109
+
110
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
111
+
did := s.OAuth.GetDid(r)
112
+
113
+
prefs := &models.NotificationPreferences{
114
+
UserDid: syntax.DID(did),
115
+
RepoStarred: r.FormValue("repo_starred") == "on",
116
+
IssueCreated: r.FormValue("issue_created") == "on",
117
+
IssueCommented: r.FormValue("issue_commented") == "on",
118
+
IssueClosed: r.FormValue("issue_closed") == "on",
119
+
PullCreated: r.FormValue("pull_created") == "on",
120
+
PullCommented: r.FormValue("pull_commented") == "on",
121
+
PullMerged: r.FormValue("pull_merged") == "on",
122
+
Followed: r.FormValue("followed") == "on",
123
+
UserMentioned: r.FormValue("user_mentioned") == "on",
124
+
EmailNotifications: r.FormValue("email_notifications") == "on",
125
+
}
126
+
127
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
128
+
if err != nil {
129
+
log.Printf("failed to update notification preferences: %s", err)
130
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
131
+
return
132
+
}
133
+
134
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
82
135
}
83
136
84
137
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
···
419
472
}
420
473
421
474
// store in pds too
422
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
475
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
423
476
Collection: tangled.PublicKeyNSID,
424
477
Repo: did,
425
478
Rkey: rkey,
···
476
529
477
530
if rkey != "" {
478
531
// remove from pds too
479
-
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
532
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
480
533
Collection: tangled.PublicKeyNSID,
481
534
Repo: did,
482
535
Rkey: rkey,
+18
appview/signup/requests.go
+18
appview/signup/requests.go
···
102
102
103
103
return result.DID, nil
104
104
}
105
+
106
+
func (s *Signup) deleteAccountRequest(did string) error {
107
+
body := map[string]string{
108
+
"did": did,
109
+
}
110
+
111
+
resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true)
112
+
if err != nil {
113
+
return err
114
+
}
115
+
defer resp.Body.Close()
116
+
117
+
if resp.StatusCode != http.StatusOK {
118
+
return s.handlePdsError(resp, "delete account")
119
+
}
120
+
121
+
return nil
122
+
}
+159
-40
appview/signup/signup.go
+159
-40
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"context"
6
+
"encoding/json"
7
+
"errors"
5
8
"fmt"
6
9
"log/slog"
7
10
"net/http"
11
+
"net/url"
8
12
"os"
9
13
"strings"
10
14
···
17
21
"tangled.org/core/appview/models"
18
22
"tangled.org/core/appview/pages"
19
23
"tangled.org/core/appview/state/userutil"
20
-
"tangled.org/core/appview/xrpcclient"
21
24
"tangled.org/core/idresolver"
22
25
)
23
26
···
26
29
db *db.DB
27
30
cf *dns.Cloudflare
28
31
posthog posthog.Client
29
-
xrpc *xrpcclient.Client
30
32
idResolver *idresolver.Resolver
31
33
pages *pages.Pages
32
34
l *slog.Logger
···
61
63
disallowed := make(map[string]bool)
62
64
63
65
if filepath == "" {
64
-
logger.Debug("no disallowed nicknames file configured")
66
+
logger.Warn("no disallowed nicknames file configured")
65
67
return disallowed
66
68
}
67
69
···
116
118
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
117
119
switch r.Method {
118
120
case http.MethodGet:
119
-
s.pages.Signup(w)
121
+
s.pages.Signup(w, pages.SignupParams{
122
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
123
+
})
120
124
case http.MethodPost:
121
125
if s.cf == nil {
122
126
http.Error(w, "signup is disabled", http.StatusFailedDependency)
127
+
return
123
128
}
124
129
emailId := r.FormValue("email")
130
+
cfToken := r.FormValue("cf-turnstile-response")
125
131
126
132
noticeId := "signup-msg"
133
+
134
+
if err := s.validateCaptcha(cfToken, r); err != nil {
135
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
136
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
137
+
return
138
+
}
139
+
127
140
if !email.IsValidEmail(emailId) {
128
141
s.pages.Notice(w, noticeId, "Invalid email address.")
129
142
return
···
204
217
return
205
218
}
206
219
207
-
did, err := s.createAccountRequest(username, password, email, code)
208
-
if err != nil {
209
-
s.l.Error("failed to create account", "error", err)
210
-
s.pages.Notice(w, "signup-error", err.Error())
211
-
return
212
-
}
213
-
214
220
if s.cf == nil {
215
221
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
216
222
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
217
223
return
218
224
}
219
225
220
-
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
221
-
Type: "TXT",
222
-
Name: "_atproto." + username,
223
-
Content: fmt.Sprintf(`"did=%s"`, did),
224
-
TTL: 6400,
225
-
Proxied: false,
226
-
})
226
+
// Execute signup transactionally with rollback capability
227
+
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
227
228
if err != nil {
228
-
s.l.Error("failed to create DNS record", "error", err)
229
-
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
// Error already logged and notice already sent
230
230
return
231
231
}
232
+
}
233
+
}
232
234
233
-
err = db.AddEmail(s.db, models.Email{
234
-
Did: did,
235
-
Address: email,
236
-
Verified: true,
237
-
Primary: true,
238
-
})
239
-
if err != nil {
240
-
s.l.Error("failed to add email", "error", err)
241
-
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
242
-
return
235
+
// executeSignupTransaction performs the signup process transactionally with rollback
236
+
func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
237
+
var recordID string
238
+
var did string
239
+
var emailAdded bool
240
+
241
+
success := false
242
+
defer func() {
243
+
if !success {
244
+
s.l.Info("rolling back signup transaction", "username", username, "did", did)
245
+
246
+
// Rollback DNS record
247
+
if recordID != "" {
248
+
if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
249
+
s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
250
+
} else {
251
+
s.l.Info("successfully rolled back DNS record", "recordID", recordID)
252
+
}
253
+
}
254
+
255
+
// Rollback PDS account
256
+
if did != "" {
257
+
if err := s.deleteAccountRequest(did); err != nil {
258
+
s.l.Error("failed to rollback PDS account", "error", err, "did", did)
259
+
} else {
260
+
s.l.Info("successfully rolled back PDS account", "did", did)
261
+
}
262
+
}
263
+
264
+
// Rollback email from database
265
+
if emailAdded {
266
+
if err := db.DeleteEmail(s.db, did, email); err != nil {
267
+
s.l.Error("failed to rollback email from database", "error", err, "email", email)
268
+
} else {
269
+
s.l.Info("successfully rolled back email from database", "email", email)
270
+
}
271
+
}
272
+
}
273
+
}()
274
+
275
+
// step 1: create account in PDS
276
+
did, err := s.createAccountRequest(username, password, email, code)
277
+
if err != nil {
278
+
s.l.Error("failed to create account", "error", err)
279
+
s.pages.Notice(w, "signup-error", err.Error())
280
+
return err
281
+
}
282
+
283
+
// step 2: create DNS record with actual DID
284
+
recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{
285
+
Type: "TXT",
286
+
Name: "_atproto." + username,
287
+
Content: fmt.Sprintf(`"did=%s"`, did),
288
+
TTL: 6400,
289
+
Proxied: false,
290
+
})
291
+
if err != nil {
292
+
s.l.Error("failed to create DNS record", "error", err)
293
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
294
+
return err
295
+
}
296
+
297
+
// step 3: add email to database
298
+
err = db.AddEmail(s.db, models.Email{
299
+
Did: did,
300
+
Address: email,
301
+
Verified: true,
302
+
Primary: true,
303
+
})
304
+
if err != nil {
305
+
s.l.Error("failed to add email", "error", err)
306
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
307
+
return err
308
+
}
309
+
emailAdded = true
310
+
311
+
// if we get here, we've successfully created the account and added the email
312
+
success = true
313
+
314
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
315
+
<a class="underline text-black dark:text-white" href="/login">login</a>
316
+
with <code>%s.tngl.sh</code>.`, username))
317
+
318
+
// clean up inflight signup asynchronously
319
+
go func() {
320
+
if err := db.DeleteInflightSignup(s.db, email); err != nil {
321
+
s.l.Error("failed to delete inflight signup", "error", err)
322
+
}
323
+
}()
324
+
325
+
return nil
326
+
}
327
+
328
+
type turnstileResponse struct {
329
+
Success bool `json:"success"`
330
+
ErrorCodes []string `json:"error-codes,omitempty"`
331
+
ChallengeTs string `json:"challenge_ts,omitempty"`
332
+
Hostname string `json:"hostname,omitempty"`
333
+
}
334
+
335
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
336
+
if cfToken == "" {
337
+
return errors.New("captcha token is empty")
338
+
}
339
+
340
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
341
+
return errors.New("turnstile secret key not configured")
342
+
}
343
+
344
+
data := url.Values{}
345
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
346
+
data.Set("response", cfToken)
347
+
348
+
// include the client IP if we have it
349
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
350
+
data.Set("remoteip", remoteIP)
351
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
352
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
353
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
243
354
}
355
+
} else {
356
+
data.Set("remoteip", r.RemoteAddr)
357
+
}
244
358
245
-
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
246
-
<a class="underline text-black dark:text-white" href="/login">login</a>
247
-
with <code>%s.tngl.sh</code>.`, username))
359
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
360
+
if err != nil {
361
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
362
+
}
363
+
defer resp.Body.Close()
248
364
249
-
go func() {
250
-
err := db.DeleteInflightSignup(s.db, email)
251
-
if err != nil {
252
-
s.l.Error("failed to delete inflight signup", "error", err)
253
-
}
254
-
}()
255
-
return
365
+
var turnstileResp turnstileResponse
366
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
367
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
256
368
}
369
+
370
+
if !turnstileResp.Success {
371
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
372
+
return errors.New("turnstile validation failed")
373
+
}
374
+
375
+
return nil
257
376
}
+14
-5
appview/spindles/spindles.go
+14
-5
appview/spindles/spindles.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
146
147
}
147
148
148
149
instance := r.FormValue("instance")
150
+
// Strip protocol, trailing slashes, and whitespace
151
+
// Rkey cannot contain slashes
152
+
instance = strings.TrimSpace(instance)
153
+
instance = strings.TrimPrefix(instance, "https://")
154
+
instance = strings.TrimPrefix(instance, "http://")
155
+
instance = strings.TrimSuffix(instance, "/")
149
156
if instance == "" {
150
157
s.Pages.Notice(w, noticeId, "Incomplete form.")
151
158
return
···
189
196
return
190
197
}
191
198
192
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
199
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
193
200
var exCid *string
194
201
if ex != nil {
195
202
exCid = ex.Cid
196
203
}
197
204
198
205
// re-announce by registering under same rkey
199
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
206
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
207
Collection: tangled.SpindleNSID,
201
208
Repo: user.Did,
202
209
Rkey: instance,
···
332
339
return
333
340
}
334
341
335
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
342
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
336
343
Collection: tangled.SpindleNSID,
337
344
Repo: user.Did,
338
345
Rkey: instance,
···
484
491
}
485
492
486
493
member := r.FormValue("member")
494
+
member = strings.TrimPrefix(member, "@")
487
495
if member == "" {
488
496
l.Error("empty member")
489
497
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
542
550
return
543
551
}
544
552
545
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
553
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
546
554
Collection: tangled.SpindleMemberNSID,
547
555
Repo: user.Did,
548
556
Rkey: rkey,
···
613
621
}
614
622
615
623
member := r.FormValue("member")
624
+
member = strings.TrimPrefix(member, "@")
616
625
if member == "" {
617
626
l.Error("empty member")
618
627
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
683
692
}
684
693
685
694
// remove from pds
686
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
695
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
687
696
Collection: tangled.SpindleMemberNSID,
688
697
Repo: user.Did,
689
698
Rkey: members[0].Rkey,
+3
-2
appview/state/follow.go
+3
-2
appview/state/follow.go
···
26
26
subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject)
27
27
if err != nil {
28
28
log.Println("failed to follow, invalid did")
29
+
return
29
30
}
30
31
31
32
if currentUser.Did == subjectIdent.DID.String() {
···
43
44
case http.MethodPost:
44
45
createdAt := time.Now().Format(time.RFC3339)
45
46
rkey := tid.TID()
46
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
47
48
Collection: tangled.GraphFollowNSID,
48
49
Repo: currentUser.Did,
49
50
Rkey: rkey,
···
88
89
return
89
90
}
90
91
91
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
92
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
92
93
Collection: tangled.GraphFollowNSID,
93
94
Repo: currentUser.Did,
94
95
Rkey: follow.Rkey,
+154
appview/state/gfi.go
+154
appview/state/gfi.go
···
1
+
package state
2
+
3
+
import (
4
+
"log"
5
+
"net/http"
6
+
"sort"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/appview/db"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/pages"
12
+
"tangled.org/core/appview/pagination"
13
+
"tangled.org/core/consts"
14
+
)
15
+
16
+
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
17
+
user := s.oauth.GetUser(r)
18
+
19
+
page := pagination.FromContext(r.Context())
20
+
21
+
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
22
+
23
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
24
+
if err != nil {
25
+
log.Println("failed to get gfi label def", err)
26
+
s.pages.Error500(w)
27
+
return
28
+
}
29
+
30
+
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
31
+
if err != nil {
32
+
log.Println("failed to get repo labels", err)
33
+
s.pages.Error503(w)
34
+
return
35
+
}
36
+
37
+
if len(repoLabels) == 0 {
38
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
39
+
LoggedInUser: user,
40
+
RepoGroups: []*models.RepoGroup{},
41
+
LabelDefs: make(map[string]*models.LabelDefinition),
42
+
Page: page,
43
+
GfiLabel: gfiLabelDef,
44
+
})
45
+
return
46
+
}
47
+
48
+
repoUris := make([]string, 0, len(repoLabels))
49
+
for _, rl := range repoLabels {
50
+
repoUris = append(repoUris, rl.RepoAt.String())
51
+
}
52
+
53
+
allIssues, err := db.GetIssuesPaginated(
54
+
s.db,
55
+
pagination.Page{
56
+
Limit: 500,
57
+
},
58
+
db.FilterIn("repo_at", repoUris),
59
+
db.FilterEq("open", 1),
60
+
)
61
+
if err != nil {
62
+
log.Println("failed to get issues", err)
63
+
s.pages.Error503(w)
64
+
return
65
+
}
66
+
67
+
var goodFirstIssues []models.Issue
68
+
for _, issue := range allIssues {
69
+
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
70
+
goodFirstIssues = append(goodFirstIssues, issue)
71
+
}
72
+
}
73
+
74
+
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
75
+
for _, issue := range goodFirstIssues {
76
+
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
77
+
group.Issues = append(group.Issues, issue)
78
+
} else {
79
+
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
80
+
Repo: issue.Repo,
81
+
Issues: []models.Issue{issue},
82
+
}
83
+
}
84
+
}
85
+
86
+
var sortedGroups []*models.RepoGroup
87
+
for _, group := range repoGroups {
88
+
sortedGroups = append(sortedGroups, group)
89
+
}
90
+
91
+
sort.Slice(sortedGroups, func(i, j int) bool {
92
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
93
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
94
+
95
+
// If one is tangled and the other isn't, non-tangled comes first
96
+
if iIsTangled != jIsTangled {
97
+
return jIsTangled // true if j is tangled (i should come first)
98
+
}
99
+
100
+
// Both tangled or both not tangled: sort by name
101
+
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
102
+
})
103
+
104
+
groupStart := page.Offset
105
+
groupEnd := page.Offset + page.Limit
106
+
if groupStart > len(sortedGroups) {
107
+
groupStart = len(sortedGroups)
108
+
}
109
+
if groupEnd > len(sortedGroups) {
110
+
groupEnd = len(sortedGroups)
111
+
}
112
+
113
+
paginatedGroups := sortedGroups[groupStart:groupEnd]
114
+
115
+
var allIssuesFromGroups []models.Issue
116
+
for _, group := range paginatedGroups {
117
+
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
118
+
}
119
+
120
+
var allLabelDefs []models.LabelDefinition
121
+
if len(allIssuesFromGroups) > 0 {
122
+
labelDefUris := make(map[string]bool)
123
+
for _, issue := range allIssuesFromGroups {
124
+
for labelDefUri := range issue.Labels.Inner() {
125
+
labelDefUris[labelDefUri] = true
126
+
}
127
+
}
128
+
129
+
uriList := make([]string, 0, len(labelDefUris))
130
+
for uri := range labelDefUris {
131
+
uriList = append(uriList, uri)
132
+
}
133
+
134
+
if len(uriList) > 0 {
135
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
136
+
if err != nil {
137
+
log.Println("failed to fetch labels", err)
138
+
}
139
+
}
140
+
}
141
+
142
+
labelDefsMap := make(map[string]*models.LabelDefinition)
143
+
for i := range allLabelDefs {
144
+
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
145
+
}
146
+
147
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
148
+
LoggedInUser: user,
149
+
RepoGroups: paginatedGroups,
150
+
LabelDefs: labelDefsMap,
151
+
Page: page,
152
+
GfiLabel: gfiLabelDef,
153
+
})
154
+
}
+17
-2
appview/state/knotstream.go
+17
-2
appview/state/knotstream.go
···
25
25
)
26
26
27
27
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
28
+
logger := log.FromContext(ctx)
29
+
logger = log.SubLogger(logger, "knotstream")
30
+
28
31
knots, err := db.GetRegistrations(
29
32
d,
30
33
db.FilterIsNot("registered", "null"),
···
39
42
srcs[s] = struct{}{}
40
43
}
41
44
42
-
logger := log.New("knotstream")
43
45
cache := cache.New(c.Redis.Addr)
44
46
cursorStore := cursor.NewRedisCursorStore(cache)
45
47
···
172
174
})
173
175
}
174
176
175
-
return db.InsertRepoLanguages(d, langs)
177
+
tx, err := d.Begin()
178
+
if err != nil {
179
+
return err
180
+
}
181
+
defer tx.Rollback()
182
+
183
+
// update appview's cache
184
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
185
+
if err != nil {
186
+
fmt.Printf("failed; %s\n", err)
187
+
// non-fatal
188
+
}
189
+
190
+
return tx.Commit()
176
191
}
177
192
178
193
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+69
appview/state/login.go
+69
appview/state/login.go
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"strings"
7
+
8
+
"tangled.org/core/appview/pages"
9
+
)
10
+
11
+
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
12
+
l := s.logger.With("handler", "Login")
13
+
14
+
switch r.Method {
15
+
case http.MethodGet:
16
+
returnURL := r.URL.Query().Get("return_url")
17
+
errorCode := r.URL.Query().Get("error")
18
+
s.pages.Login(w, pages.LoginParams{
19
+
ReturnUrl: returnURL,
20
+
ErrorCode: errorCode,
21
+
})
22
+
case http.MethodPost:
23
+
handle := r.FormValue("handle")
24
+
25
+
// when users copy their handle from bsky.app, it tends to have these characters around it:
26
+
//
27
+
// @nelind.dk:
28
+
// \u202a ensures that the handle is always rendered left to right and
29
+
// \u202c reverts that so the rest of the page renders however it should
30
+
handle = strings.TrimPrefix(handle, "\u202a")
31
+
handle = strings.TrimSuffix(handle, "\u202c")
32
+
33
+
// `@` is harmless
34
+
handle = strings.TrimPrefix(handle, "@")
35
+
36
+
// basic handle validation
37
+
if !strings.Contains(handle, ".") {
38
+
l.Error("invalid handle format", "raw", handle)
39
+
s.pages.Notice(
40
+
w,
41
+
"login-msg",
42
+
fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle),
43
+
)
44
+
return
45
+
}
46
+
47
+
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
48
+
if err != nil {
49
+
l.Error("failed to start auth", "err", err)
50
+
http.Error(w, err.Error(), http.StatusInternalServerError)
51
+
return
52
+
}
53
+
54
+
s.pages.HxRedirect(w, redirectURL)
55
+
}
56
+
}
57
+
58
+
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
59
+
l := s.logger.With("handler", "Logout")
60
+
61
+
err := s.oauth.DeleteSession(w, r)
62
+
if err != nil {
63
+
l.Error("failed to logout", "err", err)
64
+
} else {
65
+
l.Info("logged out successfully")
66
+
}
67
+
68
+
s.pages.HxRedirect(w, "/login")
69
+
}
+5
-2
appview/state/profile.go
+5
-2
appview/state/profile.go
···
336
336
profile.Did = did
337
337
}
338
338
followCards[i] = pages.FollowCard{
339
+
LoggedInUser: loggedInUser,
339
340
UserDid: did,
340
341
FollowStatus: followStatus,
341
342
FollowersCount: followStats.Followers,
···
537
538
profile.Description = r.FormValue("description")
538
539
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
539
540
profile.Location = r.FormValue("location")
541
+
profile.Pronouns = r.FormValue("pronouns")
540
542
541
543
var links [5]string
542
544
for i := range 5 {
···
633
635
vanityStats = append(vanityStats, string(v.Kind))
634
636
}
635
637
636
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
638
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
637
639
var cid *string
638
640
if ex != nil {
639
641
cid = ex.Cid
640
642
}
641
643
642
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
644
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
643
645
Collection: tangled.ActorProfileNSID,
644
646
Repo: user.Did,
645
647
Rkey: "self",
···
651
653
Location: &profile.Location,
652
654
PinnedRepositories: pinnedRepoStrings,
653
655
Stats: vanityStats[:],
656
+
Pronouns: &profile.Pronouns,
654
657
}},
655
658
SwapRecord: cid,
656
659
})
+11
-9
appview/state/reaction.go
+11
-9
appview/state/reaction.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
11
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
12
12
"tangled.org/core/api/tangled"
13
13
"tangled.org/core/appview/db"
14
14
"tangled.org/core/appview/models"
···
47
47
case http.MethodPost:
48
48
createdAt := time.Now().Format(time.RFC3339)
49
49
rkey := tid.TID()
50
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
51
51
Collection: tangled.FeedReactionNSID,
52
52
Repo: currentUser.Did,
53
53
Rkey: rkey,
···
70
70
return
71
71
}
72
72
73
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
73
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
74
74
if err != nil {
75
-
log.Println("failed to get reaction count for ", subjectUri)
75
+
log.Println("failed to get reactions for ", subjectUri)
76
76
}
77
77
78
78
log.Println("created atproto record: ", resp.Uri)
···
80
80
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
81
81
ThreadAt: subjectUri,
82
82
Kind: reactionKind,
83
-
Count: count,
83
+
Count: reactionMap[reactionKind].Count,
84
+
Users: reactionMap[reactionKind].Users,
84
85
IsReacted: true,
85
86
})
86
87
···
92
93
return
93
94
}
94
95
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
96
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
97
Collection: tangled.FeedReactionNSID,
97
98
Repo: currentUser.Did,
98
99
Rkey: reaction.Rkey,
···
109
110
// this is not an issue, the firehose event might have already done this
110
111
}
111
112
112
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
113
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
113
114
if err != nil {
114
-
log.Println("failed to get reaction count for ", subjectUri)
115
+
log.Println("failed to get reactions for ", subjectUri)
115
116
return
116
117
}
117
118
118
119
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
119
120
ThreadAt: subjectUri,
120
121
Kind: reactionKind,
121
-
Count: count,
122
+
Count: reactionMap[reactionKind].Count,
123
+
Users: reactionMap[reactionKind].Users,
122
124
IsReacted: false,
123
125
})
124
126
+122
-59
appview/state/router.go
+122
-59
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
-
"github.com/gorilla/sessions"
9
8
"tangled.org/core/appview/issues"
10
9
"tangled.org/core/appview/knots"
11
10
"tangled.org/core/appview/labels"
12
11
"tangled.org/core/appview/middleware"
13
-
oauthhandler "tangled.org/core/appview/oauth/handler"
12
+
"tangled.org/core/appview/notifications"
14
13
"tangled.org/core/appview/pipelines"
15
14
"tangled.org/core/appview/pulls"
16
15
"tangled.org/core/appview/repo"
···
35
34
36
35
router.Get("/favicon.svg", s.Favicon)
37
36
router.Get("/favicon.ico", s.Favicon)
37
+
router.Get("/pwa-manifest.json", s.PWAManifest)
38
+
router.Get("/robots.txt", s.RobotsTxt)
38
39
39
40
userRouter := s.UserRouter(&middleware)
40
41
standardRouter := s.StandardRouter(&middleware)
41
42
42
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
43
44
pat := chi.URLParam(r, "*")
44
-
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
45
-
userRouter.ServeHTTP(w, r)
46
-
} else {
47
-
// Check if the first path element is a valid handle without '@' or a flattened DID
48
-
pathParts := strings.SplitN(pat, "/", 2)
49
-
if len(pathParts) > 0 {
50
-
if userutil.IsHandleNoAt(pathParts[0]) {
51
-
// Redirect to the same path but with '@' prefixed to the handle
52
-
redirectPath := "@" + pat
53
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
54
-
return
55
-
} else if userutil.IsFlattenedDid(pathParts[0]) {
56
-
// Redirect to the unflattened DID version
57
-
unflattenedDid := userutil.UnflattenDid(pathParts[0])
58
-
var redirectPath string
59
-
if len(pathParts) > 1 {
60
-
redirectPath = unflattenedDid + "/" + pathParts[1]
61
-
} else {
62
-
redirectPath = unflattenedDid
63
-
}
64
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
65
-
return
66
-
}
45
+
pathParts := strings.SplitN(pat, "/", 2)
46
+
47
+
if len(pathParts) > 0 {
48
+
firstPart := pathParts[0]
49
+
50
+
// if using a DID or handle, just continue as per usual
51
+
if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
52
+
userRouter.ServeHTTP(w, r)
53
+
return
54
+
}
55
+
56
+
// if using a flattened DID (like you would in go modules), unflatten
57
+
if userutil.IsFlattenedDid(firstPart) {
58
+
unflattenedDid := userutil.UnflattenDid(firstPart)
59
+
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
60
+
61
+
redirectURL := *r.URL
62
+
redirectURL.Path = "/" + redirectPath
63
+
64
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
65
+
return
66
+
}
67
+
68
+
// if using a handle with @, rewrite to work without @
69
+
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
70
+
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
71
+
72
+
redirectURL := *r.URL
73
+
redirectURL.Path = "/" + redirectPath
74
+
75
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
76
+
return
67
77
}
68
-
standardRouter.ServeHTTP(w, r)
78
+
69
79
}
80
+
81
+
standardRouter.ServeHTTP(w, r)
70
82
})
71
83
72
84
return router
···
79
91
r.Get("/", s.Profile)
80
92
r.Get("/feed.atom", s.AtomFeedPage)
81
93
82
-
// redirect /@handle/repo.git -> /@handle/repo
83
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
84
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
85
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
86
-
})
87
-
88
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
89
95
r.Use(mw.GoImport())
90
96
r.Mount("/", s.RepoRouter(mw))
91
97
r.Mount("/issues", s.IssuesRouter(mw))
92
98
r.Mount("/pulls", s.PullsRouter(mw))
93
-
r.Mount("/pipelines", s.PipelinesRouter(mw))
94
-
r.Mount("/labels", s.LabelsRouter(mw))
99
+
r.Mount("/pipelines", s.PipelinesRouter())
100
+
r.Mount("/labels", s.LabelsRouter())
95
101
96
102
// These routes get proxied to the knot
97
103
r.Get("/info/refs", s.InfoRefs)
···
115
121
116
122
r.Get("/", s.HomeOrTimeline)
117
123
r.Get("/timeline", s.Timeline)
118
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
124
+
r.Get("/upgradeBanner", s.UpgradeBanner)
119
125
120
126
// special-case handler for serving tangled.org/core
121
127
r.Get("/core", s.Core())
128
+
129
+
r.Get("/login", s.Login)
130
+
r.Post("/login", s.Login)
131
+
r.Post("/logout", s.Logout)
122
132
123
133
r.Route("/repo", func(r chi.Router) {
124
134
r.Route("/new", func(r chi.Router) {
···
129
139
// r.Post("/import", s.ImportRepo)
130
140
})
131
141
142
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
143
+
132
144
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
133
145
r.Post("/", s.Follow)
134
146
r.Delete("/", s.Follow)
···
156
168
r.Mount("/strings", s.StringsRouter(mw))
157
169
r.Mount("/knots", s.KnotsRouter())
158
170
r.Mount("/spindles", s.SpindlesRouter())
171
+
r.Mount("/notifications", s.NotificationsRouter(mw))
172
+
159
173
r.Mount("/signup", s.SignupRouter())
160
-
r.Mount("/", s.OAuthRouter())
174
+
r.Mount("/", s.oauth.Router())
161
175
162
176
r.Get("/keys/{user}", s.Keys)
163
177
r.Get("/terms", s.TermsOfService)
164
178
r.Get("/privacy", s.PrivacyPolicy)
179
+
r.Get("/brand", s.Brand)
165
180
166
181
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
167
182
s.pages.Error404(w)
···
175
190
return func(w http.ResponseWriter, r *http.Request) {
176
191
if r.URL.Query().Get("go-get") == "1" {
177
192
w.Header().Set("Content-Type", "text/html")
178
-
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`))
193
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
179
194
return
180
195
}
181
196
···
183
198
}
184
199
}
185
200
186
-
func (s *State) OAuthRouter() http.Handler {
187
-
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
188
-
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
189
-
return oauth.Router()
190
-
}
191
-
192
201
func (s *State) SettingsRouter() http.Handler {
193
202
settings := &settings.Settings{
194
203
Db: s.db,
···
201
210
}
202
211
203
212
func (s *State) SpindlesRouter() http.Handler {
204
-
logger := log.New("spindles")
213
+
logger := log.SubLogger(s.logger, "spindles")
205
214
206
215
spindles := &spindles.Spindles{
207
216
Db: s.db,
···
217
226
}
218
227
219
228
func (s *State) KnotsRouter() http.Handler {
220
-
logger := log.New("knots")
229
+
logger := log.SubLogger(s.logger, "knots")
221
230
222
231
knots := &knots.Knots{
223
232
Db: s.db,
···
234
243
}
235
244
236
245
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
237
-
logger := log.New("strings")
246
+
logger := log.SubLogger(s.logger, "strings")
238
247
239
248
strs := &avstrings.Strings{
240
249
Db: s.db,
···
249
258
}
250
259
251
260
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
252
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
261
+
issues := issues.New(
262
+
s.oauth,
263
+
s.repoResolver,
264
+
s.pages,
265
+
s.idResolver,
266
+
s.db,
267
+
s.config,
268
+
s.notifier,
269
+
s.validator,
270
+
s.indexer.Issues,
271
+
log.SubLogger(s.logger, "issues"),
272
+
)
253
273
return issues.Router(mw)
254
274
}
255
275
256
276
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
257
-
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
277
+
pulls := pulls.New(
278
+
s.oauth,
279
+
s.repoResolver,
280
+
s.pages,
281
+
s.idResolver,
282
+
s.db,
283
+
s.config,
284
+
s.notifier,
285
+
s.enforcer,
286
+
s.validator,
287
+
s.indexer.Pulls,
288
+
log.SubLogger(s.logger, "pulls"),
289
+
)
258
290
return pulls.Router(mw)
259
291
}
260
292
261
293
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
262
-
logger := log.New("repo")
263
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator)
294
+
repo := repo.New(
295
+
s.oauth,
296
+
s.repoResolver,
297
+
s.pages,
298
+
s.spindlestream,
299
+
s.idResolver,
300
+
s.db,
301
+
s.config,
302
+
s.notifier,
303
+
s.enforcer,
304
+
log.SubLogger(s.logger, "repo"),
305
+
s.validator,
306
+
)
264
307
return repo.Router(mw)
265
308
}
266
309
267
-
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
268
-
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
269
-
return pipes.Router(mw)
310
+
func (s *State) PipelinesRouter() http.Handler {
311
+
pipes := pipelines.New(
312
+
s.oauth,
313
+
s.repoResolver,
314
+
s.pages,
315
+
s.spindlestream,
316
+
s.idResolver,
317
+
s.db,
318
+
s.config,
319
+
s.enforcer,
320
+
log.SubLogger(s.logger, "pipelines"),
321
+
)
322
+
return pipes.Router()
323
+
}
324
+
325
+
func (s *State) LabelsRouter() http.Handler {
326
+
ls := labels.New(
327
+
s.oauth,
328
+
s.pages,
329
+
s.db,
330
+
s.validator,
331
+
s.enforcer,
332
+
log.SubLogger(s.logger, "labels"),
333
+
)
334
+
return ls.Router()
270
335
}
271
336
272
-
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
273
-
ls := labels.New(s.oauth, s.pages, s.db, s.validator)
274
-
return ls.Router(mw)
337
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
338
+
notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications"))
339
+
return notifs.Router(mw)
275
340
}
276
341
277
342
func (s *State) SignupRouter() http.Handler {
278
-
logger := log.New("signup")
279
-
280
-
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
343
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup"))
281
344
return sig.Router()
282
345
}
+3
-1
appview/state/spindlestream.go
+3
-1
appview/state/spindlestream.go
···
22
22
)
23
23
24
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
25
+
logger := log.FromContext(ctx)
26
+
logger = log.SubLogger(logger, "spindlestream")
27
+
25
28
spindles, err := db.GetSpindles(
26
29
d,
27
30
db.FilterIsNot("verified", "null"),
···
36
39
srcs[src] = struct{}{}
37
40
}
38
41
39
-
logger := log.New("spindlestream")
40
42
cache := cache.New(c.Redis.Addr)
41
43
cursorStore := cursor.NewRedisCursorStore(cache)
42
44
+2
-2
appview/state/star.go
+2
-2
appview/state/star.go
···
40
40
case http.MethodPost:
41
41
createdAt := time.Now().Format(time.RFC3339)
42
42
rkey := tid.TID()
43
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
44
44
Collection: tangled.FeedStarNSID,
45
45
Repo: currentUser.Did,
46
46
Rkey: rkey,
···
92
92
return
93
93
}
94
94
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
95
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
96
Collection: tangled.FeedStarNSID,
97
97
Repo: currentUser.Did,
98
98
Rkey: star.Rkey,
+120
-49
appview/state/state.go
+120
-49
appview/state/state.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"strings"
12
11
"time"
13
12
14
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
-
"github.com/bluesky-social/indigo/atproto/syntax"
16
-
lexutil "github.com/bluesky-social/indigo/lex/util"
17
-
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview"
22
-
"tangled.org/core/appview/cache"
23
-
"tangled.org/core/appview/cache/session"
24
15
"tangled.org/core/appview/config"
25
16
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/indexer"
26
18
"tangled.org/core/appview/models"
27
19
"tangled.org/core/appview/notify"
20
+
dbnotify "tangled.org/core/appview/notify/db"
21
+
phnotify "tangled.org/core/appview/notify/posthog"
28
22
"tangled.org/core/appview/oauth"
29
23
"tangled.org/core/appview/pages"
30
-
posthogService "tangled.org/core/appview/posthog"
31
24
"tangled.org/core/appview/reporesolver"
32
25
"tangled.org/core/appview/validator"
33
26
xrpcclient "tangled.org/core/appview/xrpcclient"
34
27
"tangled.org/core/eventconsumer"
35
28
"tangled.org/core/idresolver"
36
29
"tangled.org/core/jetstream"
30
+
"tangled.org/core/log"
37
31
tlog "tangled.org/core/log"
38
32
"tangled.org/core/rbac"
39
33
"tangled.org/core/tid"
34
+
35
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
37
+
"github.com/bluesky-social/indigo/atproto/syntax"
38
+
lexutil "github.com/bluesky-social/indigo/lex/util"
39
+
securejoin "github.com/cyphar/filepath-securejoin"
40
+
"github.com/go-chi/chi/v5"
41
+
"github.com/posthog/posthog-go"
40
42
)
41
43
42
44
type State struct {
43
45
db *db.DB
44
46
notifier notify.Notifier
47
+
indexer *indexer.Indexer
45
48
oauth *oauth.OAuth
46
49
enforcer *rbac.Enforcer
47
50
pages *pages.Pages
48
-
sess *session.SessionStore
49
51
idResolver *idresolver.Resolver
50
52
posthog posthog.Client
51
53
jc *jetstream.JetstreamClient
···
58
60
}
59
61
60
62
func Make(ctx context.Context, config *config.Config) (*State, error) {
61
-
d, err := db.Make(config.Core.DbPath)
63
+
logger := tlog.FromContext(ctx)
64
+
65
+
d, err := db.Make(ctx, config.Core.DbPath)
62
66
if err != nil {
63
67
return nil, fmt.Errorf("failed to create db: %w", err)
64
68
}
65
69
70
+
indexer := indexer.New(log.SubLogger(logger, "indexer"))
71
+
err = indexer.Init(ctx, d)
72
+
if err != nil {
73
+
return nil, fmt.Errorf("failed to create indexer: %w", err)
74
+
}
75
+
66
76
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
67
77
if err != nil {
68
78
return nil, fmt.Errorf("failed to create enforcer: %w", err)
69
79
}
70
80
71
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
81
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
72
82
if err != nil {
73
-
log.Printf("failed to create redis resolver: %v", err)
74
-
res = idresolver.DefaultResolver()
83
+
logger.Error("failed to create redis resolver", "err", err)
84
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
75
85
}
76
86
77
-
pgs := pages.NewPages(config, res)
78
-
cache := cache.New(config.Redis.Addr)
79
-
sess := session.New(cache)
80
-
oauth := oauth.NewOAuth(config, sess)
81
-
validator := validator.New(d, res)
82
-
83
87
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
84
88
if err != nil {
85
89
return nil, fmt.Errorf("failed to create posthog client: %w", err)
86
90
}
91
+
92
+
pages := pages.NewPages(config, res, log.SubLogger(logger, "pages"))
93
+
oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth"))
94
+
if err != nil {
95
+
return nil, fmt.Errorf("failed to start oauth handler: %w", err)
96
+
}
97
+
validator := validator.New(d, res, enforcer)
87
98
88
99
repoResolver := reporesolver.New(config, enforcer, res, d)
89
100
···
106
117
tangled.LabelOpNSID,
107
118
},
108
119
nil,
109
-
slog.Default(),
120
+
tlog.SubLogger(logger, "jetstream"),
110
121
wrapper,
111
122
false,
112
123
···
118
129
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
119
130
}
120
131
121
-
if err := db.BackfillDefaultDefs(d, res); err != nil {
132
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
122
133
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
123
134
}
124
135
···
127
138
Enforcer: enforcer,
128
139
IdResolver: res,
129
140
Config: config,
130
-
Logger: tlog.New("ingester"),
141
+
Logger: log.SubLogger(logger, "ingester"),
131
142
Validator: validator,
132
143
}
133
144
err = jc.StartJetstream(ctx, ingester.Ingest())
···
148
159
spindlestream.Start(ctx)
149
160
150
161
var notifiers []notify.Notifier
162
+
163
+
// Always add the database notifier
164
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
165
+
166
+
// Add other notifiers in production only
151
167
if !config.Core.Dev {
152
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
168
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
153
169
}
154
-
notifier := notify.NewMergedNotifier(notifiers...)
170
+
notifiers = append(notifiers, indexer)
171
+
notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify"))
155
172
156
173
state := &State{
157
174
d,
158
175
notifier,
176
+
indexer,
159
177
oauth,
160
178
enforcer,
161
-
pgs,
162
-
sess,
179
+
pages,
163
180
res,
164
181
posthog,
165
182
jc,
···
167
184
repoResolver,
168
185
knotstream,
169
186
spindlestream,
170
-
slog.Default(),
187
+
logger,
171
188
validator,
172
189
}
173
190
···
192
209
s.pages.Favicon(w)
193
210
}
194
211
212
+
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
213
+
w.Header().Set("Content-Type", "text/plain")
214
+
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
215
+
216
+
robotsTxt := `User-agent: *
217
+
Allow: /
218
+
`
219
+
w.Write([]byte(robotsTxt))
220
+
}
221
+
222
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
223
+
const manifestJson = `{
224
+
"name": "tangled",
225
+
"description": "tightly-knit social coding.",
226
+
"icons": [
227
+
{
228
+
"src": "/favicon.svg",
229
+
"sizes": "144x144"
230
+
}
231
+
],
232
+
"start_url": "/",
233
+
"id": "org.tangled",
234
+
235
+
"display": "standalone",
236
+
"background_color": "#111827",
237
+
"theme_color": "#111827"
238
+
}`
239
+
240
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
241
+
w.Header().Set("Content-Type", "application/json")
242
+
w.Write([]byte(manifestJson))
243
+
}
244
+
195
245
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
196
246
user := s.oauth.GetUser(r)
197
247
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
206
256
})
207
257
}
208
258
259
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
260
+
user := s.oauth.GetUser(r)
261
+
s.pages.Brand(w, pages.BrandParams{
262
+
LoggedInUser: user,
263
+
})
264
+
}
265
+
209
266
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
210
267
if s.oauth.GetUser(r) != nil {
211
268
s.Timeline(w, r)
···
217
274
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
218
275
user := s.oauth.GetUser(r)
219
276
277
+
// TODO: set this flag based on the UI
278
+
filtered := false
279
+
220
280
var userDid string
221
281
if user != nil {
222
282
userDid = user.Did
223
283
}
224
-
timeline, err := db.MakeTimeline(s.db, 50, userDid)
284
+
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
225
285
if err != nil {
226
-
log.Println(err)
286
+
s.logger.Error("failed to make timeline", "err", err)
227
287
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
228
288
}
229
289
230
290
repos, err := db.GetTopStarredReposLastWeek(s.db)
231
291
if err != nil {
232
-
log.Println(err)
292
+
s.logger.Error("failed to get top starred repos", "err", err)
233
293
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
234
294
return
235
295
}
236
296
297
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
+
if err != nil {
299
+
// non-fatal
300
+
}
301
+
237
302
s.pages.Timeline(w, pages.TimelineParams{
238
303
LoggedInUser: user,
239
304
Timeline: timeline,
240
305
Repos: repos,
306
+
GfiLabel: gfiLabel,
241
307
})
242
308
}
243
309
244
310
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
245
311
user := s.oauth.GetUser(r)
312
+
if user == nil {
313
+
return
314
+
}
315
+
246
316
l := s.logger.With("handler", "UpgradeBanner")
247
317
l = l.With("did", user.Did)
248
-
l = l.With("handle", user.Handle)
249
318
250
319
regs, err := db.GetRegistrations(
251
320
s.db,
···
276
345
}
277
346
278
347
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
279
-
timeline, err := db.MakeTimeline(s.db, 5, "")
348
+
// TODO: set this flag based on the UI
349
+
filtered := false
350
+
351
+
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
280
352
if err != nil {
281
-
log.Println(err)
353
+
s.logger.Error("failed to make timeline", "err", err)
282
354
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
283
355
return
284
356
}
285
357
286
358
repos, err := db.GetTopStarredReposLastWeek(s.db)
287
359
if err != nil {
288
-
log.Println(err)
360
+
s.logger.Error("failed to get top starred repos", "err", err)
289
361
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
290
362
return
291
363
}
···
314
386
315
387
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
316
388
if err != nil {
317
-
w.WriteHeader(http.StatusNotFound)
389
+
s.logger.Error("failed to get public keys", "err", err)
390
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
318
391
return
319
392
}
320
393
321
394
if len(pubKeys) == 0 {
322
-
w.WriteHeader(http.StatusNotFound)
395
+
w.WriteHeader(http.StatusNoContent)
323
396
return
324
397
}
325
398
···
385
458
386
459
user := s.oauth.GetUser(r)
387
460
l = l.With("did", user.Did)
388
-
l = l.With("handle", user.Handle)
389
461
390
462
// form validation
391
463
domain := r.FormValue("domain")
···
445
517
Rkey: rkey,
446
518
Description: description,
447
519
Created: time.Now(),
448
-
Labels: models.DefaultLabelDefs(),
520
+
Labels: s.config.Label.DefaultLabelDefs,
449
521
}
450
522
record := repo.AsRecord()
451
523
452
-
xrpcClient, err := s.oauth.AuthorizedClient(r)
524
+
atpClient, err := s.oauth.AuthorizedClient(r)
453
525
if err != nil {
454
526
l.Info("PDS write failed", "err", err)
455
527
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
456
528
return
457
529
}
458
530
459
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
531
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
460
532
Collection: tangled.RepoNSID,
461
533
Repo: user.Did,
462
534
Rkey: rkey,
···
488
560
rollback := func() {
489
561
err1 := tx.Rollback()
490
562
err2 := s.enforcer.E.LoadPolicy()
491
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
563
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
492
564
493
565
// ignore txn complete errors, this is okay
494
566
if errors.Is(err1, sql.ErrTxDone) {
···
561
633
aturi = ""
562
634
563
635
s.notifier.NewRepo(r.Context(), repo)
564
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
636
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
565
637
}
566
638
}
567
639
568
640
// this is used to rollback changes made to the PDS
569
641
//
570
642
// it is a no-op if the provided ATURI is empty
571
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
643
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
572
644
if aturi == "" {
573
645
return nil
574
646
}
···
579
651
repo := parsed.Authority().String()
580
652
rkey := parsed.RecordKey().String()
581
653
582
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
654
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
583
655
Collection: collection,
584
656
Repo: repo,
585
657
Rkey: rkey,
···
587
659
return err
588
660
}
589
661
590
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
591
-
defaults := models.DefaultLabelDefs()
662
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
592
663
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
593
664
if err != nil {
594
665
return err
···
598
669
return nil
599
670
}
600
671
601
-
labelDefs, err := models.FetchDefaultDefs(r)
672
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
602
673
if err != nil {
603
674
return err
604
675
}
+6
-6
appview/state/userutil/userutil.go
+6
-6
appview/state/userutil/userutil.go
···
10
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
11
)
12
12
13
-
func IsHandleNoAt(s string) bool {
13
+
func IsHandle(s string) bool {
14
14
// ref: https://atproto.com/specs/handle
15
15
return handleRegex.MatchString(s)
16
+
}
17
+
18
+
// IsDid checks if the given string is a standard DID.
19
+
func IsDid(s string) bool {
20
+
return didRegex.MatchString(s)
16
21
}
17
22
18
23
func UnflattenDid(s string) string {
···
45
50
return strings.Replace(s, ":", "-", 2)
46
51
}
47
52
return s
48
-
}
49
-
50
-
// IsDid checks if the given string is a standard DID.
51
-
func IsDid(s string) bool {
52
-
return didRegex.MatchString(s)
53
53
}
54
54
55
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+9
-7
appview/strings/strings.go
+9
-7
appview/strings/strings.go
···
22
22
"github.com/bluesky-social/indigo/api/atproto"
23
23
"github.com/bluesky-social/indigo/atproto/identity"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
25
-
lexutil "github.com/bluesky-social/indigo/lex/util"
26
25
"github.com/go-chi/chi/v5"
26
+
27
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
27
29
)
28
30
29
31
type Strings struct {
···
254
256
}
255
257
256
258
// first replace the existing record in the PDS
257
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
259
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
258
260
if err != nil {
259
261
fail("Failed to updated existing record.", err)
260
262
return
261
263
}
262
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
264
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
263
265
Collection: tangled.StringNSID,
264
266
Repo: entry.Did.String(),
265
267
Rkey: entry.Rkey,
···
284
286
s.Notifier.EditString(r.Context(), &entry)
285
287
286
288
// if that went okay, redir to the string
287
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
289
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
288
290
}
289
291
290
292
}
···
336
338
return
337
339
}
338
340
339
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
341
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
340
342
Collection: tangled.StringNSID,
341
343
Repo: user.Did,
342
344
Rkey: string.Rkey,
···
360
362
s.Notifier.NewString(r.Context(), &string)
361
363
362
364
// successful
363
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
365
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
364
366
}
365
367
}
366
368
···
403
405
404
406
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
405
407
406
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
408
+
s.Pages.HxRedirect(w, "/strings/"+user.Did)
407
409
}
408
410
409
411
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+15
-1
appview/validator/label.go
+15
-1
appview/validator/label.go
···
95
95
return nil
96
96
}
97
97
98
-
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
98
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99
99
if labelDef == nil {
100
100
return fmt.Errorf("label definition is required")
101
101
}
102
+
if repo == nil {
103
+
return fmt.Errorf("repo is required")
104
+
}
102
105
if labelOp == nil {
103
106
return fmt.Errorf("label operation is required")
107
+
}
108
+
109
+
// validate permissions: only collaborators can apply labels currently
110
+
//
111
+
// TODO: introduce a repo:triage permission
112
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113
+
if err != nil {
114
+
return fmt.Errorf("failed to enforce permissions: %w", err)
115
+
}
116
+
if !ok {
117
+
return fmt.Errorf("unauhtorized label operation")
104
118
}
105
119
106
120
expectedKey := labelDef.AtUri().String()
+25
appview/validator/patch.go
+25
appview/validator/patch.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.org/core/patchutil"
8
+
)
9
+
10
+
func (v *Validator) ValidatePatch(patch *string) error {
11
+
if patch == nil || *patch == "" {
12
+
return fmt.Errorf("patch is empty")
13
+
}
14
+
15
+
// add newline if not present to diff style patches
16
+
if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") {
17
+
*patch = *patch + "\n"
18
+
}
19
+
20
+
if err := patchutil.IsPatchValid(*patch); err != nil {
21
+
return err
22
+
}
23
+
24
+
return nil
25
+
}
+53
appview/validator/repo_topics.go
+53
appview/validator/repo_topics.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"maps"
6
+
"regexp"
7
+
"slices"
8
+
"strings"
9
+
)
10
+
11
+
const (
12
+
maxTopicLen = 50
13
+
maxTopics = 20
14
+
)
15
+
16
+
var (
17
+
topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`)
18
+
)
19
+
20
+
// ValidateRepoTopicStr parses and validates whitespace-separated topic string.
21
+
//
22
+
// Rules:
23
+
// - topics are separated by whitespace
24
+
// - each topic may contain lowercase letters, digits, and hyphens only
25
+
// - each topic must be <= 50 characters long
26
+
// - no more than 20 topics allowed
27
+
// - duplicates are removed
28
+
func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) {
29
+
topicsStr = strings.TrimSpace(topicsStr)
30
+
if topicsStr == "" {
31
+
return nil, nil
32
+
}
33
+
parts := strings.Fields(topicsStr)
34
+
if len(parts) > maxTopics {
35
+
return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics)
36
+
}
37
+
38
+
topicSet := make(map[string]struct{})
39
+
40
+
for _, t := range parts {
41
+
if _, exists := topicSet[t]; exists {
42
+
continue
43
+
}
44
+
if len(t) > maxTopicLen {
45
+
return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics)
46
+
}
47
+
if !topicRE.MatchString(t) {
48
+
return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t)
49
+
}
50
+
topicSet[t] = struct{}{}
51
+
}
52
+
return slices.Collect(maps.Keys(topicSet)), nil
53
+
}
+17
appview/validator/uri.go
+17
appview/validator/uri.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
func (v *Validator) ValidateURI(uri string) error {
9
+
parsed, err := url.Parse(uri)
10
+
if err != nil {
11
+
return fmt.Errorf("invalid uri format")
12
+
}
13
+
if parsed.Scheme == "" {
14
+
return fmt.Errorf("uri scheme missing")
15
+
}
16
+
return nil
17
+
}
+4
-1
appview/validator/validator.go
+4
-1
appview/validator/validator.go
···
4
4
"tangled.org/core/appview/db"
5
5
"tangled.org/core/appview/pages/markup"
6
6
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
7
8
)
8
9
9
10
type Validator struct {
10
11
db *db.DB
11
12
sanitizer markup.Sanitizer
12
13
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
13
15
}
14
16
15
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
16
18
return &Validator{
17
19
db: db,
18
20
sanitizer: markup.NewSanitizer(),
19
21
resolver: res,
22
+
enforcer: enforcer,
20
23
}
21
24
}
-99
appview/xrpcclient/xrpc.go
-99
appview/xrpcclient/xrpc.go
···
1
1
package xrpcclient
2
2
3
3
import (
4
-
"bytes"
5
-
"context"
6
4
"errors"
7
-
"io"
8
5
"net/http"
9
6
10
-
"github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
7
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
14
8
)
15
9
16
10
var (
···
19
13
ErrXrpcFailed = errors.New("xrpc request failed")
20
14
ErrXrpcInvalid = errors.New("invalid xrpc request")
21
15
)
22
-
23
-
type Client struct {
24
-
*oauth.XrpcClient
25
-
authArgs *oauth.XrpcAuthedRequestArgs
26
-
}
27
-
28
-
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
29
-
return &Client{
30
-
XrpcClient: client,
31
-
authArgs: authArgs,
32
-
}
33
-
}
34
-
35
-
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
36
-
var out atproto.RepoPutRecord_Output
37
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
38
-
return nil, err
39
-
}
40
-
41
-
return &out, nil
42
-
}
43
-
44
-
func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) {
45
-
var out atproto.RepoApplyWrites_Output
46
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
47
-
return nil, err
48
-
}
49
-
50
-
return &out, nil
51
-
}
52
-
53
-
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
54
-
var out atproto.RepoGetRecord_Output
55
-
56
-
params := map[string]interface{}{
57
-
"cid": cid,
58
-
"collection": collection,
59
-
"repo": repo,
60
-
"rkey": rkey,
61
-
}
62
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
63
-
return nil, err
64
-
}
65
-
66
-
return &out, nil
67
-
}
68
-
69
-
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
70
-
var out atproto.RepoUploadBlob_Output
71
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return &out, nil
76
-
}
77
-
78
-
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
79
-
buf := new(bytes.Buffer)
80
-
81
-
params := map[string]interface{}{
82
-
"cid": cid,
83
-
"did": did,
84
-
}
85
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
86
-
return nil, err
87
-
}
88
-
89
-
return buf.Bytes(), nil
90
-
}
91
-
92
-
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
93
-
var out atproto.RepoDeleteRecord_Output
94
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
95
-
return nil, err
96
-
}
97
-
98
-
return &out, nil
99
-
}
100
-
101
-
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
102
-
var out atproto.ServerGetServiceAuth_Output
103
-
104
-
params := map[string]interface{}{
105
-
"aud": aud,
106
-
"exp": exp,
107
-
"lxm": lxm,
108
-
}
109
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
110
-
return nil, err
111
-
}
112
-
113
-
return &out, nil
114
-
}
115
16
116
17
// produces a more manageable error
117
18
func HandleXrpcErr(err error) error {
+14
-9
cmd/appview/main.go
+14
-9
cmd/appview/main.go
···
2
2
3
3
import (
4
4
"context"
5
-
"log"
6
-
"log/slog"
7
5
"net/http"
8
6
"os"
9
7
10
8
"tangled.org/core/appview/config"
11
9
"tangled.org/core/appview/state"
10
+
tlog "tangled.org/core/log"
12
11
)
13
12
14
13
func main() {
15
-
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
16
-
17
14
ctx := context.Background()
15
+
logger := tlog.New("appview")
16
+
ctx = tlog.IntoContext(ctx, logger)
18
17
19
18
c, err := config.LoadConfig(ctx)
20
19
if err != nil {
21
-
log.Println("failed to load config", "error", err)
20
+
logger.Error("failed to load config", "error", err)
22
21
return
23
22
}
24
23
25
24
state, err := state.Make(ctx, c)
26
25
defer func() {
27
-
log.Println(state.Close())
26
+
if err := state.Close(); err != nil {
27
+
logger.Error("failed to close state", "err", err)
28
+
}
28
29
}()
29
30
30
31
if err != nil {
31
-
log.Fatal(err)
32
+
logger.Error("failed to start appview", "err", err)
33
+
os.Exit(-1)
32
34
}
33
35
34
-
log.Println("starting server on", c.Core.ListenAddr)
35
-
log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router()))
36
+
logger.Info("starting server", "address", c.Core.ListenAddr)
37
+
38
+
if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil {
39
+
logger.Error("failed to start appview", "err", err)
40
+
}
36
41
}
+62
cmd/cborgen/cborgen.go
+62
cmd/cborgen/cborgen.go
···
1
+
package main
2
+
3
+
import (
4
+
cbg "github.com/whyrusleeping/cbor-gen"
5
+
"tangled.org/core/api/tangled"
6
+
)
7
+
8
+
func main() {
9
+
10
+
genCfg := cbg.Gen{
11
+
MaxStringLength: 1_000_000,
12
+
}
13
+
14
+
if err := genCfg.WriteMapEncodersToFile(
15
+
"api/tangled/cbor_gen.go",
16
+
"tangled",
17
+
tangled.ActorProfile{},
18
+
tangled.FeedReaction{},
19
+
tangled.FeedStar{},
20
+
tangled.GitRefUpdate{},
21
+
tangled.GitRefUpdate_CommitCountBreakdown{},
22
+
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
+
tangled.GitRefUpdate_IndividualLanguageSize{},
24
+
tangled.GitRefUpdate_LangBreakdown{},
25
+
tangled.GitRefUpdate_Meta{},
26
+
tangled.GraphFollow{},
27
+
tangled.Knot{},
28
+
tangled.KnotMember{},
29
+
tangled.LabelDefinition{},
30
+
tangled.LabelDefinition_ValueType{},
31
+
tangled.LabelOp{},
32
+
tangled.LabelOp_Operand{},
33
+
tangled.Pipeline{},
34
+
tangled.Pipeline_CloneOpts{},
35
+
tangled.Pipeline_ManualTriggerData{},
36
+
tangled.Pipeline_Pair{},
37
+
tangled.Pipeline_PullRequestTriggerData{},
38
+
tangled.Pipeline_PushTriggerData{},
39
+
tangled.PipelineStatus{},
40
+
tangled.Pipeline_TriggerMetadata{},
41
+
tangled.Pipeline_TriggerRepo{},
42
+
tangled.Pipeline_Workflow{},
43
+
tangled.PublicKey{},
44
+
tangled.Repo{},
45
+
tangled.RepoArtifact{},
46
+
tangled.RepoCollaborator{},
47
+
tangled.RepoIssue{},
48
+
tangled.RepoIssueComment{},
49
+
tangled.RepoIssueState{},
50
+
tangled.RepoPull{},
51
+
tangled.RepoPullComment{},
52
+
tangled.RepoPull_Source{},
53
+
tangled.RepoPullStatus{},
54
+
tangled.RepoPull_Target{},
55
+
tangled.Spindle{},
56
+
tangled.SpindleMember{},
57
+
tangled.String{},
58
+
); err != nil {
59
+
panic(err)
60
+
}
61
+
62
+
}
-62
cmd/gen.go
-62
cmd/gen.go
···
1
-
package main
2
-
3
-
import (
4
-
cbg "github.com/whyrusleeping/cbor-gen"
5
-
"tangled.org/core/api/tangled"
6
-
)
7
-
8
-
func main() {
9
-
10
-
genCfg := cbg.Gen{
11
-
MaxStringLength: 1_000_000,
12
-
}
13
-
14
-
if err := genCfg.WriteMapEncodersToFile(
15
-
"api/tangled/cbor_gen.go",
16
-
"tangled",
17
-
tangled.ActorProfile{},
18
-
tangled.FeedReaction{},
19
-
tangled.FeedStar{},
20
-
tangled.GitRefUpdate{},
21
-
tangled.GitRefUpdate_CommitCountBreakdown{},
22
-
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
-
tangled.GitRefUpdate_IndividualLanguageSize{},
24
-
tangled.GitRefUpdate_LangBreakdown{},
25
-
tangled.GitRefUpdate_Meta{},
26
-
tangled.GraphFollow{},
27
-
tangled.Knot{},
28
-
tangled.KnotMember{},
29
-
tangled.LabelDefinition{},
30
-
tangled.LabelDefinition_ValueType{},
31
-
tangled.LabelOp{},
32
-
tangled.LabelOp_Operand{},
33
-
tangled.Pipeline{},
34
-
tangled.Pipeline_CloneOpts{},
35
-
tangled.Pipeline_ManualTriggerData{},
36
-
tangled.Pipeline_Pair{},
37
-
tangled.Pipeline_PullRequestTriggerData{},
38
-
tangled.Pipeline_PushTriggerData{},
39
-
tangled.PipelineStatus{},
40
-
tangled.Pipeline_TriggerMetadata{},
41
-
tangled.Pipeline_TriggerRepo{},
42
-
tangled.Pipeline_Workflow{},
43
-
tangled.PublicKey{},
44
-
tangled.Repo{},
45
-
tangled.RepoArtifact{},
46
-
tangled.RepoCollaborator{},
47
-
tangled.RepoIssue{},
48
-
tangled.RepoIssueComment{},
49
-
tangled.RepoIssueState{},
50
-
tangled.RepoPull{},
51
-
tangled.RepoPullComment{},
52
-
tangled.RepoPull_Source{},
53
-
tangled.RepoPullStatus{},
54
-
tangled.RepoPull_Target{},
55
-
tangled.Spindle{},
56
-
tangled.SpindleMember{},
57
-
tangled.String{},
58
-
); err != nil {
59
-
panic(err)
60
-
}
61
-
62
-
}
-43
cmd/genjwks/main.go
-43
cmd/genjwks/main.go
···
1
-
// adapted from https://tangled.sh/icyphox.sh/atproto-oauth
2
-
3
-
package main
4
-
5
-
import (
6
-
"crypto/ecdsa"
7
-
"crypto/elliptic"
8
-
"crypto/rand"
9
-
"encoding/json"
10
-
"fmt"
11
-
"time"
12
-
13
-
"github.com/lestrrat-go/jwx/v2/jwk"
14
-
)
15
-
16
-
func main() {
17
-
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
18
-
if err != nil {
19
-
panic(err)
20
-
}
21
-
22
-
key, err := jwk.FromRaw(privKey)
23
-
if err != nil {
24
-
panic(err)
25
-
}
26
-
27
-
kid := fmt.Sprintf("%d", time.Now().Unix())
28
-
29
-
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
30
-
panic(err)
31
-
}
32
-
33
-
if err := key.Set("use", "sig"); err != nil {
34
-
panic(err)
35
-
}
36
-
37
-
b, err := json.Marshal(key)
38
-
if err != nil {
39
-
panic(err)
40
-
}
41
-
42
-
fmt.Println(string(b))
43
-
}
+6
-3
cmd/knot/main.go
+6
-3
cmd/knot/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
5
6
"os"
6
7
7
8
"github.com/urfave/cli/v3"
···
9
10
"tangled.org/core/hook"
10
11
"tangled.org/core/keyfetch"
11
12
"tangled.org/core/knotserver"
12
-
"tangled.org/core/log"
13
+
tlog "tangled.org/core/log"
13
14
)
14
15
15
16
func main() {
···
24
25
},
25
26
}
26
27
28
+
logger := tlog.New("knot")
29
+
slog.SetDefault(logger)
30
+
27
31
ctx := context.Background()
28
-
logger := log.New("knot")
29
-
ctx = log.IntoContext(ctx, logger.With("command", cmd.Name))
32
+
ctx = tlog.IntoContext(ctx, logger)
30
33
31
34
if err := cmd.Run(ctx, os.Args); err != nil {
32
35
logger.Error(err.Error())
-49
cmd/punchcardPopulate/main.go
-49
cmd/punchcardPopulate/main.go
···
1
-
package main
2
-
3
-
import (
4
-
"database/sql"
5
-
"fmt"
6
-
"log"
7
-
"math/rand"
8
-
"time"
9
-
10
-
_ "github.com/mattn/go-sqlite3"
11
-
)
12
-
13
-
func main() {
14
-
db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1")
15
-
if err != nil {
16
-
log.Fatal("Failed to open database:", err)
17
-
}
18
-
defer db.Close()
19
-
20
-
const did = "did:plc:qfpnj4og54vl56wngdriaxug"
21
-
22
-
now := time.Now()
23
-
start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
24
-
25
-
tx, err := db.Begin()
26
-
if err != nil {
27
-
log.Fatal(err)
28
-
}
29
-
stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)")
30
-
if err != nil {
31
-
log.Fatal(err)
32
-
}
33
-
defer stmt.Close()
34
-
35
-
for day := start; !day.After(now); day = day.AddDate(0, 0, 1) {
36
-
count := rand.Intn(16) // 0–5
37
-
dateStr := day.Format("2006-01-02")
38
-
_, err := stmt.Exec(did, dateStr, count)
39
-
if err != nil {
40
-
log.Printf("Failed to insert for date %s: %v", dateStr, err)
41
-
}
42
-
}
43
-
44
-
if err := tx.Commit(); err != nil {
45
-
log.Fatal("Failed to commit:", err)
46
-
}
47
-
48
-
fmt.Println("Done populating punchcard.")
49
-
}
+9
-4
cmd/spindle/main.go
+9
-4
cmd/spindle/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
5
6
"os"
6
7
7
-
"tangled.org/core/log"
8
+
tlog "tangled.org/core/log"
8
9
"tangled.org/core/spindle"
9
-
_ "tangled.org/core/tid"
10
10
)
11
11
12
12
func main() {
13
-
ctx := log.NewContext(context.Background(), "spindle")
13
+
logger := tlog.New("spindle")
14
+
slog.SetDefault(logger)
15
+
16
+
ctx := context.Background()
17
+
ctx = tlog.IntoContext(ctx, logger)
18
+
14
19
err := spindle.Run(ctx)
15
20
if err != nil {
16
-
log.FromContext(ctx).Error("error running spindle", "error", err)
21
+
logger.Error("error running spindle", "error", err)
17
22
os.Exit(-1)
18
23
}
19
24
}
+16
-6
docs/hacking.md
+16
-6
docs/hacking.md
···
37
37
38
38
```
39
39
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_JWKS
41
-
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
40
+
echo $TANGLED_OAUTH_CLIENT_SECRET
41
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
+
43
+
echo $TANGLED_OAUTH_CLIENT_KID
44
+
1761667908
42
45
43
46
# if not, you can set it up yourself:
44
-
go build -o genjwks.out ./cmd/genjwks
45
-
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
47
+
goat key generate -t P-256
48
+
Key Type: P-256 / secp256r1 / ES256 private key
49
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
52
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
+
54
+
# the secret key from above
55
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
46
56
47
57
# run redis in at a new shell to store oauth sessions
48
58
redis-server
···
158
168
159
169
If for any reason you wish to disable either one of the
160
170
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161
-
`services.tangled-spindle.enable` (or
162
-
`services.tangled-knot.enable`) to `false`.
171
+
`services.tangled.spindle.enable` (or
172
+
`services.tangled.knot.enable`) to `false`.
+2
-1
docs/knot-hosting.md
+2
-1
docs/knot-hosting.md
···
39
39
```
40
40
41
41
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/knot` is a good choice:
42
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
43
44
44
```
45
45
sudo mv knot /usr/local/bin/knot
46
+
sudo chown root:root /usr/local/bin/knot
46
47
```
47
48
48
49
This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1
-1
docs/migrations.md
+1
-1
docs/migrations.md
+20
-2
docs/spindle/pipeline.md
+20
-2
docs/spindle/pipeline.md
···
19
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
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.
22
+
- `branch`: 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. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
+
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
24
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
+
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
26
27
```yaml
27
28
when:
···
29
30
branch: ["main", "develop"]
30
31
- event: ["pull_request"]
31
32
branch: ["main"]
33
+
```
34
+
35
+
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
+
37
+
```yaml
38
+
when:
39
+
- event: ["push"]
40
+
tag: ["v*"]
41
+
```
42
+
43
+
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
+
45
+
```yaml
46
+
when:
47
+
- event: ["push"]
48
+
branch: ["main", "release-*"]
49
+
tag: ["v*", "stable"]
32
50
```
33
51
34
52
## Engine
+17
flake.lock
+17
flake.lock
···
1
1
{
2
2
"nodes": {
3
+
"actor-typeahead-src": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1762835797,
7
+
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
8
+
"ref": "refs/heads/main",
9
+
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
10
+
"revCount": 6,
11
+
"type": "git",
12
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
13
+
},
14
+
"original": {
15
+
"type": "git",
16
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
17
+
}
18
+
},
3
19
"flake-compat": {
4
20
"flake": false,
5
21
"locked": {
···
150
166
},
151
167
"root": {
152
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
153
170
"flake-compat": "flake-compat",
154
171
"gomod2nix": "gomod2nix",
155
172
"htmx-src": "htmx-src",
+19
-9
flake.nix
+19
-9
flake.nix
···
33
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
34
flake = false;
35
35
};
36
+
actor-typeahead-src = {
37
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
+
flake = false;
39
+
};
36
40
ibm-plex-mono-src = {
37
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
42
flake = false;
···
54
58
inter-fonts-src,
55
59
sqlite-lib-src,
56
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
57
62
...
58
63
}: let
59
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
78
83
inherit (pkgs) gcc;
79
84
inherit sqlite-lib-src;
80
85
};
81
-
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
82
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
87
+
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
88
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
84
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
85
90
};
86
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
90
95
});
91
96
in {
92
97
overlays.default = final: prev: {
93
-
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
98
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
94
99
};
95
100
96
101
packages = forAllSystems (system: let
···
99
104
staticPackages = mkPackageSet pkgs.pkgsStatic;
100
105
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
101
106
in {
102
-
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
107
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
103
108
104
109
pkgsStatic-appview = staticPackages.appview;
105
110
pkgsStatic-knot = staticPackages.knot;
···
167
172
mkdir -p appview/pages/static
168
173
# no preserve is needed because watch-tailwind will want to be able to overwrite
169
174
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
170
-
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
175
+
export TANGLED_OAUTH_CLIENT_KID="$(date +%s)"
176
+
export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')"
171
177
'';
172
178
env.CGO_ENABLED = 1;
173
179
};
···
206
212
watch-knot = {
207
213
type = "app";
208
214
program = ''${air-watcher "knot" "server"}/bin/run'';
215
+
};
216
+
watch-spindle = {
217
+
type = "app";
218
+
program = ''${air-watcher "spindle" ""}/bin/run'';
209
219
};
210
220
watch-tailwind = {
211
221
type = "app";
···
262
272
lexgen --build-file lexicon-build-config.json lexicons
263
273
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
264
274
${pkgs.gotools}/bin/goimports -w api/tangled/*
265
-
go run cmd/gen.go
275
+
go run ./cmd/cborgen/
266
276
lexgen --build-file lexicon-build-config.json lexicons
267
277
rm api/tangled/*.bak
268
278
'';
···
278
288
}: {
279
289
imports = [./nix/modules/appview.nix];
280
290
281
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
291
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
282
292
};
283
293
nixosModules.knot = {
284
294
lib,
···
287
297
}: {
288
298
imports = [./nix/modules/knot.nix];
289
299
290
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
300
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
291
301
};
292
302
nixosModules.spindle = {
293
303
lib,
···
296
306
}: {
297
307
imports = [./nix/modules/spindle.nix];
298
308
299
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
309
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
300
310
};
301
311
};
302
312
}
+53
-9
go.mod
+53
-9
go.mod
···
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
11
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
···
21
21
github.com/go-chi/chi/v5 v5.2.0
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
+
github.com/goki/freetype v1.0.5
24
25
github.com/google/uuid v1.6.0
25
26
github.com/gorilla/feeds v1.2.0
26
27
github.com/gorilla/sessions v1.4.0
···
36
37
github.com/redis/go-redis/v9 v9.7.3
37
38
github.com/resend/resend-go/v2 v2.15.0
38
39
github.com/sethvargo/go-envconfig v1.1.0
40
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
41
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
39
42
github.com/stretchr/testify v1.10.0
40
43
github.com/urfave/cli/v3 v3.3.3
41
44
github.com/whyrusleeping/cbor-gen v0.3.1
42
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
-
github.com/yuin/goldmark v1.7.12
45
+
github.com/wyatt915/goldmark-treeblood v0.0.1
46
+
github.com/yuin/goldmark v1.7.13
44
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
48
golang.org/x/crypto v0.40.0
49
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
+
golang.org/x/image v0.31.0
46
51
golang.org/x/net v0.42.0
47
-
golang.org/x/sync v0.16.0
52
+
golang.org/x/sync v0.17.0
48
53
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
49
54
gopkg.in/yaml.v3 v3.0.1
50
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
51
55
)
52
56
53
57
require (
54
58
dario.cat/mergo v1.0.1 // indirect
55
59
github.com/Microsoft/go-winio v0.6.2 // indirect
56
60
github.com/ProtonMail/go-crypto v1.3.0 // indirect
61
+
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
57
62
github.com/alecthomas/repr v0.4.0 // indirect
58
63
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
64
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
59
65
github.com/aymerick/douceur v0.2.0 // indirect
60
66
github.com/beorn7/perks v1.0.1 // indirect
61
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
67
+
github.com/bits-and-blooms/bitset v1.22.0 // indirect
68
+
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
69
+
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
70
+
github.com/blevesearch/geo v0.2.4 // indirect
71
+
github.com/blevesearch/go-faiss v1.0.25 // indirect
72
+
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
73
+
github.com/blevesearch/gtreap v0.1.1 // indirect
74
+
github.com/blevesearch/mmap-go v1.0.4 // indirect
75
+
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
76
+
github.com/blevesearch/segment v0.9.1 // indirect
77
+
github.com/blevesearch/snowballstem v0.9.0 // indirect
78
+
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
79
+
github.com/blevesearch/vellum v1.1.0 // indirect
80
+
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
81
+
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
82
+
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
83
+
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
84
+
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
85
+
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
86
+
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
62
87
github.com/casbin/govaluate v1.3.0 // indirect
63
88
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
64
89
github.com/cespare/xxhash/v2 v2.3.0 // indirect
90
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
91
+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
92
+
github.com/charmbracelet/log v0.4.2 // indirect
93
+
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
+
github.com/charmbracelet/x/term v0.2.1 // indirect
65
96
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
66
97
github.com/containerd/errdefs v1.0.0 // indirect
67
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
···
80
111
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
81
112
github.com/go-git/go-billy/v5 v5.6.2 // indirect
82
113
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
114
+
github.com/go-logfmt/logfmt v0.6.0 // indirect
83
115
github.com/go-logr/logr v1.4.3 // indirect
84
116
github.com/go-logr/stdr v1.2.2 // indirect
85
117
github.com/go-redis/cache/v9 v9.0.0 // indirect
···
89
121
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
90
122
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
91
123
github.com/golang/mock v1.6.0 // indirect
124
+
github.com/golang/protobuf v1.5.4 // indirect
125
+
github.com/golang/snappy v0.0.4 // indirect
92
126
github.com/google/go-querystring v1.1.0 // indirect
93
127
github.com/gorilla/css v1.0.1 // indirect
94
128
github.com/gorilla/securecookie v1.1.2 // indirect
···
114
148
github.com/ipfs/go-log v1.0.5 // indirect
115
149
github.com/ipfs/go-log/v2 v2.6.0 // indirect
116
150
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
151
+
github.com/json-iterator/go v1.1.12 // indirect
117
152
github.com/kevinburke/ssh_config v1.2.0 // indirect
118
153
github.com/klauspost/compress v1.18.0 // indirect
119
154
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
···
122
157
github.com/lestrrat-go/httprc v1.0.6 // indirect
123
158
github.com/lestrrat-go/iter v1.0.2 // indirect
124
159
github.com/lestrrat-go/option v1.0.1 // indirect
160
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
125
161
github.com/mattn/go-isatty v0.0.20 // indirect
162
+
github.com/mattn/go-runewidth v0.0.16 // indirect
126
163
github.com/minio/sha256-simd v1.0.1 // indirect
127
164
github.com/mitchellh/mapstructure v1.5.0 // indirect
128
165
github.com/moby/docker-image-spec v1.3.1 // indirect
129
166
github.com/moby/sys/atomicwriter v0.1.0 // indirect
130
167
github.com/moby/term v0.5.2 // indirect
168
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
169
+
github.com/modern-go/reflect2 v1.0.2 // indirect
131
170
github.com/morikuni/aec v1.0.0 // indirect
132
171
github.com/mr-tron/base58 v1.2.0 // indirect
172
+
github.com/mschoch/smat v0.2.0 // indirect
173
+
github.com/muesli/termenv v0.16.0 // indirect
133
174
github.com/multiformats/go-base32 v0.1.0 // indirect
134
175
github.com/multiformats/go-base36 v0.2.0 // indirect
135
176
github.com/multiformats/go-multibase v0.2.0 // indirect
···
148
189
github.com/prometheus/client_model v0.6.2 // indirect
149
190
github.com/prometheus/common v0.64.0 // indirect
150
191
github.com/prometheus/procfs v0.16.1 // indirect
192
+
github.com/rivo/uniseg v0.4.7 // indirect
151
193
github.com/ryanuber/go-glob v1.0.0 // indirect
152
194
github.com/segmentio/asm v1.2.0 // indirect
153
195
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
···
155
197
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
156
198
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
157
199
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
158
-
github.com/wyatt915/treeblood v0.1.15 // indirect
200
+
github.com/wyatt915/treeblood v0.1.16 // indirect
201
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
202
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
159
203
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
160
204
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
205
+
go.etcd.io/bbolt v1.4.0 // indirect
161
206
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
162
207
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
163
208
go.opentelemetry.io/otel v1.37.0 // indirect
···
168
213
go.uber.org/atomic v1.11.0 // indirect
169
214
go.uber.org/multierr v1.11.0 // indirect
170
215
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
216
golang.org/x/sys v0.34.0 // indirect
173
-
golang.org/x/text v0.27.0 // indirect
217
+
golang.org/x/text v0.29.0 // indirect
174
218
golang.org/x/time v0.12.0 // indirect
175
219
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
176
220
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+109
-16
go.sum
+109
-16
go.sum
···
9
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
10
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
11
11
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
+
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
13
+
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
12
14
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
15
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
16
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
19
21
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
20
22
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
21
23
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
24
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
25
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
22
26
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
23
27
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
28
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
29
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=
30
+
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
31
+
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
32
+
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
33
+
github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM=
34
+
github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw=
35
+
github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y=
36
+
github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
37
+
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
38
+
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
39
+
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
40
+
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
41
+
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
42
+
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
43
+
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
44
+
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
45
+
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
46
+
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
47
+
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s=
48
+
github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8=
49
+
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
50
+
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
51
+
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
52
+
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
53
+
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
54
+
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
55
+
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
56
+
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
57
+
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
58
+
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
59
+
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
60
+
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
61
+
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
62
+
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
63
+
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
64
+
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
65
+
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
66
+
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
67
+
github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww=
68
+
github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs=
69
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
70
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
28
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
73
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
31
74
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
32
75
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
76
+
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
77
+
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
33
78
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
34
79
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
35
80
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
48
93
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
49
94
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
50
95
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
96
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
97
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
98
+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
99
+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
100
+
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
101
+
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
102
+
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
103
+
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
104
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
105
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
106
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
107
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
51
108
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
109
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
110
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
···
120
177
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
121
178
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
122
179
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
180
+
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
181
+
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
123
182
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
124
183
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
125
184
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
···
136
195
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
137
196
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
138
197
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
198
+
github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
199
+
github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
139
200
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
140
201
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
141
202
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
152
213
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
153
214
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
154
215
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
216
+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
217
+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
218
+
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
219
+
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
155
220
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
156
221
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
157
222
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
···
163
228
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
164
229
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
165
230
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
231
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
166
232
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
167
233
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
168
234
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
243
309
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
244
310
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
245
311
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=
312
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
313
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
248
314
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
249
315
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
250
316
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
276
342
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
277
343
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
278
344
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
345
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
346
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
279
347
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
280
348
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
281
349
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
282
350
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
351
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
352
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
283
353
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
284
354
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
285
355
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
···
296
366
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
297
367
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
298
368
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
369
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
370
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
371
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
372
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
373
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
299
374
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
300
375
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
301
376
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
302
377
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
378
+
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
379
+
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
380
+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
381
+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
303
382
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
304
383
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
305
384
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
···
377
456
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
378
457
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
379
458
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
459
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
460
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
461
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
380
462
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
381
463
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
382
464
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
···
399
481
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
400
482
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
401
483
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
484
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
485
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
486
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
487
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
402
488
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
403
489
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
404
490
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
426
512
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
427
513
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
428
514
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
429
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
430
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
431
-
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
432
-
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
515
+
github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs=
516
+
github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208=
517
+
github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y=
518
+
github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
519
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
520
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
433
521
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
434
522
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
435
523
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
436
524
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
437
525
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
438
526
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=
527
+
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
528
+
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
441
529
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
442
530
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
531
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
532
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c=
443
533
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
444
534
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
445
535
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
446
536
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
537
+
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
538
+
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
447
539
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
448
540
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
449
541
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
···
489
581
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
490
582
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
491
583
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
584
+
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
585
+
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
492
586
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
493
587
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
494
588
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
528
622
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
529
623
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
530
624
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=
625
+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
626
+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
533
627
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
534
628
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
535
629
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
583
677
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
584
678
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
585
679
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=
680
+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
681
+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
588
682
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
589
683
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
590
684
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
645
739
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
646
740
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
647
741
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
742
+
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
648
743
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
649
744
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
650
745
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
···
652
747
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
653
748
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
654
749
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
750
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
658
751
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+36
-61
guard/guard.go
+36
-61
guard/guard.go
···
12
12
"os/exec"
13
13
"strings"
14
14
15
-
"github.com/bluesky-social/indigo/atproto/identity"
16
15
securejoin "github.com/cyphar/filepath-securejoin"
17
16
"github.com/urfave/cli/v3"
18
-
"tangled.org/core/idresolver"
19
17
"tangled.org/core/log"
20
18
)
21
19
···
93
91
"command", sshCommand,
94
92
"client", clientIP)
95
93
94
+
// TODO: greet user with their resolved handle instead of did
96
95
if sshCommand == "" {
97
96
l.Info("access denied: no interactive shells", "user", incomingUser)
98
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
107
106
}
108
107
109
108
gitCommand := cmdParts[0]
110
-
111
-
// did:foo/repo-name or
112
-
// handle/repo-name or
113
-
// any of the above with a leading slash (/)
114
-
115
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
116
-
l.Info("command components", "components", components)
117
-
118
-
if len(components) != 2 {
119
-
l.Error("invalid repo format", "components", components)
120
-
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
121
-
os.Exit(-1)
122
-
}
123
-
124
-
didOrHandle := components[0]
125
-
identity := resolveIdentity(ctx, l, didOrHandle)
126
-
did := identity.DID.String()
127
-
repoName := components[1]
128
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
109
+
repoPath := cmdParts[1]
129
110
130
111
validCommands := map[string]bool{
131
112
"git-receive-pack": true,
···
138
119
return fmt.Errorf("access denied: invalid git command")
139
120
}
140
121
141
-
if gitCommand != "git-upload-pack" {
142
-
if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
143
-
l.Error("access denied: user not allowed",
144
-
"did", incomingUser,
145
-
"reponame", qualifiedRepoName)
146
-
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
147
-
os.Exit(-1)
148
-
}
122
+
// qualify repo path from internal server which holds the knot config
123
+
qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
124
+
if err != nil {
125
+
l.Error("failed to run guard", "err", err)
126
+
fmt.Fprintln(os.Stderr, err)
127
+
os.Exit(1)
149
128
}
150
129
151
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
152
131
153
132
l.Info("processing command",
154
133
"user", incomingUser,
155
134
"command", gitCommand,
156
-
"repo", repoName,
135
+
"repo", repoPath,
157
136
"fullPath", fullPath,
158
137
"client", clientIP)
159
138
···
177
156
gitCmd.Stdin = os.Stdin
178
157
gitCmd.Env = append(os.Environ(),
179
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
181
159
)
182
160
183
161
if err := gitCmd.Run(); err != nil {
···
189
167
l.Info("command completed",
190
168
"user", incomingUser,
191
169
"command", gitCommand,
192
-
"repo", repoName,
170
+
"repo", repoPath,
193
171
"success", true)
194
172
195
173
return nil
196
174
}
197
175
198
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
-
resolver := idresolver.DefaultResolver()
200
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
176
+
// runs guardAndQualifyRepo logic
177
+
func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
178
+
u, _ := url.Parse(endpoint + "/guard")
179
+
q := u.Query()
180
+
q.Add("user", incomingUser)
181
+
q.Add("repo", repo)
182
+
q.Add("gitCmd", gitCommand)
183
+
u.RawQuery = q.Encode()
184
+
185
+
resp, err := http.Get(u.String())
201
186
if err != nil {
202
-
l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
203
-
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
204
-
os.Exit(1)
205
-
}
206
-
if ident.Handle.IsInvalidHandle() {
207
-
l.Error("Error resolving handle", "invalid handle", didOrHandle)
208
-
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
209
-
os.Exit(1)
187
+
return "", err
210
188
}
211
-
return ident
212
-
}
189
+
defer resp.Body.Close()
213
190
214
-
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
215
-
u, _ := url.Parse(endpoint + "/push-allowed")
216
-
q := u.Query()
217
-
q.Add("user", user)
218
-
q.Add("repo", qualifiedRepoName)
219
-
u.RawQuery = q.Encode()
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
220
192
221
-
req, err := http.Get(u.String())
193
+
body, err := io.ReadAll(resp.Body)
222
194
if err != nil {
223
-
l.Error("Error verifying permissions", "error", err)
224
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
225
-
os.Exit(1)
195
+
return "", err
226
196
}
227
-
228
-
l.Info("Checking push permission",
229
-
"url", u.String(),
230
-
"status", req.Status)
197
+
text := string(body)
231
198
232
-
return req.StatusCode == http.StatusNoContent
199
+
switch resp.StatusCode {
200
+
case http.StatusOK:
201
+
return text, nil
202
+
case http.StatusForbidden:
203
+
l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
204
+
return text, errors.New("access denied: user not allowed")
205
+
default:
206
+
return "", errors.New(text)
207
+
}
233
208
}
+17
-8
idresolver/resolver.go
+17
-8
idresolver/resolver.go
···
17
17
directory identity.Directory
18
18
}
19
19
20
-
func BaseDirectory() identity.Directory {
20
+
func BaseDirectory(plcUrl string) identity.Directory {
21
21
base := identity.BaseDirectory{
22
-
PLCURL: identity.DefaultPLCURL,
22
+
PLCURL: plcUrl,
23
23
HTTPClient: http.Client{
24
24
Timeout: time.Second * 10,
25
25
Transport: &http.Transport{
···
42
42
return &base
43
43
}
44
44
45
-
func RedisDirectory(url string) (identity.Directory, error) {
45
+
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
46
46
hitTTL := time.Hour * 24
47
47
errTTL := time.Second * 30
48
48
invalidHandleTTL := time.Minute * 5
49
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
49
+
return redisdir.NewRedisDirectory(
50
+
BaseDirectory(plcUrl),
51
+
url,
52
+
hitTTL,
53
+
errTTL,
54
+
invalidHandleTTL,
55
+
10000,
56
+
)
50
57
}
51
58
52
-
func DefaultResolver() *Resolver {
59
+
func DefaultResolver(plcUrl string) *Resolver {
60
+
base := BaseDirectory(plcUrl)
61
+
cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5)
53
62
return &Resolver{
54
-
directory: identity.DefaultDirectory(),
63
+
directory: &cached,
55
64
}
56
65
}
57
66
58
-
func RedisResolver(redisUrl string) (*Resolver, error) {
59
-
directory, err := RedisDirectory(redisUrl)
67
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
+
directory, err := RedisDirectory(redisUrl, plcUrl)
60
69
if err != nil {
61
70
return nil, err
62
71
}
+109
-12
input.css
+109
-12
input.css
···
134
134
}
135
135
136
136
.prose hr {
137
-
@apply my-2;
137
+
@apply my-2;
138
138
}
139
139
140
140
.prose li:has(input) {
141
-
@apply list-none;
141
+
@apply list-none;
142
142
}
143
143
144
144
.prose ul:has(input) {
145
-
@apply pl-2;
145
+
@apply pl-2;
146
146
}
147
147
148
148
.prose .heading .anchor {
149
-
@apply no-underline mx-2 opacity-0;
149
+
@apply no-underline mx-2 opacity-0;
150
150
}
151
151
152
152
.prose .heading:hover .anchor {
153
-
@apply opacity-70;
153
+
@apply opacity-70;
154
154
}
155
155
156
156
.prose .heading .anchor:hover {
157
-
@apply opacity-70;
157
+
@apply opacity-70;
158
158
}
159
159
160
160
.prose a.footnote-backref {
161
-
@apply no-underline;
161
+
@apply no-underline;
162
+
}
163
+
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
162
166
}
163
167
164
168
.prose li {
165
-
@apply my-0 py-0;
169
+
@apply my-0 py-0;
166
170
}
167
171
168
-
.prose ul, .prose ol {
169
-
@apply my-1 py-0;
172
+
.prose ul,
173
+
.prose ol {
174
+
@apply my-1 py-0;
170
175
}
171
176
172
177
.prose img {
···
176
181
}
177
182
178
183
.prose input {
179
-
@apply inline-block my-0 mb-1 mx-1;
184
+
@apply inline-block my-0 mb-1 mx-1;
180
185
}
181
186
182
187
.prose input[type="checkbox"] {
183
188
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
184
189
}
190
+
191
+
/* Base callout */
192
+
details[data-callout] {
193
+
@apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;
194
+
}
195
+
196
+
details[data-callout] > summary {
197
+
@apply font-bold cursor-pointer mb-1;
198
+
}
199
+
200
+
details[data-callout] > .callout-content {
201
+
@apply text-sm leading-snug;
202
+
}
203
+
204
+
/* Note (blue) */
205
+
details[data-callout="note" i] {
206
+
@apply border-blue-400 dark:border-blue-500;
207
+
}
208
+
details[data-callout="note" i] > summary {
209
+
@apply text-blue-700 dark:text-blue-400;
210
+
}
211
+
212
+
/* Important (purple) */
213
+
details[data-callout="important" i] {
214
+
@apply border-purple-400 dark:border-purple-500;
215
+
}
216
+
details[data-callout="important" i] > summary {
217
+
@apply text-purple-700 dark:text-purple-400;
218
+
}
219
+
220
+
/* Warning (yellow) */
221
+
details[data-callout="warning" i] {
222
+
@apply border-yellow-400 dark:border-yellow-500;
223
+
}
224
+
details[data-callout="warning" i] > summary {
225
+
@apply text-yellow-700 dark:text-yellow-400;
226
+
}
227
+
228
+
/* Caution (red) */
229
+
details[data-callout="caution" i] {
230
+
@apply border-red-400 dark:border-red-500;
231
+
}
232
+
details[data-callout="caution" i] > summary {
233
+
@apply text-red-700 dark:text-red-400;
234
+
}
235
+
236
+
/* Tip (green) */
237
+
details[data-callout="tip" i] {
238
+
@apply border-green-400 dark:border-green-500;
239
+
}
240
+
details[data-callout="tip" i] > summary {
241
+
@apply text-green-700 dark:text-green-400;
242
+
}
243
+
244
+
/* Optional: hide the disclosure arrow like GitHub */
245
+
details[data-callout] > summary::-webkit-details-marker {
246
+
display: none;
247
+
}
248
+
185
249
}
186
250
@layer utilities {
187
251
.error {
···
228
292
}
229
293
/* LineHighlight */
230
294
.chroma .hl {
231
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
295
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
232
296
}
233
297
234
298
/* LineNumbersTable */
···
865
929
text-decoration: underline;
866
930
}
867
931
}
932
+
933
+
actor-typeahead {
934
+
--color-background: #ffffff;
935
+
--color-border: #d1d5db;
936
+
--color-shadow: #000000;
937
+
--color-hover: #f9fafb;
938
+
--color-avatar-fallback: #e5e7eb;
939
+
--radius: 0.0;
940
+
--padding-menu: 0.0rem;
941
+
z-index: 1000;
942
+
}
943
+
944
+
actor-typeahead::part(handle) {
945
+
color: #111827;
946
+
}
947
+
948
+
actor-typeahead::part(menu) {
949
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
950
+
}
951
+
952
+
@media (prefers-color-scheme: dark) {
953
+
actor-typeahead {
954
+
--color-background: #1f2937;
955
+
--color-border: #4b5563;
956
+
--color-shadow: #000000;
957
+
--color-hover: #374151;
958
+
--color-avatar-fallback: #4b5563;
959
+
}
960
+
961
+
actor-typeahead::part(handle) {
962
+
color: #f9fafb;
963
+
}
964
+
}
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
···
114
114
115
115
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
116
116
117
-
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
117
+
client, err := client.NewClient(j.cfg, logger, sched)
118
118
if err != nil {
119
119
return fmt.Errorf("failed to create jetstream client: %w", err)
120
120
}
+2
-1
knotserver/config/config.go
+2
-1
knotserver/config/config.go
···
19
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
21
Hostname string `env:"HOSTNAME, required"`
22
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
22
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
24
Owner string `env:"OWNER, required"`
24
25
LogDids bool `env:"LOG_DIDS, default=true"`
···
41
42
Repo Repo `env:",prefix=KNOT_REPO_"`
42
43
Server Server `env:",prefix=KNOT_SERVER_"`
43
44
Git Git `env:",prefix=KNOT_GIT_"`
44
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
45
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
45
46
}
46
47
47
48
func Load(ctx context.Context) (*Config, error) {
+2
-3
knotserver/events.go
+2
-3
knotserver/events.go
···
8
8
"time"
9
9
10
10
"github.com/gorilla/websocket"
11
+
"tangled.org/core/log"
11
12
)
12
13
13
14
var upgrader = websocket.Upgrader{
···
16
17
}
17
18
18
19
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
19
-
l := h.l.With("handler", "OpLog")
20
+
l := log.SubLogger(h.l, "eventstream")
20
21
l.Debug("received new connection")
21
22
22
23
conn, err := upgrader.Upgrade(w, r, nil)
···
75
76
}
76
77
case <-time.After(30 * time.Second):
77
78
// send a keep-alive
78
-
l.Debug("sent keepalive")
79
79
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
80
80
l.Error("failed to write control", "err", err)
81
81
}
···
89
89
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
90
90
return err
91
91
}
92
-
h.l.Debug("ops", "ops", events)
93
92
94
93
for _, event := range events {
95
94
// first extract the inner json into a map
+5
knotserver/git/branch.go
+5
knotserver/git/branch.go
+11
-103
knotserver/git/git.go
+11
-103
knotserver/git/git.go
···
27
27
h plumbing.Hash
28
28
}
29
29
30
-
type TagList struct {
31
-
refs []*TagReference
32
-
r *git.Repository
33
-
}
34
-
35
-
// TagReference is used to list both tag and non-annotated tags.
36
-
// Non-annotated tags should only contains a reference.
37
-
// Annotated tags should contain its reference and its tag information.
38
-
type TagReference struct {
39
-
ref *plumbing.Reference
40
-
tag *object.Tag
41
-
}
42
-
43
30
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
44
31
// to tar WriteHeader
45
32
type infoWrapper struct {
···
50
37
isDir bool
51
38
}
52
39
53
-
func (self *TagList) Len() int {
54
-
return len(self.refs)
55
-
}
56
-
57
-
func (self *TagList) Swap(i, j int) {
58
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
59
-
}
60
-
61
-
// sorting tags in reverse chronological order
62
-
func (self *TagList) Less(i, j int) bool {
63
-
var dateI time.Time
64
-
var dateJ time.Time
65
-
66
-
if self.refs[i].tag != nil {
67
-
dateI = self.refs[i].tag.Tagger.When
68
-
} else {
69
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
70
-
if err != nil {
71
-
dateI = time.Now()
72
-
} else {
73
-
dateI = c.Committer.When
74
-
}
75
-
}
76
-
77
-
if self.refs[j].tag != nil {
78
-
dateJ = self.refs[j].tag.Tagger.When
79
-
} else {
80
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
81
-
if err != nil {
82
-
dateJ = time.Now()
83
-
} else {
84
-
dateJ = c.Committer.When
85
-
}
86
-
}
87
-
88
-
return dateI.After(dateJ)
89
-
}
90
-
91
40
func Open(path string, ref string) (*GitRepo, error) {
92
41
var err error
93
42
g := GitRepo{path: path}
···
122
71
return &g, nil
123
72
}
124
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
+
125
85
func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
126
86
commits := []*object.Commit{}
127
87
···
171
131
return g.r.CommitObject(h)
172
132
}
173
133
174
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
175
-
c, err := g.r.CommitObject(g.h)
176
-
if err != nil {
177
-
return nil, fmt.Errorf("last commit: %w", err)
178
-
}
179
-
return c, nil
180
-
}
181
-
182
134
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
183
135
c, err := g.r.CommitObject(g.h)
184
136
if err != nil {
···
211
163
}
212
164
213
165
return buf.Bytes(), nil
214
-
}
215
-
216
-
func (g *GitRepo) FileContent(path string) (string, error) {
217
-
c, err := g.r.CommitObject(g.h)
218
-
if err != nil {
219
-
return "", fmt.Errorf("commit object: %w", err)
220
-
}
221
-
222
-
tree, err := c.Tree()
223
-
if err != nil {
224
-
return "", fmt.Errorf("file tree: %w", err)
225
-
}
226
-
227
-
file, err := tree.File(path)
228
-
if err != nil {
229
-
return "", err
230
-
}
231
-
232
-
isbin, _ := file.IsBinary()
233
-
234
-
if !isbin {
235
-
return file.Contents()
236
-
} else {
237
-
return "", ErrBinaryFile
238
-
}
239
166
}
240
167
241
168
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
410
337
func (i *infoWrapper) Sys() any {
411
338
return nil
412
339
}
413
-
414
-
func (t *TagReference) Name() string {
415
-
return t.ref.Name().Short()
416
-
}
417
-
418
-
func (t *TagReference) Message() string {
419
-
if t.tag != nil {
420
-
return t.tag.Message
421
-
}
422
-
return ""
423
-
}
424
-
425
-
func (t *TagReference) TagObject() *object.Tag {
426
-
return t.tag
427
-
}
428
-
429
-
func (t *TagReference) Hash() plumbing.Hash {
430
-
return t.ref.Hash()
431
-
}
+21
-2
knotserver/git/last_commit.go
+21
-2
knotserver/git/last_commit.go
···
30
30
commitCache = cache
31
31
}
32
32
33
-
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) {
33
+
// processReader wraps a reader and ensures the associated process is cleaned up
34
+
type processReader struct {
35
+
io.Reader
36
+
cmd *exec.Cmd
37
+
stdout io.ReadCloser
38
+
}
39
+
40
+
func (pr *processReader) Close() error {
41
+
if err := pr.stdout.Close(); err != nil {
42
+
return err
43
+
}
44
+
return pr.cmd.Wait()
45
+
}
46
+
47
+
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) {
34
48
args := []string{}
35
49
args = append(args, "log")
36
50
args = append(args, g.h.String())
···
48
62
return nil, err
49
63
}
50
64
51
-
return stdout, nil
65
+
return &processReader{
66
+
Reader: stdout,
67
+
cmd: cmd,
68
+
stdout: stdout,
69
+
}, nil
52
70
}
53
71
54
72
type commit struct {
···
104
122
if err != nil {
105
123
return nil, err
106
124
}
125
+
defer output.Close() // Ensure the git process is properly cleaned up
107
126
108
127
reader := bufio.NewReader(output)
109
128
var current commit
+150
-37
knotserver/git/merge.go
+150
-37
knotserver/git/merge.go
···
4
4
"bytes"
5
5
"crypto/sha256"
6
6
"fmt"
7
+
"log"
7
8
"os"
8
9
"os/exec"
9
10
"regexp"
···
12
13
"github.com/dgraph-io/ristretto"
13
14
"github.com/go-git/go-git/v5"
14
15
"github.com/go-git/go-git/v5/plumbing"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
15
18
)
16
19
17
20
type MergeCheckCache struct {
···
32
35
mergeCheckCache = MergeCheckCache{cache}
33
36
}
34
37
35
-
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
38
+
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
36
39
sep := byte(':')
37
40
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
38
41
return fmt.Sprintf("%x", hash)
···
49
52
}
50
53
}
51
54
52
-
func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
55
+
func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
53
56
key := m.cacheKey(g, patch, targetBranch)
54
57
val := m.cacheVal(mergeCheck)
55
58
m.cache.Set(key, val, 0)
56
59
}
57
60
58
-
func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
61
+
func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
59
62
key := m.cacheKey(g, patch, targetBranch)
60
63
if val, ok := m.cache.Get(key); ok {
61
64
if val == struct{}{} {
···
104
107
return fmt.Sprintf("merge failed: %s", e.Message)
105
108
}
106
109
107
-
func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
110
+
func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) {
108
111
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
109
112
if err != nil {
110
113
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
111
114
}
112
115
113
-
if _, err := tmpFile.Write(patchData); err != nil {
116
+
if _, err := tmpFile.Write([]byte(patchData)); err != nil {
114
117
tmpFile.Close()
115
118
os.Remove(tmpFile.Name())
116
119
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
···
162
165
return nil
163
166
}
164
167
165
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
168
+
func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
166
169
var stderr bytes.Buffer
167
170
var cmd *exec.Cmd
168
171
169
172
// configure default git user before merge
170
-
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171
-
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
173
+
exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
174
+
exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
175
+
exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
173
176
174
177
// if patch is a format-patch, apply using 'git am'
175
178
if opts.FormatPatch {
176
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177
-
} else {
178
-
// else, apply using 'git apply' and commit it manually
179
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180
-
applyCmd.Stderr = &stderr
181
-
if err := applyCmd.Run(); err != nil {
182
-
return fmt.Errorf("patch application failed: %s", stderr.String())
183
-
}
179
+
return g.applyMailbox(patchData)
180
+
}
184
181
185
-
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
186
-
if err := stageCmd.Run(); err != nil {
187
-
return fmt.Errorf("failed to stage changes: %w", err)
188
-
}
182
+
// else, apply using 'git apply' and commit it manually
183
+
applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
184
+
applyCmd.Stderr = &stderr
185
+
if err := applyCmd.Run(); err != nil {
186
+
return fmt.Errorf("patch application failed: %s", stderr.String())
187
+
}
189
188
190
-
commitArgs := []string{"-C", tmpDir, "commit"}
189
+
stageCmd := exec.Command("git", "-C", g.path, "add", ".")
190
+
if err := stageCmd.Run(); err != nil {
191
+
return fmt.Errorf("failed to stage changes: %w", err)
192
+
}
191
193
192
-
// Set author if provided
193
-
authorName := opts.AuthorName
194
-
authorEmail := opts.AuthorEmail
194
+
commitArgs := []string{"-C", g.path, "commit"}
195
195
196
-
if authorName != "" && authorEmail != "" {
197
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198
-
}
199
-
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
196
+
// Set author if provided
197
+
authorName := opts.AuthorName
198
+
authorEmail := opts.AuthorEmail
200
199
201
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
200
+
if authorName != "" && authorEmail != "" {
201
+
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
202
+
}
203
+
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
202
204
203
-
if opts.CommitBody != "" {
204
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
-
}
205
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
206
206
207
-
cmd = exec.Command("git", commitArgs...)
207
+
if opts.CommitBody != "" {
208
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
208
209
}
210
+
211
+
cmd = exec.Command("git", commitArgs...)
209
212
210
213
cmd.Stderr = &stderr
211
214
···
216
219
return nil
217
220
}
218
221
219
-
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
222
+
func (g *GitRepo) applyMailbox(patchData string) error {
223
+
fps, err := patchutil.ExtractPatches(patchData)
224
+
if err != nil {
225
+
return fmt.Errorf("failed to extract patches: %w", err)
226
+
}
227
+
228
+
// apply each patch one by one
229
+
// update the newly created commit object to add the change-id header
230
+
total := len(fps)
231
+
for i, p := range fps {
232
+
newCommit, err := g.applySingleMailbox(p)
233
+
if err != nil {
234
+
return err
235
+
}
236
+
237
+
log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
238
+
}
239
+
240
+
return nil
241
+
}
242
+
243
+
func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
244
+
tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw)
245
+
if err != nil {
246
+
return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err)
247
+
}
248
+
249
+
var stderr bytes.Buffer
250
+
cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
251
+
cmd.Stderr = &stderr
252
+
253
+
head, err := g.r.Head()
254
+
if err != nil {
255
+
return plumbing.ZeroHash, err
256
+
}
257
+
log.Println("head before apply", head.Hash().String())
258
+
259
+
if err := cmd.Run(); err != nil {
260
+
return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String())
261
+
}
262
+
263
+
if err := g.Refresh(); err != nil {
264
+
return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
265
+
}
266
+
267
+
head, err = g.r.Head()
268
+
if err != nil {
269
+
return plumbing.ZeroHash, err
270
+
}
271
+
log.Println("head after apply", head.Hash().String())
272
+
273
+
newHash := head.Hash()
274
+
if changeId, err := singlePatch.ChangeId(); err != nil {
275
+
// no change ID
276
+
} else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
277
+
return plumbing.ZeroHash, err
278
+
} else {
279
+
newHash = updatedHash
280
+
}
281
+
282
+
return newHash, nil
283
+
}
284
+
285
+
func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
286
+
log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
287
+
obj, err := g.r.CommitObject(hash)
288
+
if err != nil {
289
+
return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
290
+
}
291
+
292
+
// write the change-id header
293
+
obj.ExtraHeaders["change-id"] = []byte(changeId)
294
+
295
+
// create a new object
296
+
dest := g.r.Storer.NewEncodedObject()
297
+
if err := obj.Encode(dest); err != nil {
298
+
return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
299
+
}
300
+
301
+
// store the new object
302
+
newHash, err := g.r.Storer.SetEncodedObject(dest)
303
+
if err != nil {
304
+
return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
305
+
}
306
+
307
+
log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
308
+
309
+
// find the branch that HEAD is pointing to
310
+
ref, err := g.r.Head()
311
+
if err != nil {
312
+
return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
313
+
}
314
+
315
+
// and update that branch to point to new commit
316
+
if ref.Name().IsBranch() {
317
+
err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
318
+
if err != nil {
319
+
return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
320
+
}
321
+
}
322
+
323
+
// new hash of commit
324
+
return newHash, nil
325
+
}
326
+
327
+
func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error {
220
328
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
221
329
return val
222
330
}
···
244
352
return result
245
353
}
246
354
247
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
355
+
func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
248
356
patchFile, err := g.createTempFileWithPatch(patchData)
249
357
if err != nil {
250
358
return &ErrMerge{
···
263
371
}
264
372
defer os.RemoveAll(tmpDir)
265
373
266
-
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
374
+
tmpRepo, err := PlainOpen(tmpDir)
375
+
if err != nil {
376
+
return err
377
+
}
378
+
379
+
if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
267
380
return err
268
381
}
269
382
+1
-3
knotserver/git/tag.go
+1
-3
knotserver/git/tag.go
···
2
2
3
3
import (
4
4
"fmt"
5
-
"slices"
6
5
"strconv"
7
6
"strings"
8
7
"time"
···
35
34
outFormat.WriteString("")
36
35
outFormat.WriteString(recordSeparator)
37
36
38
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
37
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
39
38
if err != nil {
40
39
return nil, fmt.Errorf("failed to get tags: %w", err)
41
40
}
···
94
93
tags = append(tags, tag)
95
94
}
96
95
97
-
slices.Reverse(tags)
98
96
return tags, nil
99
97
}
+18
-18
knotserver/git.go
+18
-18
knotserver/git.go
···
13
13
"tangled.org/core/knotserver/git/service"
14
14
)
15
15
16
-
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
16
+
func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
17
17
did := chi.URLParam(r, "did")
18
18
name := chi.URLParam(r, "name")
19
19
repoName, err := securejoin.SecureJoin(did, name)
20
20
if err != nil {
21
21
gitError(w, "repository not found", http.StatusNotFound)
22
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
22
+
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
23
23
return
24
24
}
25
25
26
-
repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName)
26
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName)
27
27
if err != nil {
28
28
gitError(w, "repository not found", http.StatusNotFound)
29
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
29
+
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
30
30
return
31
31
}
32
32
···
46
46
47
47
if err := cmd.InfoRefs(); err != nil {
48
48
gitError(w, err.Error(), http.StatusInternalServerError)
49
-
d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
49
+
h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
50
50
return
51
51
}
52
52
case "git-receive-pack":
53
-
d.RejectPush(w, r, name)
53
+
h.RejectPush(w, r, name)
54
54
default:
55
55
gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
56
56
}
57
57
}
58
58
59
-
func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
59
+
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
60
did := chi.URLParam(r, "did")
61
61
name := chi.URLParam(r, "name")
62
-
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
63
if err != nil {
64
64
gitError(w, err.Error(), http.StatusInternalServerError)
65
-
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
66
return
67
67
}
68
68
···
77
77
gzipReader, err := gzip.NewReader(r.Body)
78
78
if err != nil {
79
79
gitError(w, err.Error(), http.StatusInternalServerError)
80
-
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
81
81
return
82
82
}
83
83
defer gzipReader.Close()
···
88
88
w.Header().Set("Connection", "Keep-Alive")
89
89
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
90
90
91
-
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
91
+
h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
92
92
93
93
cmd := service.ServiceCommand{
94
94
GitProtocol: r.Header.Get("Git-Protocol"),
···
100
100
w.WriteHeader(http.StatusOK)
101
101
102
102
if err := cmd.UploadPack(); err != nil {
103
-
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
103
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
104
104
return
105
105
}
106
106
}
107
107
108
-
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
108
+
func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
109
109
did := chi.URLParam(r, "did")
110
110
name := chi.URLParam(r, "name")
111
-
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
111
+
_, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
112
112
if err != nil {
113
113
gitError(w, err.Error(), http.StatusForbidden)
114
-
d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
114
+
h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
115
115
return
116
116
}
117
117
118
-
d.RejectPush(w, r, name)
118
+
h.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
121
+
func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
122
122
// A text/plain response will cause git to print each line of the body
123
123
// prefixed with "remote: ".
124
124
w.Header().Set("content-type", "text/plain; charset=UTF-8")
···
131
131
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
132
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
133
133
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
134
-
hostname := d.c.Server.Hostname
134
+
hostname := h.c.Server.Hostname
135
135
if strings.Contains(hostname, ":") {
136
136
hostname = strings.Split(hostname, ":")[0]
137
137
}
-4
knotserver/http_util.go
-4
knotserver/http_util.go
+3
-7
knotserver/ingester.go
+3
-7
knotserver/ingester.go
···
16
16
"github.com/bluesky-social/jetstream/pkg/models"
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
18
"tangled.org/core/api/tangled"
19
-
"tangled.org/core/idresolver"
20
19
"tangled.org/core/knotserver/db"
21
20
"tangled.org/core/knotserver/git"
22
21
"tangled.org/core/log"
···
120
119
}
121
120
122
121
// resolve this aturi to extract the repo record
123
-
resolver := idresolver.DefaultResolver()
124
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
122
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
125
123
if err != nil || ident.Handle.IsInvalidHandle() {
126
124
return fmt.Errorf("failed to resolve handle: %w", err)
127
125
}
···
233
231
return err
234
232
}
235
233
236
-
resolver := idresolver.DefaultResolver()
237
-
238
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
234
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
239
235
if err != nil || subjectId.Handle.IsInvalidHandle() {
240
236
return err
241
237
}
242
238
243
239
// TODO: fix this for good, we need to fetch the record here unfortunately
244
240
// resolve this aturi to extract the repo record
245
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
241
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
246
242
if err != nil || owner.Handle.IsInvalidHandle() {
247
243
return fmt.Errorf("failed to resolve handle: %w", err)
248
244
}
+153
-7
knotserver/internal.go
+153
-7
knotserver/internal.go
···
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
14
"github.com/go-chi/chi/v5"
15
15
"github.com/go-chi/chi/v5/middleware"
16
+
"github.com/go-git/go-git/v5/plumbing"
16
17
"tangled.org/core/api/tangled"
17
18
"tangled.org/core/hook"
19
+
"tangled.org/core/idresolver"
18
20
"tangled.org/core/knotserver/config"
19
21
"tangled.org/core/knotserver/db"
20
22
"tangled.org/core/knotserver/git"
23
+
"tangled.org/core/log"
21
24
"tangled.org/core/notifier"
22
25
"tangled.org/core/rbac"
23
26
"tangled.org/core/workflow"
24
27
)
25
28
26
29
type InternalHandle struct {
27
-
db *db.DB
28
-
c *config.Config
29
-
e *rbac.Enforcer
30
-
l *slog.Logger
31
-
n *notifier.Notifier
30
+
db *db.DB
31
+
c *config.Config
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
res *idresolver.Resolver
32
36
}
33
37
34
38
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
64
68
writeJSON(w, data)
65
69
}
66
70
71
+
// response in text/plain format
72
+
// the body will be qualified repository path on success/push-denied
73
+
// or an error message when process failed
74
+
func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
75
+
l := h.l.With("handler", "PostReceiveHook")
76
+
77
+
var (
78
+
incomingUser = r.URL.Query().Get("user")
79
+
repo = r.URL.Query().Get("repo")
80
+
gitCommand = r.URL.Query().Get("gitCmd")
81
+
)
82
+
83
+
if incomingUser == "" || repo == "" || gitCommand == "" {
84
+
w.WriteHeader(http.StatusBadRequest)
85
+
l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
86
+
fmt.Fprintln(w, "invalid internal request")
87
+
return
88
+
}
89
+
90
+
// did:foo/repo-name or
91
+
// handle/repo-name or
92
+
// any of the above with a leading slash (/)
93
+
components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
94
+
l.Info("command components", "components", components)
95
+
96
+
if len(components) != 2 {
97
+
w.WriteHeader(http.StatusBadRequest)
98
+
l.Error("invalid repo format", "components", components)
99
+
fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
100
+
return
101
+
}
102
+
repoOwner := components[0]
103
+
repoName := components[1]
104
+
105
+
resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
106
+
107
+
repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner)
108
+
if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
109
+
l.Error("Error resolving handle", "handle", repoOwner, "err", err)
110
+
w.WriteHeader(http.StatusInternalServerError)
111
+
fmt.Fprintf(w, "error resolving handle: invalid handle\n")
112
+
return
113
+
}
114
+
repoOwnerDid := repoOwnerIdent.DID.String()
115
+
116
+
qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
117
+
118
+
if gitCommand == "git-receive-pack" {
119
+
ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
120
+
if err != nil || !ok {
121
+
w.WriteHeader(http.StatusForbidden)
122
+
fmt.Fprint(w, repo)
123
+
return
124
+
}
125
+
}
126
+
127
+
w.WriteHeader(http.StatusOK)
128
+
fmt.Fprint(w, qualifiedRepo)
129
+
}
130
+
67
131
type PushOptions struct {
68
132
skipCi bool
69
133
verboseCi bool
···
115
179
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
116
180
if err != nil {
117
181
l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
182
+
// non-fatal
183
+
}
184
+
185
+
err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
186
+
if err != nil {
187
+
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
118
188
// non-fatal
119
189
}
120
190
···
173
243
return errors.Join(errs, h.db.InsertEvent(event, h.n))
174
244
}
175
245
176
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
246
+
func (h *InternalHandle) triggerPipeline(
247
+
clientMsgs *[]string,
248
+
line git.PostReceiveLine,
249
+
gitUserDid string,
250
+
repoDid string,
251
+
repoName string,
252
+
pushOptions PushOptions,
253
+
) error {
177
254
if pushOptions.skipCi {
178
255
return nil
179
256
}
···
268
345
return h.db.InsertEvent(event, h.n)
269
346
}
270
347
271
-
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
348
+
func (h *InternalHandle) emitCompareLink(
349
+
clientMsgs *[]string,
350
+
line git.PostReceiveLine,
351
+
repoDid string,
352
+
repoName string,
353
+
) error {
354
+
// this is a second push to a branch, don't reply with the link again
355
+
if !line.OldSha.IsZero() {
356
+
return nil
357
+
}
358
+
359
+
// the ref was not updated to a new hash, don't reply with the link
360
+
//
361
+
// NOTE: do we need this?
362
+
if line.NewSha.String() == line.OldSha.String() {
363
+
return nil
364
+
}
365
+
366
+
pushedRef := plumbing.ReferenceName(line.Ref)
367
+
368
+
userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
369
+
user := repoDid
370
+
if err == nil {
371
+
user = userIdent.Handle.String()
372
+
}
373
+
374
+
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
375
+
if err != nil {
376
+
return err
377
+
}
378
+
379
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
380
+
if err != nil {
381
+
return err
382
+
}
383
+
384
+
gr, err := git.PlainOpen(repoPath)
385
+
if err != nil {
386
+
return err
387
+
}
388
+
389
+
defaultBranch, err := gr.FindMainBranch()
390
+
if err != nil {
391
+
return err
392
+
}
393
+
394
+
// pushing to default branch
395
+
if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
396
+
return nil
397
+
}
398
+
399
+
// pushing a tag, don't prompt the user the open a PR
400
+
if pushedRef.IsTag() {
401
+
return nil
402
+
}
403
+
404
+
ZWS := "\u200B"
405
+
*clientMsgs = append(*clientMsgs, ZWS)
406
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
407
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
408
+
*clientMsgs = append(*clientMsgs, ZWS)
409
+
return nil
410
+
}
411
+
412
+
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
272
413
r := chi.NewRouter()
414
+
l := log.FromContext(ctx)
415
+
l = log.SubLogger(l, "internal")
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
273
417
274
418
h := InternalHandle{
275
419
db,
···
277
421
e,
278
422
l,
279
423
n,
424
+
res,
280
425
}
281
426
282
427
r.Get("/push-allowed", h.PushAllowed)
283
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
284
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
285
431
r.Mount("/debug", middleware.Profiler())
286
432
+53
knotserver/middleware.go
+53
knotserver/middleware.go
···
1
+
package knotserver
2
+
3
+
import (
4
+
"log/slog"
5
+
"net/http"
6
+
"time"
7
+
)
8
+
9
+
func (h *Knot) RequestLogger(next http.Handler) http.Handler {
10
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11
+
start := time.Now()
12
+
13
+
next.ServeHTTP(w, r)
14
+
15
+
// Build query params as slog.Attrs for the group
16
+
queryParams := r.URL.Query()
17
+
queryAttrs := make([]any, 0, len(queryParams))
18
+
for key, values := range queryParams {
19
+
if len(values) == 1 {
20
+
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
21
+
} else {
22
+
queryAttrs = append(queryAttrs, slog.Any(key, values))
23
+
}
24
+
}
25
+
26
+
h.l.LogAttrs(r.Context(), slog.LevelInfo, "",
27
+
slog.Group("request",
28
+
slog.String("method", r.Method),
29
+
slog.String("path", r.URL.Path),
30
+
slog.Group("query", queryAttrs...),
31
+
slog.Duration("duration", time.Since(start)),
32
+
),
33
+
)
34
+
})
35
+
}
36
+
37
+
func (h *Knot) CORS(next http.Handler) http.Handler {
38
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39
+
// Set CORS headers
40
+
w.Header().Set("Access-Control-Allow-Origin", "*")
41
+
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
42
+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
43
+
w.Header().Set("Access-Control-Max-Age", "86400")
44
+
45
+
// Handle preflight requests
46
+
if r.Method == "OPTIONS" {
47
+
w.WriteHeader(http.StatusOK)
48
+
return
49
+
}
50
+
51
+
next.ServeHTTP(w, r)
52
+
})
53
+
}
+18
-10
knotserver/router.go
+18
-10
knotserver/router.go
···
12
12
"tangled.org/core/knotserver/config"
13
13
"tangled.org/core/knotserver/db"
14
14
"tangled.org/core/knotserver/xrpc"
15
-
tlog "tangled.org/core/log"
15
+
"tangled.org/core/log"
16
16
"tangled.org/core/notifier"
17
17
"tangled.org/core/rbac"
18
18
"tangled.org/core/xrpc/serviceauth"
···
28
28
resolver *idresolver.Resolver
29
29
}
30
30
31
-
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
32
-
r := chi.NewRouter()
33
-
31
+
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) {
34
32
h := Knot{
35
33
c: c,
36
34
db: db,
37
35
e: e,
38
-
l: l,
36
+
l: log.FromContext(ctx),
39
37
jc: jc,
40
38
n: n,
41
-
resolver: idresolver.DefaultResolver(),
39
+
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
42
40
}
43
41
44
42
err := e.AddKnot(rbac.ThisServer)
···
67
65
return nil, fmt.Errorf("failed to start jetstream: %w", err)
68
66
}
69
67
68
+
return h.Router(), nil
69
+
}
70
+
71
+
func (h *Knot) Router() http.Handler {
72
+
r := chi.NewRouter()
73
+
74
+
r.Use(h.CORS)
75
+
r.Use(h.RequestLogger)
76
+
70
77
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
71
78
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
72
79
})
···
86
93
// Socket that streams git oplogs
87
94
r.Get("/events", h.Events)
88
95
89
-
return r, nil
96
+
return r
90
97
}
91
98
92
99
func (h *Knot) XrpcRouter() http.Handler {
93
-
logger := tlog.New("knots")
94
-
95
100
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
101
+
102
+
l := log.SubLogger(h.l, "xrpc")
96
103
97
104
xrpc := &xrpc.Xrpc{
98
105
Config: h.c,
99
106
Db: h.db,
100
107
Ingester: h.jc,
101
108
Enforcer: h.e,
102
-
Logger: logger,
109
+
Logger: l,
103
110
Notifier: h.n,
104
111
Resolver: h.resolver,
105
112
ServiceAuth: serviceAuth,
106
113
}
114
+
107
115
return xrpc.Router()
108
116
}
109
117
+5
-4
knotserver/server.go
+5
-4
knotserver/server.go
···
43
43
44
44
func Run(ctx context.Context, cmd *cli.Command) error {
45
45
logger := log.FromContext(ctx)
46
-
iLogger := log.New("knotserver/internal")
46
+
logger = log.SubLogger(logger, cmd.Name)
47
+
ctx = log.IntoContext(ctx, logger)
47
48
48
49
c, err := config.Load(ctx)
49
50
if err != nil {
···
80
81
tangled.KnotMemberNSID,
81
82
tangled.RepoPullNSID,
82
83
tangled.RepoCollaboratorNSID,
83
-
}, nil, logger, db, true, c.Server.LogDids)
84
+
}, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
84
85
if err != nil {
85
86
logger.Error("failed to setup jetstream", "error", err)
86
87
}
87
88
88
89
notifier := notifier.New()
89
90
90
-
mux, err := Setup(ctx, c, db, e, jc, logger, ¬ifier)
91
+
mux, err := Setup(ctx, c, db, e, jc, ¬ifier)
91
92
if err != nil {
92
93
return fmt.Errorf("failed to setup server: %w", err)
93
94
}
94
95
95
-
imux := Internal(ctx, c, db, e, iLogger, ¬ifier)
96
+
imux := Internal(ctx, c, db, e, ¬ifier)
96
97
97
98
logger.Info("starting internal server", "address", c.Server.InternalListenAddr)
98
99
go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+87
knotserver/xrpc/delete_branch.go
+87
knotserver/xrpc/delete_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
"tangled.org/core/rbac"
15
+
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
+
)
18
+
19
+
func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) {
20
+
l := x.Logger
21
+
fail := func(e xrpcerr.XrpcError) {
22
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
+
writeError(w, e, http.StatusBadRequest)
24
+
}
25
+
26
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
+
if !ok {
28
+
fail(xrpcerr.MissingActorDidError)
29
+
return
30
+
}
31
+
32
+
var data tangled.RepoDeleteBranch_Input
33
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
+
fail(xrpcerr.GenericError(err))
35
+
return
36
+
}
37
+
38
+
// unfortunately we have to resolve repo-at here
39
+
repoAt, err := syntax.ParseATURI(data.Repo)
40
+
if err != nil {
41
+
fail(xrpcerr.InvalidRepoError(data.Repo))
42
+
return
43
+
}
44
+
45
+
// resolve this aturi to extract the repo record
46
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
+
if err != nil || ident.Handle.IsInvalidHandle() {
48
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
+
return
50
+
}
51
+
52
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
+
if err != nil {
55
+
fail(xrpcerr.GenericError(err))
56
+
return
57
+
}
58
+
59
+
repo := resp.Value.Val.(*tangled.Repo)
60
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
61
+
if err != nil {
62
+
fail(xrpcerr.GenericError(err))
63
+
return
64
+
}
65
+
66
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
+
l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath)
68
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
+
return
70
+
}
71
+
72
+
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
+
gr, err := git.PlainOpen(path)
74
+
if err != nil {
75
+
fail(xrpcerr.GenericError(err))
76
+
return
77
+
}
78
+
79
+
err = gr.DeleteBranch(data.Branch)
80
+
if err != nil {
81
+
l.Error("deleting branch", "error", err.Error(), "branch", data.Branch)
82
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
83
+
return
84
+
}
85
+
86
+
w.WriteHeader(http.StatusOK)
87
+
}
+1
-1
knotserver/xrpc/merge.go
+1
-1
knotserver/xrpc/merge.go
···
85
85
mo.CommitterEmail = x.Config.Git.UserEmail
86
86
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
87
87
88
-
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
88
+
err = gr.MergeWithOptions(data.Patch, data.Branch, mo)
89
89
if err != nil {
90
90
var mergeErr *git.ErrMerge
91
91
if errors.As(err, &mergeErr) {
+3
-1
knotserver/xrpc/merge_check.go
+3
-1
knotserver/xrpc/merge_check.go
···
51
51
return
52
52
}
53
53
54
-
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
54
+
err = gr.MergeCheck(data.Patch, data.Branch)
55
55
56
56
response := tangled.RepoMergeCheck_Output{
57
57
Is_conflicted: false,
···
80
80
response.Error = &errMsg
81
81
}
82
82
}
83
+
84
+
l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts)
83
85
84
86
w.Header().Set("Content-Type", "application/json")
85
87
w.WriteHeader(http.StatusOK)
+1
-1
knotserver/xrpc/repo_blob.go
+1
-1
knotserver/xrpc/repo_blob.go
···
44
44
45
45
contents, err := gr.RawContent(treePath)
46
46
if err != nil {
47
-
x.Logger.Error("file content", "error", err.Error())
47
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
48
48
writeError(w, xrpcerr.NewXrpcError(
49
49
xrpcerr.WithTag("FileNotFound"),
50
50
xrpcerr.WithMessage("file not found at the specified path"),
+20
-4
knotserver/xrpc/repo_compare.go
+20
-4
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
7
8
"tangled.org/core/knotserver/git"
8
9
"tangled.org/core/types"
9
10
xrpcerr "tangled.org/core/xrpc/errors"
···
71
72
return
72
73
}
73
74
75
+
var combinedPatch []*gitdiff.File
76
+
var combinedPatchRaw string
77
+
// we need the combined patch
78
+
if len(formatPatch) >= 2 {
79
+
diffTree, err := gr.DiffTree(commit1, commit2)
80
+
if err != nil {
81
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
82
+
} else {
83
+
combinedPatch = diffTree.Diff
84
+
combinedPatchRaw = diffTree.Patch
85
+
}
86
+
}
87
+
74
88
response := types.RepoFormatPatchResponse{
75
-
Rev1: commit1.Hash.String(),
76
-
Rev2: commit2.Hash.String(),
77
-
FormatPatch: formatPatch,
78
-
Patch: rawPatch,
89
+
Rev1: commit1.Hash.String(),
90
+
Rev2: commit2.Hash.String(),
91
+
FormatPatch: formatPatch,
92
+
FormatPatchRaw: rawPatch,
93
+
CombinedPatch: combinedPatch,
94
+
CombinedPatchRaw: combinedPatchRaw,
79
95
}
80
96
81
97
writeJson(w, response)
+24
knotserver/xrpc/repo_tree.go
+24
knotserver/xrpc/repo_tree.go
···
4
4
"net/http"
5
5
"path/filepath"
6
6
"time"
7
+
"unicode/utf8"
7
8
8
9
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/pages/markup"
9
11
"tangled.org/core/knotserver/git"
10
12
xrpcerr "tangled.org/core/xrpc/errors"
11
13
)
···
43
45
return
44
46
}
45
47
48
+
// if any of these files are a readme candidate, pass along its blob contents too
49
+
var readmeFileName string
50
+
var readmeContents string
51
+
for _, file := range files {
52
+
if markup.IsReadmeFile(file.Name) {
53
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
54
+
if err != nil {
55
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56
+
}
57
+
58
+
if utf8.Valid(contents) {
59
+
readmeFileName = file.Name
60
+
readmeContents = string(contents)
61
+
break
62
+
}
63
+
}
64
+
}
65
+
46
66
// convert NiceTree -> tangled.RepoTree_TreeEntry
47
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
48
68
for i, file := range files {
···
83
103
Parent: parentPtr,
84
104
Dotdot: dotdotPtr,
85
105
Files: treeEntries,
106
+
Readme: &tangled.RepoTree_Readme{
107
+
Filename: readmeFileName,
108
+
Contents: readmeContents,
109
+
},
86
110
}
87
111
88
112
writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
+1
knotserver/xrpc/xrpc.go
···
38
38
r.Use(x.ServiceAuth.VerifyServiceAuth)
39
39
40
40
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
41
+
r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch)
41
42
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
42
43
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
43
44
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
-158
legal/privacy.md
-158
legal/privacy.md
···
1
-
# Privacy Policy
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
This Privacy Policy describes how Tangled ("we," "us," or "our")
6
-
collects, uses, and shares your personal information when you use our
7
-
platform and services (the "Service").
8
-
9
-
## 1. Information We Collect
10
-
11
-
### Account Information
12
-
13
-
When you create an account, we collect:
14
-
15
-
- Your chosen username
16
-
- Email address
17
-
- Profile information you choose to provide
18
-
- Authentication data
19
-
20
-
### Content and Activity
21
-
22
-
We store:
23
-
24
-
- Code repositories and associated metadata
25
-
- Issues, pull requests, and comments
26
-
- Activity logs and usage patterns
27
-
- Public keys for authentication
28
-
29
-
## 2. Data Location and Hosting
30
-
31
-
### EU Data Hosting
32
-
33
-
**All Tangled service data is hosted within the European Union.**
34
-
Specifically:
35
-
36
-
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37
-
(*.tngl.sh) are located in Finland
38
-
- **Application Data:** All other service data is stored on EU-based
39
-
servers
40
-
- **Data Processing:** All data processing occurs within EU
41
-
jurisdiction
42
-
43
-
### External PDS Notice
44
-
45
-
**Important:** If your account is hosted on Bluesky's PDS or other
46
-
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47
-
that data. The data protection, storage location, and privacy
48
-
practices for such accounts are governed by the respective PDS
49
-
provider's policies, not this Privacy Policy. We only control data
50
-
processing within our own services and infrastructure.
51
-
52
-
## 3. Third-Party Data Processors
53
-
54
-
We only share your data with the following third-party processors:
55
-
56
-
### Resend (Email Services)
57
-
58
-
- **Purpose:** Sending transactional emails (account verification,
59
-
notifications)
60
-
- **Data Shared:** Email address and necessary message content
61
-
62
-
### Cloudflare (Image Caching)
63
-
64
-
- **Purpose:** Caching and optimizing image delivery
65
-
- **Data Shared:** Public images and associated metadata for caching
66
-
purposes
67
-
68
-
### Posthog (Usage Metrics Tracking)
69
-
70
-
- **Purpose:** Tracking usage and platform metrics
71
-
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72
-
information
73
-
74
-
## 4. How We Use Your Information
75
-
76
-
We use your information to:
77
-
78
-
- Provide and maintain the Service
79
-
- Process your transactions and requests
80
-
- Send you technical notices and support messages
81
-
- Improve and develop new features
82
-
- Ensure security and prevent fraud
83
-
- Comply with legal obligations
84
-
85
-
## 5. Data Sharing and Disclosure
86
-
87
-
We do not sell, trade, or rent your personal information. We may share
88
-
your information only in the following circumstances:
89
-
90
-
- With the third-party processors listed above
91
-
- When required by law or legal process
92
-
- To protect our rights, property, or safety, or that of our users
93
-
- In connection with a merger, acquisition, or sale of assets (with
94
-
appropriate protections)
95
-
96
-
## 6. Data Security
97
-
98
-
We implement appropriate technical and organizational measures to
99
-
protect your personal information against unauthorized access,
100
-
alteration, disclosure, or destruction. However, no method of
101
-
transmission over the Internet is 100% secure.
102
-
103
-
## 7. Data Retention
104
-
105
-
We retain your personal information for as long as necessary to provide
106
-
the Service and fulfill the purposes outlined in this Privacy Policy,
107
-
unless a longer retention period is required by law.
108
-
109
-
## 8. Your Rights
110
-
111
-
Under applicable data protection laws, you have the right to:
112
-
113
-
- Access your personal information
114
-
- Correct inaccurate information
115
-
- Request deletion of your information
116
-
- Object to processing of your information
117
-
- Data portability
118
-
- Withdraw consent (where applicable)
119
-
120
-
## 9. Cookies and Tracking
121
-
122
-
We use cookies and similar technologies to:
123
-
124
-
- Maintain your login session
125
-
- Remember your preferences
126
-
- Analyze usage patterns to improve the Service
127
-
128
-
You can control cookie settings through your browser preferences.
129
-
130
-
## 10. Children's Privacy
131
-
132
-
The Service is not intended for children under 16 years of age. We do
133
-
not knowingly collect personal information from children under 16. If
134
-
we become aware that we have collected such information, we will take
135
-
steps to delete it.
136
-
137
-
## 11. International Data Transfers
138
-
139
-
While all our primary data processing occurs within the EU, some of our
140
-
third-party processors may process data outside the EU. When this
141
-
occurs, we ensure appropriate safeguards are in place, such as Standard
142
-
Contractual Clauses or adequacy decisions.
143
-
144
-
## 12. Changes to This Privacy Policy
145
-
146
-
We may update this Privacy Policy from time to time. We will notify you
147
-
of any changes by posting the new Privacy Policy on this page and
148
-
updating the "Last updated" date.
149
-
150
-
## 13. Contact Information
151
-
152
-
If you have any questions about this Privacy Policy or wish to exercise
153
-
your rights, please contact us through our platform or via email.
154
-
155
-
---
156
-
157
-
This Privacy Policy complies with the EU General Data Protection
158
-
Regulation (GDPR) and other applicable data protection laws.
-109
legal/terms.md
-109
legal/terms.md
···
1
-
# Terms of Service
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
-
to and use of the Tangled platform and services (the "Service")
7
-
operated by us ("Tangled," "we," "us," or "our").
8
-
9
-
## 1. Acceptance of Terms
10
-
11
-
By accessing or using our Service, you agree to be bound by these Terms.
12
-
If you disagree with any part of these terms, then you may not access
13
-
the Service.
14
-
15
-
## 2. Account Registration
16
-
17
-
To use certain features of the Service, you must register for an
18
-
account. You agree to provide accurate, current, and complete
19
-
information during the registration process and to update such
20
-
information to keep it accurate, current, and complete.
21
-
22
-
## 3. Account Termination
23
-
24
-
> **Important Notice**
25
-
>
26
-
> **We reserve the right to terminate, suspend, or restrict access to
27
-
> your account at any time, for any reason, or for no reason at all, at
28
-
> our sole discretion.** This includes, but is not limited to,
29
-
> termination for violation of these Terms, inappropriate conduct, spam,
30
-
> abuse, or any other behavior we deem harmful to the Service or other
31
-
> users.
32
-
>
33
-
> Account termination may result in the loss of access to your
34
-
> repositories, data, and other content associated with your account. We
35
-
> are not obligated to provide advance notice of termination, though we
36
-
> may do so in our discretion.
37
-
38
-
## 4. Acceptable Use
39
-
40
-
You agree not to use the Service to:
41
-
42
-
- Violate any applicable laws or regulations
43
-
- Infringe upon the rights of others
44
-
- Upload, store, or share content that is illegal, harmful, threatening,
45
-
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
-
objectionable
47
-
- Engage in spam, phishing, or other deceptive practices
48
-
- Attempt to gain unauthorized access to the Service or other users'
49
-
accounts
50
-
- Interfere with or disrupt the Service or servers connected to the
51
-
Service
52
-
53
-
## 5. Content and Intellectual Property
54
-
55
-
You retain ownership of the content you upload to the Service. By
56
-
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
-
license to use, reproduce, modify, and distribute your content as
58
-
necessary to provide the Service.
59
-
60
-
## 6. Privacy
61
-
62
-
Your privacy is important to us. Please review our [Privacy
63
-
Policy](/privacy), which also governs your use of the Service.
64
-
65
-
## 7. Disclaimers
66
-
67
-
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
-
no warranties, expressed or implied, and hereby disclaim and negate all
69
-
other warranties including without limitation, implied warranties or
70
-
conditions of merchantability, fitness for a particular purpose, or
71
-
non-infringement of intellectual property or other violation of rights.
72
-
73
-
## 8. Limitation of Liability
74
-
75
-
In no event shall Tangled, nor its directors, employees, partners,
76
-
agents, suppliers, or affiliates, be liable for any indirect,
77
-
incidental, special, consequential, or punitive damages, including
78
-
without limitation, loss of profits, data, use, goodwill, or other
79
-
intangible losses, resulting from your use of the Service.
80
-
81
-
## 9. Indemnification
82
-
83
-
You agree to defend, indemnify, and hold harmless Tangled and its
84
-
affiliates, officers, directors, employees, and agents from and against
85
-
any and all claims, damages, obligations, losses, liabilities, costs,
86
-
or debt, and expenses (including attorney's fees).
87
-
88
-
## 10. Governing Law
89
-
90
-
These Terms shall be interpreted and governed by the laws of Finland,
91
-
without regard to its conflict of law provisions.
92
-
93
-
## 11. Changes to Terms
94
-
95
-
We reserve the right to modify or replace these Terms at any time. If a
96
-
revision is material, we will try to provide at least 30 days notice
97
-
prior to any new terms taking effect.
98
-
99
-
## 12. Contact Information
100
-
101
-
If you have any questions about these Terms of Service, please contact
102
-
us through our platform or via email.
103
-
104
-
---
105
-
106
-
These terms are effective as of the last updated date shown above and
107
-
will remain in effect except with respect to any changes in their
108
-
provisions in the future, which will be in effect immediately after
109
-
being posted on this page.
+5
lexicons/actor/profile.json
+5
lexicons/actor/profile.json
+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
+
+15
lexicons/repo/repo.json
+15
lexicons/repo/repo.json
···
32
32
"minGraphemes": 1,
33
33
"maxGraphemes": 140
34
34
},
35
+
"website": {
36
+
"type": "string",
37
+
"format": "uri",
38
+
"description": "Any URI related to the repo"
39
+
},
40
+
"topics": {
41
+
"type": "array",
42
+
"description": "Topics related to the repo",
43
+
"items": {
44
+
"type": "string",
45
+
"minLength": 1,
46
+
"maxLength": 50
47
+
},
48
+
"maxLength": 50
49
+
},
35
50
"source": {
36
51
"type": "string",
37
52
"format": "uri",
+19
lexicons/repo/tree.json
+19
lexicons/repo/tree.json
···
41
41
"type": "string",
42
42
"description": "Parent directory path"
43
43
},
44
+
"readme": {
45
+
"type": "ref",
46
+
"ref": "#readme",
47
+
"description": "Readme for this file tree"
48
+
},
44
49
"files": {
45
50
"type": "array",
46
51
"items": {
···
69
74
"description": "Invalid request parameters"
70
75
}
71
76
]
77
+
},
78
+
"readme": {
79
+
"type": "object",
80
+
"required": ["filename", "contents"],
81
+
"properties": {
82
+
"filename": {
83
+
"type": "string",
84
+
"description": "Name of the readme file"
85
+
},
86
+
"contents": {
87
+
"type": "string",
88
+
"description": "Contents of the readme file"
89
+
}
90
+
}
72
91
},
73
92
"treeEntry": {
74
93
"type": "object",
+23
-9
log/log.go
+23
-9
log/log.go
···
4
4
"context"
5
5
"log/slog"
6
6
"os"
7
+
8
+
"github.com/charmbracelet/log"
7
9
)
8
10
9
-
// NewHandler sets up a new slog.Handler with the service name
10
-
// as an attribute
11
11
func NewHandler(name string) slog.Handler {
12
-
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
13
-
Level: slog.LevelDebug,
12
+
return log.NewWithOptions(os.Stderr, log.Options{
13
+
ReportTimestamp: true,
14
+
Prefix: name,
15
+
Level: log.DebugLevel,
14
16
})
15
-
16
-
var attrs []slog.Attr
17
-
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
18
-
handler.WithAttrs(attrs)
19
-
return handler
20
17
}
21
18
22
19
func New(name string) *slog.Logger {
···
49
46
50
47
return slog.Default()
51
48
}
49
+
50
+
// sublogger derives a new logger from an existing one by appending a suffix to its prefix.
51
+
func SubLogger(base *slog.Logger, suffix string) *slog.Logger {
52
+
// try to get the underlying charmbracelet logger
53
+
if cl, ok := base.Handler().(*log.Logger); ok {
54
+
prefix := cl.GetPrefix()
55
+
if prefix != "" {
56
+
prefix = prefix + "/" + suffix
57
+
} else {
58
+
prefix = suffix
59
+
}
60
+
return slog.New(NewHandler(prefix))
61
+
}
62
+
63
+
// Fallback: no known handler type
64
+
return slog.New(NewHandler(suffix))
65
+
}
+149
-17
nix/gomod2nix.toml
+149
-17
nix/gomod2nix.toml
···
13
13
[mod."github.com/ProtonMail/go-crypto"]
14
14
version = "v1.3.0"
15
15
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/RoaringBitmap/roaring/v2"]
17
+
version = "v2.4.5"
18
+
hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA="
16
19
[mod."github.com/alecthomas/assert/v2"]
17
20
version = "v2.11.0"
18
21
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
···
29
32
[mod."github.com/avast/retry-go/v4"]
30
33
version = "v4.6.1"
31
34
hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k="
35
+
[mod."github.com/aymanbagabas/go-osc52/v2"]
36
+
version = "v2.0.1"
37
+
hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg="
32
38
[mod."github.com/aymerick/douceur"]
33
39
version = "v0.2.0"
34
40
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
35
41
[mod."github.com/beorn7/perks"]
36
42
version = "v1.0.1"
37
43
hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
44
+
[mod."github.com/bits-and-blooms/bitset"]
45
+
version = "v1.22.0"
46
+
hash = "sha256-lY1K29h4vlAmJVvwKgbTG8BTACYGjFaginCszN+ST6w="
47
+
[mod."github.com/blevesearch/bleve/v2"]
48
+
version = "v2.5.3"
49
+
hash = "sha256-DkpX43WMpB8+9KCibdNjyf6N/1a51xJTfGF97xdoCAQ="
50
+
[mod."github.com/blevesearch/bleve_index_api"]
51
+
version = "v1.2.8"
52
+
hash = "sha256-LyGDBRvK2GThgUFLZoAbDOOKP1M9Z8oy0E2M6bHZdrk="
53
+
[mod."github.com/blevesearch/geo"]
54
+
version = "v0.2.4"
55
+
hash = "sha256-W1OV/pvqzJC28VJomGnIU/HeBZ689+p54vWdZ1z/bxc="
56
+
[mod."github.com/blevesearch/go-faiss"]
57
+
version = "v1.0.25"
58
+
hash = "sha256-bcm976UX22aNIuSjBxFaYMKTltO9lbqyeG4Z3KVG3/Y="
59
+
[mod."github.com/blevesearch/go-porterstemmer"]
60
+
version = "v1.0.3"
61
+
hash = "sha256-hUjo6g1ehUD1awBmta0ji/xoooD2qG7O22HIeSQiRFo="
62
+
[mod."github.com/blevesearch/gtreap"]
63
+
version = "v0.1.1"
64
+
hash = "sha256-B4p/5RnECRfV4yOiSQDLMHb23uI7lsQDePhNK+zjbF4="
65
+
[mod."github.com/blevesearch/mmap-go"]
66
+
version = "v1.0.4"
67
+
hash = "sha256-8y0nMAE9goKjYhR/FFEvtbP7cvM46xneE461L1Jn2Pg="
68
+
[mod."github.com/blevesearch/scorch_segment_api/v2"]
69
+
version = "v2.3.10"
70
+
hash = "sha256-BcBRjVOrsYySdsdgEjS3qHFm/c58KUNJepRPUO0lFmY="
71
+
[mod."github.com/blevesearch/segment"]
72
+
version = "v0.9.1"
73
+
hash = "sha256-0EAT737kNxl8IJFGl2SD9mOzxolONGgpfaYEGr7JXkQ="
74
+
[mod."github.com/blevesearch/snowballstem"]
75
+
version = "v0.9.0"
76
+
hash = "sha256-NQsXrhXcYXn4jQcvwjwLc96SGMRcqVlrR6hYKWGk7/s="
77
+
[mod."github.com/blevesearch/upsidedown_store_api"]
78
+
version = "v1.0.2"
79
+
hash = "sha256-P69Mnh6YR5RI73bD6L7BYDxkVmaqPMNUrjbfSJoKWuo="
80
+
[mod."github.com/blevesearch/vellum"]
81
+
version = "v1.1.0"
82
+
hash = "sha256-GJ1wslEJEZhPbMiANw0W4Dgb1ZouiILbWEaIUfxZTkw="
83
+
[mod."github.com/blevesearch/zapx/v11"]
84
+
version = "v11.4.2"
85
+
hash = "sha256-YzRcc2GwV4VL2Bc+tXOOUL6xNi8LWS76DXEcTkFPTaQ="
86
+
[mod."github.com/blevesearch/zapx/v12"]
87
+
version = "v12.4.2"
88
+
hash = "sha256-yqyzkMWpyXZSF9KLjtiuOmnRUfhaZImk27mU8lsMyJY="
89
+
[mod."github.com/blevesearch/zapx/v13"]
90
+
version = "v13.4.2"
91
+
hash = "sha256-VSS2fI7YUkeGMBH89TB9yW5qG8MWjM6zKbl8DboHsB4="
92
+
[mod."github.com/blevesearch/zapx/v14"]
93
+
version = "v14.4.2"
94
+
hash = "sha256-mAWr+vK0uZWMUaJfGfchzQo4dzMdBbD3Z7F84Jn/ktg="
95
+
[mod."github.com/blevesearch/zapx/v15"]
96
+
version = "v15.4.2"
97
+
hash = "sha256-R8Eh3N4e8CDXiW47J8ZBnfMY1TTnX1SJPwQc4gYChi8="
98
+
[mod."github.com/blevesearch/zapx/v16"]
99
+
version = "v16.2.4"
100
+
hash = "sha256-Jo5k7DflV/ghszOWJTCOGVyyLMvlvSYyxRrmSIFjyEE="
38
101
[mod."github.com/bluekeyes/go-gitdiff"]
39
102
version = "v0.8.2"
40
103
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
104
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
105
[mod."github.com/bluesky-social/indigo"]
43
-
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
-
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
106
+
version = "v0.0.0-20251003000214-3259b215110e"
107
+
hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo="
45
108
[mod."github.com/bluesky-social/jetstream"]
46
109
version = "v0.0.0-20241210005130-ea96859b93d1"
47
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
48
111
[mod."github.com/bmatcuk/doublestar/v4"]
49
-
version = "v4.7.1"
50
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
112
+
version = "v4.9.1"
113
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
51
114
[mod."github.com/carlmjohnson/versioninfo"]
52
115
version = "v0.22.5"
53
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
63
126
[mod."github.com/cespare/xxhash/v2"]
64
127
version = "v2.3.0"
65
128
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
129
+
[mod."github.com/charmbracelet/colorprofile"]
130
+
version = "v0.2.3-0.20250311203215-f60798e515dc"
131
+
hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q="
132
+
[mod."github.com/charmbracelet/lipgloss"]
133
+
version = "v1.1.0"
134
+
hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4="
135
+
[mod."github.com/charmbracelet/log"]
136
+
version = "v0.4.2"
137
+
hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I="
138
+
[mod."github.com/charmbracelet/x/ansi"]
139
+
version = "v0.8.0"
140
+
hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas="
141
+
[mod."github.com/charmbracelet/x/cellbuf"]
142
+
version = "v0.0.13-0.20250311204145-2c3ea96c31dd"
143
+
hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU="
144
+
[mod."github.com/charmbracelet/x/term"]
145
+
version = "v0.2.1"
146
+
hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM="
66
147
[mod."github.com/cloudflare/circl"]
67
148
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
149
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
···
145
226
[mod."github.com/go-jose/go-jose/v3"]
146
227
version = "v3.0.4"
147
228
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
229
+
[mod."github.com/go-logfmt/logfmt"]
230
+
version = "v0.6.0"
231
+
hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg="
148
232
[mod."github.com/go-logr/logr"]
149
233
version = "v1.4.3"
150
234
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
···
163
247
[mod."github.com/gogo/protobuf"]
164
248
version = "v1.3.2"
165
249
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
250
+
[mod."github.com/goki/freetype"]
251
+
version = "v1.0.5"
252
+
hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs="
166
253
[mod."github.com/golang-jwt/jwt/v5"]
167
254
version = "v5.2.3"
168
255
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
172
259
[mod."github.com/golang/mock"]
173
260
version = "v1.6.0"
174
261
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
262
+
[mod."github.com/golang/protobuf"]
263
+
version = "v1.5.4"
264
+
hash = "sha256-N3+Lv9lEZjrdOWdQhFj6Y3Iap4rVLEQeI8/eFFyAMZ0="
265
+
[mod."github.com/golang/snappy"]
266
+
version = "v0.0.4"
267
+
hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA="
175
268
[mod."github.com/google/go-querystring"]
176
269
version = "v1.1.0"
177
270
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
···
268
361
[mod."github.com/ipfs/go-metrics-interface"]
269
362
version = "v0.3.0"
270
363
hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ="
364
+
[mod."github.com/json-iterator/go"]
365
+
version = "v1.1.12"
366
+
hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
271
367
[mod."github.com/kevinburke/ssh_config"]
272
368
version = "v1.2.0"
273
369
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
···
295
391
[mod."github.com/lestrrat-go/option"]
296
392
version = "v1.0.1"
297
393
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
394
+
[mod."github.com/lucasb-eyer/go-colorful"]
395
+
version = "v1.2.0"
396
+
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
298
397
[mod."github.com/mattn/go-isatty"]
299
398
version = "v0.0.20"
300
399
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
400
+
[mod."github.com/mattn/go-runewidth"]
401
+
version = "v0.0.16"
402
+
hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ="
301
403
[mod."github.com/mattn/go-sqlite3"]
302
404
version = "v1.14.24"
303
405
hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg="
···
319
421
[mod."github.com/moby/term"]
320
422
version = "v0.5.2"
321
423
hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU="
424
+
[mod."github.com/modern-go/concurrent"]
425
+
version = "v0.0.0-20180306012644-bacd9c7ef1dd"
426
+
hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo="
427
+
[mod."github.com/modern-go/reflect2"]
428
+
version = "v1.0.2"
429
+
hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU="
322
430
[mod."github.com/morikuni/aec"]
323
431
version = "v1.0.0"
324
432
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
325
433
[mod."github.com/mr-tron/base58"]
326
434
version = "v1.2.0"
327
435
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
436
+
[mod."github.com/mschoch/smat"]
437
+
version = "v0.2.0"
438
+
hash = "sha256-DZvUJXjIcta3U+zxzgU3wpoGn/V4lpBY7Xme8aQUi+E="
439
+
[mod."github.com/muesli/termenv"]
440
+
version = "v0.16.0"
441
+
hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI="
328
442
[mod."github.com/multiformats/go-base32"]
329
443
version = "v0.1.0"
330
444
hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio="
···
391
505
[mod."github.com/resend/resend-go/v2"]
392
506
version = "v2.15.0"
393
507
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
508
+
[mod."github.com/rivo/uniseg"]
509
+
version = "v0.4.7"
510
+
hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo="
394
511
[mod."github.com/ryanuber/go-glob"]
395
512
version = "v1.0.0"
396
513
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
···
407
524
[mod."github.com/spaolacci/murmur3"]
408
525
version = "v1.1.0"
409
526
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
527
+
[mod."github.com/srwiley/oksvg"]
528
+
version = "v0.0.0-20221011165216-be6e8873101c"
529
+
hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk="
530
+
[mod."github.com/srwiley/rasterx"]
531
+
version = "v0.0.0-20220730225603-2ab79fcdd4ef"
532
+
hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68="
410
533
[mod."github.com/stretchr/testify"]
411
534
version = "v1.10.0"
412
535
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
426
549
version = "v0.3.1"
427
550
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
551
[mod."github.com/wyatt915/goldmark-treeblood"]
429
-
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
430
-
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
552
+
version = "v0.0.1"
553
+
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
431
554
[mod."github.com/wyatt915/treeblood"]
432
-
version = "v0.1.15"
433
-
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
555
+
version = "v0.1.16"
556
+
hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw="
557
+
[mod."github.com/xo/terminfo"]
558
+
version = "v0.0.0-20220910002029-abceb7e1c41e"
559
+
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
434
560
[mod."github.com/yuin/goldmark"]
435
-
version = "v1.7.12"
436
-
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
561
+
version = "v1.7.13"
562
+
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
437
563
[mod."github.com/yuin/goldmark-highlighting/v2"]
438
564
version = "v2.0.0-20230729083705-37449abec8cc"
439
565
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
566
+
[mod."gitlab.com/staticnoise/goldmark-callout"]
567
+
version = "v0.0.0-20240609120641-6366b799e4ab"
568
+
hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44="
440
569
[mod."gitlab.com/yawning/secp256k1-voi"]
441
570
version = "v0.0.0-20230925100816-f2616030848b"
442
571
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
443
572
[mod."gitlab.com/yawning/tuplehash"]
444
573
version = "v0.0.0-20230713102510-df83abbf9a02"
445
574
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
575
+
[mod."go.etcd.io/bbolt"]
576
+
version = "v1.4.0"
577
+
hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
446
578
[mod."go.opentelemetry.io/auto/sdk"]
447
579
version = "v1.1.0"
448
580
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
···
479
611
[mod."golang.org/x/exp"]
480
612
version = "v0.0.0-20250620022241-b7579e27df2b"
481
613
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
614
+
[mod."golang.org/x/image"]
615
+
version = "v0.31.0"
616
+
hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg="
482
617
[mod."golang.org/x/net"]
483
618
version = "v0.42.0"
484
619
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
485
620
[mod."golang.org/x/sync"]
486
-
version = "v0.16.0"
487
-
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
621
+
version = "v0.17.0"
622
+
hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0="
488
623
[mod."golang.org/x/sys"]
489
624
version = "v0.34.0"
490
625
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
491
626
[mod."golang.org/x/text"]
492
-
version = "v0.27.0"
493
-
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
627
+
version = "v0.29.0"
628
+
hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI="
494
629
[mod."golang.org/x/time"]
495
630
version = "v0.12.0"
496
631
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
527
662
[mod."lukechampine.com/blake3"]
528
663
version = "v1.4.1"
529
664
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="
+285
-18
nix/modules/appview.nix
+285
-18
nix/modules/appview.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled-appview;
6
+
cfg = config.services.tangled.appview;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled-appview = {
10
+
services.tangled.appview = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
14
14
description = "Enable tangled appview";
15
15
};
16
+
16
17
package = mkOption {
17
18
type = types.package;
18
19
description = "Package to use for the appview";
19
20
};
21
+
22
+
# core configuration
20
23
port = mkOption {
21
-
type = types.int;
24
+
type = types.port;
22
25
default = 3000;
23
26
description = "Port to run the appview on";
24
27
};
25
-
cookie_secret = mkOption {
28
+
29
+
listenAddr = mkOption {
30
+
type = types.str;
31
+
default = "0.0.0.0:${toString cfg.port}";
32
+
description = "Listen address for the appview service";
33
+
};
34
+
35
+
dbPath = mkOption {
26
36
type = types.str;
27
-
default = "00000000000000000000000000000000";
28
-
description = "Cookie secret";
37
+
default = "/var/lib/appview/appview.db";
38
+
description = "Path to the SQLite database file";
39
+
};
40
+
41
+
appviewHost = mkOption {
42
+
type = types.str;
43
+
default = "https://tangled.org";
44
+
example = "https://example.com";
45
+
description = "Public host URL for the appview instance";
46
+
};
47
+
48
+
appviewName = mkOption {
49
+
type = types.str;
50
+
default = "Tangled";
51
+
description = "Display name for the appview instance";
52
+
};
53
+
54
+
dev = mkOption {
55
+
type = types.bool;
56
+
default = false;
57
+
description = "Enable development mode";
58
+
};
59
+
60
+
disallowedNicknamesFile = mkOption {
61
+
type = types.nullOr types.path;
62
+
default = null;
63
+
description = "Path to file containing disallowed nicknames";
64
+
};
65
+
66
+
# redis configuration
67
+
redis = {
68
+
addr = mkOption {
69
+
type = types.str;
70
+
default = "localhost:6379";
71
+
description = "Redis server address";
72
+
};
73
+
74
+
db = mkOption {
75
+
type = types.int;
76
+
default = 0;
77
+
description = "Redis database number";
78
+
};
79
+
};
80
+
81
+
# jetstream configuration
82
+
jetstream = {
83
+
endpoint = mkOption {
84
+
type = types.str;
85
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
86
+
description = "Jetstream WebSocket endpoint";
87
+
};
88
+
};
89
+
90
+
# knotstream consumer configuration
91
+
knotstream = {
92
+
retryInterval = mkOption {
93
+
type = types.str;
94
+
default = "60s";
95
+
description = "Initial retry interval for knotstream consumer";
96
+
};
97
+
98
+
maxRetryInterval = mkOption {
99
+
type = types.str;
100
+
default = "120m";
101
+
description = "Maximum retry interval for knotstream consumer";
102
+
};
103
+
104
+
connectionTimeout = mkOption {
105
+
type = types.str;
106
+
default = "5s";
107
+
description = "Connection timeout for knotstream consumer";
108
+
};
109
+
110
+
workerCount = mkOption {
111
+
type = types.int;
112
+
default = 64;
113
+
description = "Number of workers for knotstream consumer";
114
+
};
115
+
116
+
queueSize = mkOption {
117
+
type = types.int;
118
+
default = 100;
119
+
description = "Queue size for knotstream consumer";
120
+
};
121
+
};
122
+
123
+
# spindlestream consumer configuration
124
+
spindlestream = {
125
+
retryInterval = mkOption {
126
+
type = types.str;
127
+
default = "60s";
128
+
description = "Initial retry interval for spindlestream consumer";
129
+
};
130
+
131
+
maxRetryInterval = mkOption {
132
+
type = types.str;
133
+
default = "120m";
134
+
description = "Maximum retry interval for spindlestream consumer";
135
+
};
136
+
137
+
connectionTimeout = mkOption {
138
+
type = types.str;
139
+
default = "5s";
140
+
description = "Connection timeout for spindlestream consumer";
141
+
};
142
+
143
+
workerCount = mkOption {
144
+
type = types.int;
145
+
default = 64;
146
+
description = "Number of workers for spindlestream consumer";
147
+
};
148
+
149
+
queueSize = mkOption {
150
+
type = types.int;
151
+
default = 100;
152
+
description = "Queue size for spindlestream consumer";
153
+
};
154
+
};
155
+
156
+
# resend configuration
157
+
resend = {
158
+
sentFrom = mkOption {
159
+
type = types.str;
160
+
default = "noreply@notifs.tangled.sh";
161
+
description = "Email address to send notifications from";
162
+
};
163
+
};
164
+
165
+
# posthog configuration
166
+
posthog = {
167
+
endpoint = mkOption {
168
+
type = types.str;
169
+
default = "https://eu.i.posthog.com";
170
+
description = "PostHog API endpoint";
171
+
};
172
+
};
173
+
174
+
# camo configuration
175
+
camo = {
176
+
host = mkOption {
177
+
type = types.str;
178
+
default = "https://camo.tangled.sh";
179
+
description = "Camo proxy host URL";
180
+
};
29
181
};
182
+
183
+
# avatar configuration
184
+
avatar = {
185
+
host = mkOption {
186
+
type = types.str;
187
+
default = "https://avatar.tangled.sh";
188
+
description = "Avatar service host URL";
189
+
};
190
+
};
191
+
192
+
plc = {
193
+
url = mkOption {
194
+
type = types.str;
195
+
default = "https://plc.directory";
196
+
description = "PLC directory URL";
197
+
};
198
+
};
199
+
200
+
pds = {
201
+
host = mkOption {
202
+
type = types.str;
203
+
default = "https://tngl.sh";
204
+
description = "PDS host URL";
205
+
};
206
+
};
207
+
208
+
label = {
209
+
defaults = mkOption {
210
+
type = types.listOf types.str;
211
+
default = [
212
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
213
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
214
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
215
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
216
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
217
+
];
218
+
description = "Default label definitions";
219
+
};
220
+
221
+
goodFirstIssue = mkOption {
222
+
type = types.str;
223
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
224
+
description = "Good first issue label definition";
225
+
};
226
+
};
227
+
30
228
environmentFile = mkOption {
31
229
type = with types; nullOr path;
32
230
default = null;
33
-
example = "/etc/tangled-appview.env";
231
+
example = "/etc/appview.env";
34
232
description = ''
35
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
234
37
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
-
passed to the service without makeing them world readable in the
39
-
nix store.
40
-
235
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
236
+
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
237
+
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
238
+
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
239
+
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
240
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
241
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
242
+
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
243
+
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
244
+
without making them world readable in the nix store.
41
245
'';
42
246
};
43
247
};
44
248
};
45
249
46
250
config = mkIf cfg.enable {
47
-
systemd.services.tangled-appview = {
251
+
services.redis.servers.appview = {
252
+
enable = true;
253
+
port = 6379;
254
+
};
255
+
256
+
systemd.services.appview = {
48
257
description = "tangled appview service";
49
258
wantedBy = ["multi-user.target"];
259
+
after = ["redis-appview.service" "network-online.target"];
260
+
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
50
262
51
263
serviceConfig = {
52
-
ListenStream = "0.0.0.0:${toString cfg.port}";
264
+
Type = "simple";
53
265
ExecStart = "${cfg.package}/bin/appview";
54
266
Restart = "always";
55
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
267
+
RestartSec = "10s";
268
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
269
+
270
+
# state directory
271
+
StateDirectory = "appview";
272
+
WorkingDirectory = "/var/lib/appview";
273
+
274
+
# security hardening
275
+
NoNewPrivileges = true;
276
+
PrivateTmp = true;
277
+
ProtectSystem = "strict";
278
+
ProtectHome = true;
279
+
ReadWritePaths = ["/var/lib/appview"];
56
280
};
57
281
58
-
environment = {
59
-
TANGLED_DB_PATH = "appview.db";
60
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
-
};
282
+
environment =
283
+
{
284
+
TANGLED_DB_PATH = cfg.dbPath;
285
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
286
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
287
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
288
+
TANGLED_DEV =
289
+
if cfg.dev
290
+
then "true"
291
+
else "false";
292
+
}
293
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
294
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
295
+
}
296
+
// {
297
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
298
+
TANGLED_REDIS_DB = toString cfg.redis.db;
299
+
300
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
301
+
302
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
303
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
304
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
305
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
306
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
307
+
308
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
309
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
310
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
311
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
312
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
313
+
314
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
315
+
316
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
317
+
318
+
TANGLED_CAMO_HOST = cfg.camo.host;
319
+
320
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
321
+
322
+
TANGLED_PLC_URL = cfg.plc.url;
323
+
324
+
TANGLED_PDS_HOST = cfg.pds.host;
325
+
326
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
327
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
328
+
};
62
329
};
63
330
};
64
331
}
+76
-6
nix/modules/knot.nix
+76
-6
nix/modules/knot.nix
···
4
4
lib,
5
5
...
6
6
}: let
7
-
cfg = config.services.tangled-knot;
7
+
cfg = config.services.tangled.knot;
8
8
in
9
9
with lib; {
10
10
options = {
11
-
services.tangled-knot = {
11
+
services.tangled.knot = {
12
12
enable = mkOption {
13
13
type = types.bool;
14
14
default = false;
···
22
22
23
23
appviewEndpoint = mkOption {
24
24
type = types.str;
25
-
default = "https://tangled.sh";
25
+
default = "https://tangled.org";
26
26
description = "Appview endpoint";
27
27
};
28
28
···
51
51
description = "Path where repositories are scanned from";
52
52
};
53
53
54
+
readme = mkOption {
55
+
type = types.listOf types.str;
56
+
default = [
57
+
"README.md"
58
+
"readme.md"
59
+
"README"
60
+
"readme"
61
+
"README.markdown"
62
+
"readme.markdown"
63
+
"README.txt"
64
+
"readme.txt"
65
+
"README.rst"
66
+
"readme.rst"
67
+
"README.org"
68
+
"readme.org"
69
+
"README.asciidoc"
70
+
"readme.asciidoc"
71
+
];
72
+
description = "List of README filenames to look for (in priority order)";
73
+
};
74
+
54
75
mainBranch = mkOption {
55
76
type = types.str;
56
77
default = "main";
57
78
description = "Default branch name for repositories";
79
+
};
80
+
};
81
+
82
+
git = {
83
+
userName = mkOption {
84
+
type = types.str;
85
+
default = "Tangled";
86
+
description = "Git user name used as committer";
87
+
};
88
+
89
+
userEmail = mkOption {
90
+
type = types.str;
91
+
default = "noreply@tangled.org";
92
+
description = "Git user email used as committer";
58
93
};
59
94
};
60
95
···
107
142
108
143
hostname = mkOption {
109
144
type = types.str;
110
-
example = "knot.tangled.sh";
145
+
example = "my.knot.com";
111
146
description = "Hostname for the server (required)";
147
+
};
148
+
149
+
plcUrl = mkOption {
150
+
type = types.str;
151
+
default = "https://plc.directory";
152
+
description = "atproto PLC directory";
153
+
};
154
+
155
+
jetstreamEndpoint = mkOption {
156
+
type = types.str;
157
+
default = "wss://jetstream1.us-west.bsky.network/subscribe";
158
+
description = "Jetstream endpoint to subscribe to";
159
+
};
160
+
161
+
logDids = mkOption {
162
+
type = types.bool;
163
+
default = true;
164
+
description = "Enable logging of DIDs";
112
165
};
113
166
114
167
dev = mkOption {
···
178
231
mkdir -p "${cfg.stateDir}/.config/git"
179
232
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
233
[user]
181
-
name = Git User
182
-
email = git@example.com
234
+
name = ${cfg.git.userName}
235
+
email = ${cfg.git.userEmail}
183
236
[receive]
184
237
advertisePushOptions = true
238
+
[uploadpack]
239
+
allowFilter = true
185
240
EOF
186
241
${setMotd}
187
242
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
193
248
WorkingDirectory = cfg.stateDir;
194
249
Environment = [
195
250
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
196
252
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
197
255
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
198
256
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
199
257
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
258
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
259
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
260
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
261
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
202
262
"KNOT_SERVER_OWNER=${cfg.server.owner}"
263
+
"KNOT_SERVER_LOG_DIDS=${
264
+
if cfg.server.logDids
265
+
then "true"
266
+
else "false"
267
+
}"
268
+
"KNOT_SERVER_DEV=${
269
+
if cfg.server.dev
270
+
then "true"
271
+
else "false"
272
+
}"
203
273
];
204
274
ExecStart = "${cfg.package}/bin/knot server";
205
275
Restart = "always";
+12
-5
nix/modules/spindle.nix
+12
-5
nix/modules/spindle.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled-spindle;
6
+
cfg = config.services.tangled.spindle;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled-spindle = {
10
+
services.tangled.spindle = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
···
33
33
34
34
hostname = mkOption {
35
35
type = types.str;
36
-
example = "spindle.tangled.sh";
36
+
example = "my.spindle.com";
37
37
description = "Hostname for the server (required)";
38
38
};
39
39
40
+
plcUrl = mkOption {
41
+
type = types.str;
42
+
default = "https://plc.directory";
43
+
description = "atproto PLC directory";
44
+
};
45
+
40
46
jetstreamEndpoint = mkOption {
41
47
type = types.str;
42
48
default = "wss://jetstream1.us-west.bsky.network/subscribe";
···
92
98
pipelines = {
93
99
nixery = mkOption {
94
100
type = types.str;
95
-
default = "nixery.tangled.sh";
101
+
default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
96
102
description = "Nixery instance to use";
97
103
};
98
104
···
119
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
120
126
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
121
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
122
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
128
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
123
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
124
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
132
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+3
nix/pkgs/appview-static-files.nix
+3
nix/pkgs/appview-static-files.nix
···
5
5
lucide-src,
6
6
inter-fonts-src,
7
7
ibm-plex-mono-src,
8
+
actor-typeahead-src,
8
9
sqlite-lib,
9
10
tailwindcss,
10
11
src,
···
22
23
cp -rf ${lucide-src}/*.svg icons/
23
24
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
26
+
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
25
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
26
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
30
# for whatever reason (produces broken css), so we are doing this instead
28
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
-18
nix/pkgs/genjwks.nix
-18
nix/pkgs/genjwks.nix
···
1
-
{
2
-
buildGoApplication,
3
-
modules,
4
-
}:
5
-
buildGoApplication {
6
-
pname = "genjwks";
7
-
version = "0.1.0";
8
-
src = ../../cmd/genjwks;
9
-
postPatch = ''
10
-
ln -s ${../../go.mod} ./go.mod
11
-
'';
12
-
postInstall = ''
13
-
mv $out/bin/core $out/bin/genjwks
14
-
'';
15
-
inherit modules;
16
-
doCheck = false;
17
-
CGO_ENABLED = 0;
18
-
}
+12
nix/pkgs/goat.nix
+12
nix/pkgs/goat.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+21
-8
nix/vm.nix
+21
-8
nix/vm.nix
···
10
10
if var == ""
11
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
12
else var;
13
+
envVarOr = name: default: let
14
+
var = builtins.getEnv name;
15
+
in
16
+
if var != ""
17
+
then var
18
+
else default;
19
+
20
+
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
21
+
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
13
22
in
14
23
nixpkgs.lib.nixosSystem {
15
24
inherit system;
···
73
82
time.timeZone = "Europe/London";
74
83
services.getty.autologinUser = "root";
75
84
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
-
services.tangled-knot = {
85
+
services.tangled.knot = {
77
86
enable = true;
78
87
motd = "Welcome to the development knot!\n";
79
88
server = {
80
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
81
-
hostname = "localhost:6000";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
+
plcUrl = plcUrl;
92
+
jetstreamEndpoint = jetstream;
82
93
listenAddr = "0.0.0.0:6000";
83
94
};
84
95
};
85
-
services.tangled-spindle = {
96
+
services.tangled.spindle = {
86
97
enable = true;
87
98
server = {
88
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
89
-
hostname = "localhost:6555";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
+
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
90
103
listenAddr = "0.0.0.0:6555";
91
104
dev = true;
92
105
queueSize = 100;
···
99
112
users = {
100
113
# So we don't have to deal with permission clashing between
101
114
# blank disk VMs and existing state
102
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
115
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
104
117
105
118
# TODO: separate spindle user
106
119
};
···
120
133
serviceConfig.PermissionsStartOnly = true;
121
134
};
122
135
in {
123
-
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
124
-
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
136
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
137
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
125
138
};
126
139
})
127
140
];
+18
-7
patchutil/patchutil.go
+18
-7
patchutil/patchutil.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
6
"log"
6
7
"os"
···
42
43
// IsPatchValid checks if the given patch string is valid.
43
44
// It performs very basic sniffing for either git-diff or git-format-patch
44
45
// header lines. For format patches, it attempts to extract and validate each one.
45
-
func IsPatchValid(patch string) bool {
46
+
var (
47
+
EmptyPatchError error = errors.New("patch is empty")
48
+
GenericPatchError error = errors.New("patch is invalid")
49
+
FormatPatchError error = errors.New("patch is not a valid format-patch")
50
+
)
51
+
52
+
func IsPatchValid(patch string) error {
46
53
if len(patch) == 0 {
47
-
return false
54
+
return EmptyPatchError
48
55
}
49
56
50
57
lines := strings.Split(patch, "\n")
51
58
if len(lines) < 2 {
52
-
return false
59
+
return EmptyPatchError
53
60
}
54
61
55
62
firstLine := strings.TrimSpace(lines[0])
···
60
67
strings.HasPrefix(firstLine, "Index: ") ||
61
68
strings.HasPrefix(firstLine, "+++ ") ||
62
69
strings.HasPrefix(firstLine, "@@ ") {
63
-
return true
70
+
return nil
64
71
}
65
72
66
73
// check if it's format-patch
···
70
77
// it's safe to say it's broken.
71
78
patches, err := ExtractPatches(patch)
72
79
if err != nil {
73
-
return false
80
+
return fmt.Errorf("%w: %w", FormatPatchError, err)
74
81
}
75
-
return len(patches) > 0
82
+
if len(patches) == 0 {
83
+
return EmptyPatchError
84
+
}
85
+
86
+
return nil
76
87
}
77
88
78
-
return false
89
+
return GenericPatchError
79
90
}
80
91
81
92
func IsFormatPatch(patch string) bool {
+13
-12
patchutil/patchutil_test.go
+13
-12
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"reflect"
5
6
"testing"
6
7
)
···
9
10
tests := []struct {
10
11
name string
11
12
patch string
12
-
expected bool
13
+
expected error
13
14
}{
14
15
{
15
16
name: `empty patch`,
16
17
patch: ``,
17
-
expected: false,
18
+
expected: EmptyPatchError,
18
19
},
19
20
{
20
21
name: `single line patch`,
21
22
patch: `single line`,
22
-
expected: false,
23
+
expected: EmptyPatchError,
23
24
},
24
25
{
25
26
name: `valid diff patch`,
···
31
32
-old line
32
33
+new line
33
34
context`,
34
-
expected: true,
35
+
expected: nil,
35
36
},
36
37
{
37
38
name: `valid patch starting with ---`,
···
41
42
-old line
42
43
+new line
43
44
context`,
44
-
expected: true,
45
+
expected: nil,
45
46
},
46
47
{
47
48
name: `valid patch starting with Index`,
···
53
54
-old line
54
55
+new line
55
56
context`,
56
-
expected: true,
57
+
expected: nil,
57
58
},
58
59
{
59
60
name: `valid patch starting with +++`,
···
63
64
-old line
64
65
+new line
65
66
context`,
66
-
expected: true,
67
+
expected: nil,
67
68
},
68
69
{
69
70
name: `valid patch starting with @@`,
···
72
73
+new line
73
74
context
74
75
`,
75
-
expected: true,
76
+
expected: nil,
76
77
},
77
78
{
78
79
name: `valid format patch`,
···
90
91
+new content
91
92
--
92
93
2.48.1`,
93
-
expected: true,
94
+
expected: nil,
94
95
},
95
96
{
96
97
name: `invalid format patch`,
97
98
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
99
From: Author <author@example.com>
99
100
This is not a valid patch format`,
100
-
expected: false,
101
+
expected: FormatPatchError,
101
102
},
102
103
{
103
104
name: `not a patch at all`,
···
105
106
just some
106
107
random text
107
108
that isn't a patch`,
108
-
expected: false,
109
+
expected: GenericPatchError,
109
110
},
110
111
}
111
112
112
113
for _, tt := range tests {
113
114
t.Run(tt.name, func(t *testing.T) {
114
115
result := IsPatchValid(tt.patch)
115
-
if result != tt.expected {
116
+
if !errors.Is(result, tt.expected) {
116
117
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
118
}
118
119
})
-26
scripts/appview.sh
-26
scripts/appview.sh
···
1
-
#!/bin/bash
2
-
3
-
# Variables
4
-
BINARY_NAME="appview"
5
-
BINARY_PATH=".bin/app"
6
-
SERVER="95.111.206.63"
7
-
USER="appview"
8
-
9
-
# SCP the binary to root's home directory
10
-
scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME"
11
-
12
-
# SSH into the server and perform the necessary operations
13
-
ssh root@$SERVER <<EOF
14
-
set -e # Exit on error
15
-
16
-
# Move binary to /usr/local/bin and set executable permissions
17
-
mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME
18
-
chmod +x /usr/local/bin/$BINARY_NAME
19
-
20
-
su appview
21
-
cd ~
22
-
./reset.sh
23
-
EOF
24
-
25
-
echo "Deployment complete."
26
-
-5
scripts/generate-jwks.sh
-5
scripts/generate-jwks.sh
+1
spindle/config/config.go
+1
spindle/config/config.go
···
13
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
14
Hostname string `env:"HOSTNAME, required"`
15
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
16
17
Dev bool `env:"DEV, default=false"`
17
18
Owner string `env:"OWNER, required"`
18
19
Secrets Secrets `env:",prefix=SECRETS_"`
+13
-3
spindle/engine/engine.go
+13
-3
spindle/engine/engine.go
···
79
79
defer cancel()
80
80
81
81
for stepIdx, step := range w.Steps {
82
+
// log start of step
82
83
if wfLogger != nil {
83
-
ctl := wfLogger.ControlWriter(stepIdx, step)
84
-
ctl.Write([]byte(step.Name()))
84
+
wfLogger.
85
+
ControlWriter(stepIdx, step, models.StepStatusStart).
86
+
Write([]byte{0})
85
87
}
86
88
87
89
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
90
+
91
+
// log end of step
92
+
if wfLogger != nil {
93
+
wfLogger.
94
+
ControlWriter(stepIdx, step, models.StepStatusEnd).
95
+
Write([]byte{0})
96
+
}
97
+
88
98
if err != nil {
89
99
if errors.Is(err, ErrTimedOut) {
90
100
dbErr := db.StatusTimeout(wid, n)
···
115
125
if err := eg.Wait(); err != nil {
116
126
l.Error("failed to run one or more workflows", "err", err)
117
127
} else {
118
-
l.Error("successfully ran full pipeline")
128
+
l.Info("successfully ran full pipeline")
119
129
}
120
130
}
+3
-3
spindle/engines/nixery/engine.go
+3
-3
spindle/engines/nixery/engine.go
···
222
222
},
223
223
ReadonlyRootfs: false,
224
224
CapDrop: []string{"ALL"},
225
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
226
226
SecurityOpt: []string{"no-new-privileges"},
227
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
228
}, nil, nil, "")
···
381
381
defer logs.Close()
382
382
383
383
_, err = stdcopy.StdCopy(
384
-
wfLogger.DataWriter("stdout"),
385
-
wfLogger.DataWriter("stderr"),
384
+
wfLogger.DataWriter(stepIdx, "stdout"),
385
+
wfLogger.DataWriter(stepIdx, "stderr"),
386
386
logs.Reader,
387
387
)
388
388
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3
-7
spindle/ingester.go
+3
-7
spindle/ingester.go
···
9
9
10
10
"tangled.org/core/api/tangled"
11
11
"tangled.org/core/eventconsumer"
12
-
"tangled.org/core/idresolver"
13
12
"tangled.org/core/rbac"
14
13
"tangled.org/core/spindle/db"
15
14
···
142
141
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
143
142
var err error
144
143
did := e.Did
145
-
resolver := idresolver.DefaultResolver()
146
144
147
145
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
146
···
190
188
}
191
189
192
190
// add collaborators to rbac
193
-
owner, err := resolver.ResolveIdent(ctx, did)
191
+
owner, err := s.res.ResolveIdent(ctx, did)
194
192
if err != nil || owner.Handle.IsInvalidHandle() {
195
193
return err
196
194
}
···
225
223
return err
226
224
}
227
225
228
-
resolver := idresolver.DefaultResolver()
229
-
230
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
231
227
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
228
return err
233
229
}
···
240
236
241
237
// TODO: get rid of this entirely
242
238
// resolve this aturi to extract the repo record
243
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
244
240
if err != nil || owner.Handle.IsInvalidHandle() {
245
241
return fmt.Errorf("failed to resolve handle: %w", err)
246
242
}
+35
spindle/middleware.go
+35
spindle/middleware.go
···
1
+
package spindle
2
+
3
+
import (
4
+
"log/slog"
5
+
"net/http"
6
+
"time"
7
+
)
8
+
9
+
func (s *Spindle) RequestLogger(next http.Handler) http.Handler {
10
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11
+
start := time.Now()
12
+
13
+
next.ServeHTTP(w, r)
14
+
15
+
// Build query params as slog.Attrs for the group
16
+
queryParams := r.URL.Query()
17
+
queryAttrs := make([]any, 0, len(queryParams))
18
+
for key, values := range queryParams {
19
+
if len(values) == 1 {
20
+
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
21
+
} else {
22
+
queryAttrs = append(queryAttrs, slog.Any(key, values))
23
+
}
24
+
}
25
+
26
+
s.l.LogAttrs(r.Context(), slog.LevelInfo, "",
27
+
slog.Group("request",
28
+
slog.String("method", r.Method),
29
+
slog.String("path", r.URL.Path),
30
+
slog.Group("query", queryAttrs...),
31
+
slog.Duration("duration", time.Since(start)),
32
+
),
33
+
)
34
+
})
35
+
}
+14
-11
spindle/models/logger.go
+14
-11
spindle/models/logger.go
···
37
37
return l.file.Close()
38
38
}
39
39
40
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
41
-
// TODO: emit stream
40
+
func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer {
42
41
return &dataWriter{
43
42
logger: l,
43
+
idx: idx,
44
44
stream: stream,
45
45
}
46
46
}
47
47
48
-
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer {
49
49
return &controlWriter{
50
-
logger: l,
51
-
idx: idx,
52
-
step: step,
50
+
logger: l,
51
+
idx: idx,
52
+
step: step,
53
+
stepStatus: stepStatus,
53
54
}
54
55
}
55
56
56
57
type dataWriter struct {
57
58
logger *WorkflowLogger
59
+
idx int
58
60
stream string
59
61
}
60
62
61
63
func (w *dataWriter) Write(p []byte) (int, error) {
62
64
line := strings.TrimRight(string(p), "\r\n")
63
-
entry := NewDataLogLine(line, w.stream)
65
+
entry := NewDataLogLine(w.idx, line, w.stream)
64
66
if err := w.logger.encoder.Encode(entry); err != nil {
65
67
return 0, err
66
68
}
···
68
70
}
69
71
70
72
type controlWriter struct {
71
-
logger *WorkflowLogger
72
-
idx int
73
-
step Step
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step Step
76
+
stepStatus StepStatus
74
77
}
75
78
76
79
func (w *controlWriter) Write(_ []byte) (int, error) {
77
-
entry := NewControlLogLine(w.idx, w.step)
80
+
entry := NewControlLogLine(w.idx, w.step, w.stepStatus)
78
81
if err := w.logger.encoder.Encode(entry); err != nil {
79
82
return 0, err
80
83
}
+23
-8
spindle/models/models.go
+23
-8
spindle/models/models.go
···
4
4
"fmt"
5
5
"regexp"
6
6
"slices"
7
+
"time"
7
8
8
9
"tangled.org/core/api/tangled"
9
10
···
76
77
var (
77
78
// step log data
78
79
LogKindData LogKind = "data"
79
-
// indicates start/end of a step
80
+
// indicates status of a step
80
81
LogKindControl LogKind = "control"
81
82
)
82
83
84
+
// step status indicator in control log lines
85
+
type StepStatus string
86
+
87
+
var (
88
+
StepStatusStart StepStatus = "start"
89
+
StepStatusEnd StepStatus = "end"
90
+
)
91
+
83
92
type LogLine struct {
84
-
Kind LogKind `json:"kind"`
85
-
Content string `json:"content"`
93
+
Kind LogKind `json:"kind"`
94
+
Content string `json:"content"`
95
+
Time time.Time `json:"time"`
96
+
StepId int `json:"step_id"`
86
97
87
98
// fields if kind is "data"
88
99
Stream string `json:"stream,omitempty"`
89
100
90
101
// fields if kind is "control"
91
-
StepId int `json:"step_id,omitempty"`
92
-
StepKind StepKind `json:"step_kind,omitempty"`
93
-
StepCommand string `json:"step_command,omitempty"`
102
+
StepStatus StepStatus `json:"step_status,omitempty"`
103
+
StepKind StepKind `json:"step_kind,omitempty"`
104
+
StepCommand string `json:"step_command,omitempty"`
94
105
}
95
106
96
-
func NewDataLogLine(content, stream string) LogLine {
107
+
func NewDataLogLine(idx int, content, stream string) LogLine {
97
108
return LogLine{
98
109
Kind: LogKindData,
110
+
Time: time.Now(),
99
111
Content: content,
112
+
StepId: idx,
100
113
Stream: stream,
101
114
}
102
115
}
103
116
104
-
func NewControlLogLine(idx int, step Step) LogLine {
117
+
func NewControlLogLine(idx int, step Step, status StepStatus) LogLine {
105
118
return LogLine{
106
119
Kind: LogKindControl,
120
+
Time: time.Now(),
107
121
Content: step.Name(),
108
122
StepId: idx,
123
+
StepStatus: status,
109
124
StepKind: step.Kind(),
110
125
StepCommand: step.Command(),
111
126
}
+92
-47
spindle/server.go
+92
-47
spindle/server.go
···
49
49
vault secrets.Manager
50
50
}
51
51
52
-
func Run(ctx context.Context) error {
52
+
// New creates a new Spindle server with the provided configuration and engines.
53
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
53
54
logger := log.FromContext(ctx)
54
55
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
-
60
56
d, err := db.Make(cfg.Server.DBPath)
61
57
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
58
+
return nil, fmt.Errorf("failed to setup db: %w", err)
63
59
}
64
60
65
61
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
62
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
63
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
64
}
69
65
e.E.EnableAutoSave(true)
70
66
···
74
70
switch cfg.Server.Secrets.Provider {
75
71
case "openbao":
76
72
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
73
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
74
}
79
75
vault, err = secrets.NewOpenBaoManager(
80
76
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
78
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
79
)
84
80
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
81
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
82
}
87
83
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
84
case "sqlite", "":
89
85
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
86
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
87
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
88
}
93
89
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
90
default:
95
-
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
96
-
}
97
-
98
-
nixeryEng, err := nixery.New(ctx, cfg)
99
-
if err != nil {
100
-
return err
91
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
101
92
}
102
93
103
94
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
108
99
tangled.RepoNSID,
109
100
tangled.RepoCollaboratorNSID,
110
101
}
111
-
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
102
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
103
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
104
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
114
105
}
115
106
jc.AddDid(cfg.Server.Owner)
116
107
117
108
// Check if the spindle knows about any Dids;
118
109
dids, err := d.GetAllDids()
119
110
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
111
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
121
112
}
122
113
for _, d := range dids {
123
114
jc.AddDid(d)
124
115
}
125
116
126
-
resolver := idresolver.DefaultResolver()
117
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
127
118
128
-
spindle := Spindle{
119
+
spindle := &Spindle{
129
120
jc: jc,
130
121
e: e,
131
122
db: d,
132
123
l: logger,
133
124
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
125
+
engs: engines,
135
126
jq: jq,
136
127
cfg: cfg,
137
128
res: resolver,
···
140
131
141
132
err = e.AddSpindle(rbacDomain)
142
133
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
134
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
144
135
}
145
136
err = spindle.configureOwner()
146
137
if err != nil {
147
-
return err
138
+
return nil, err
148
139
}
149
140
logger.Info("owner set", "did", cfg.Server.Owner)
150
141
151
-
// starts a job queue runner in the background
152
-
jq.Start()
153
-
defer jq.Stop()
154
-
155
-
// Stop vault token renewal if it implements Stopper
156
-
if stopper, ok := vault.(secrets.Stopper); ok {
157
-
defer stopper.Stop()
158
-
}
159
-
160
142
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
143
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
144
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
145
}
164
146
165
147
err = jc.StartJetstream(ctx, spindle.ingest())
166
148
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
149
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
168
150
}
169
151
170
152
// for each incoming sh.tangled.pipeline, we execute
171
153
// spindle.processPipeline, which in turn enqueues the pipeline
172
154
// job in the above registered queue.
173
155
ccfg := eventconsumer.NewConsumerConfig()
174
-
ccfg.Logger = logger
156
+
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
175
157
ccfg.Dev = cfg.Server.Dev
176
158
ccfg.ProcessFunc = spindle.processPipeline
177
159
ccfg.CursorStore = cursorStore
178
160
knownKnots, err := d.Knots()
179
161
if err != nil {
180
-
return err
162
+
return nil, err
181
163
}
182
164
for _, knot := range knownKnots {
183
165
logger.Info("adding source start", "knot", knot)
···
185
167
}
186
168
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
169
170
+
return spindle, nil
171
+
}
172
+
173
+
// DB returns the database instance.
174
+
func (s *Spindle) DB() *db.DB {
175
+
return s.db
176
+
}
177
+
178
+
// Queue returns the job queue instance.
179
+
func (s *Spindle) Queue() *queue.Queue {
180
+
return s.jq
181
+
}
182
+
183
+
// Engines returns the map of available engines.
184
+
func (s *Spindle) Engines() map[string]models.Engine {
185
+
return s.engs
186
+
}
187
+
188
+
// Vault returns the secrets manager instance.
189
+
func (s *Spindle) Vault() secrets.Manager {
190
+
return s.vault
191
+
}
192
+
193
+
// Notifier returns the notifier instance.
194
+
func (s *Spindle) Notifier() *notifier.Notifier {
195
+
return s.n
196
+
}
197
+
198
+
// Enforcer returns the RBAC enforcer instance.
199
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
200
+
return s.e
201
+
}
202
+
203
+
// Start starts the Spindle server (blocking).
204
+
func (s *Spindle) Start(ctx context.Context) error {
205
+
// starts a job queue runner in the background
206
+
s.jq.Start()
207
+
defer s.jq.Stop()
208
+
209
+
// Stop vault token renewal if it implements Stopper
210
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
211
+
defer stopper.Stop()
212
+
}
213
+
188
214
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
215
+
s.l.Info("starting knot event consumer")
216
+
s.ks.Start(ctx)
191
217
}()
192
218
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
219
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
220
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
221
+
}
222
+
223
+
func Run(ctx context.Context) error {
224
+
cfg, err := config.Load(ctx)
225
+
if err != nil {
226
+
return fmt.Errorf("failed to load config: %w", err)
227
+
}
228
+
229
+
nixeryEng, err := nixery.New(ctx, cfg)
230
+
if err != nil {
231
+
return err
232
+
}
233
+
234
+
s, err := New(ctx, cfg, map[string]models.Engine{
235
+
"nixery": nixeryEng,
236
+
})
237
+
if err != nil {
238
+
return err
239
+
}
195
240
196
-
return nil
241
+
return s.Start(ctx)
197
242
}
198
243
199
244
func (s *Spindle) Router() http.Handler {
···
210
255
}
211
256
212
257
func (s *Spindle) XrpcRouter() http.Handler {
213
-
logger := s.l.With("route", "xrpc")
214
-
215
258
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
216
259
260
+
l := log.SubLogger(s.l, "xrpc")
261
+
217
262
x := xrpc.Xrpc{
218
-
Logger: logger,
263
+
Logger: l,
219
264
Db: s.db,
220
265
Enforcer: s.e,
221
266
Engines: s.engs,
···
305
350
306
351
ok := s.jq.Enqueue(queue.Job{
307
352
Run: func() error {
308
-
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
353
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
309
354
RepoOwner: tpl.TriggerMetadata.Repo.Did,
310
355
RepoName: tpl.TriggerMetadata.Repo.Repo,
311
356
Workflows: workflows,
+8
-3
spindle/stream.go
+8
-3
spindle/stream.go
···
10
10
"strconv"
11
11
"time"
12
12
13
+
"tangled.org/core/log"
13
14
"tangled.org/core/spindle/models"
14
15
15
16
"github.com/go-chi/chi/v5"
···
23
24
}
24
25
25
26
func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) {
26
-
l := s.l.With("handler", "Events")
27
+
l := log.SubLogger(s.l, "eventstream")
28
+
27
29
l.Debug("received new connection")
28
30
29
31
conn, err := upgrader.Upgrade(w, r, nil)
···
82
84
}
83
85
case <-time.After(30 * time.Second):
84
86
// send a keep-alive
85
-
l.Debug("sent keepalive")
86
87
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
87
88
l.Error("failed to write control", "err", err)
88
89
}
···
212
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
213
214
return fmt.Errorf("failed to write to websocket: %w", err)
214
215
}
216
+
case <-time.After(30 * time.Second):
217
+
// send a keep-alive
218
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
219
+
return fmt.Errorf("failed to write control: %w", err)
220
+
}
215
221
}
216
222
}
217
223
}
···
222
228
s.l.Debug("err", "err", err)
223
229
return err
224
230
}
225
-
s.l.Debug("ops", "ops", events)
226
231
227
232
for _, event := range events {
228
233
// first extract the inner json into a map
+14
-10
types/repo.go
+14
-10
types/repo.go
···
1
1
package types
2
2
3
3
import (
4
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
4
5
"github.com/go-git/go-git/v5/plumbing/object"
5
6
)
6
7
···
33
34
}
34
35
35
36
type RepoFormatPatchResponse struct {
36
-
Rev1 string `json:"rev1,omitempty"`
37
-
Rev2 string `json:"rev2,omitempty"`
38
-
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
39
-
MergeBase string `json:"merge_base,omitempty"` // deprecated
40
-
Patch string `json:"patch,omitempty"`
37
+
Rev1 string `json:"rev1,omitempty"`
38
+
Rev2 string `json:"rev2,omitempty"`
39
+
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
40
+
FormatPatchRaw string `json:"patch,omitempty"`
41
+
CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"`
42
+
CombinedPatchRaw string `json:"combined_patch_raw,omitempty"`
41
43
}
42
44
43
45
type RepoTreeResponse struct {
44
-
Ref string `json:"ref,omitempty"`
45
-
Parent string `json:"parent,omitempty"`
46
-
Description string `json:"description,omitempty"`
47
-
DotDot string `json:"dotdot,omitempty"`
48
-
Files []NiceTree `json:"files,omitempty"`
46
+
Ref string `json:"ref,omitempty"`
47
+
Parent string `json:"parent,omitempty"`
48
+
Description string `json:"description,omitempty"`
49
+
DotDot string `json:"dotdot,omitempty"`
50
+
Files []NiceTree `json:"files,omitempty"`
51
+
ReadmeFileName string `json:"readme_filename,omitempty"`
52
+
Readme string `json:"readme_contents,omitempty"`
49
53
}
50
54
51
55
type TagReference struct {
+9
-1
workflow/compile.go
+9
-1
workflow/compile.go
···
113
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
114
cw := &tangled.Pipeline_Workflow{}
115
115
116
-
if !w.Match(compiler.Trigger) {
116
+
matched, err := w.Match(compiler.Trigger)
117
+
if err != nil {
118
+
compiler.Diagnostics.AddError(
119
+
w.Name,
120
+
fmt.Errorf("failed to execute workflow: %w", err),
121
+
)
122
+
return nil
123
+
}
124
+
if !matched {
117
125
compiler.Diagnostics.AddWarning(
118
126
w.Name,
119
127
WorkflowSkipped,
+125
workflow/compile_test.go
+125
workflow/compile_test.go
···
95
95
assert.Len(t, c.Diagnostics.Errors, 1)
96
96
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
97
97
}
98
+
99
+
func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) {
100
+
wf := Workflow{
101
+
Name: ".tangled/workflows/branch_and_tag.yml",
102
+
When: []Constraint{
103
+
{
104
+
Event: []string{"push"},
105
+
Branch: []string{"main", "develop"},
106
+
Tag: []string{"v*"},
107
+
},
108
+
},
109
+
Engine: "nixery",
110
+
}
111
+
112
+
tests := []struct {
113
+
name string
114
+
trigger tangled.Pipeline_TriggerMetadata
115
+
shouldMatch bool
116
+
expectedCount int
117
+
}{
118
+
{
119
+
name: "matches main branch",
120
+
trigger: tangled.Pipeline_TriggerMetadata{
121
+
Kind: string(TriggerKindPush),
122
+
Push: &tangled.Pipeline_PushTriggerData{
123
+
Ref: "refs/heads/main",
124
+
OldSha: strings.Repeat("0", 40),
125
+
NewSha: strings.Repeat("f", 40),
126
+
},
127
+
},
128
+
shouldMatch: true,
129
+
expectedCount: 1,
130
+
},
131
+
{
132
+
name: "matches develop branch",
133
+
trigger: tangled.Pipeline_TriggerMetadata{
134
+
Kind: string(TriggerKindPush),
135
+
Push: &tangled.Pipeline_PushTriggerData{
136
+
Ref: "refs/heads/develop",
137
+
OldSha: strings.Repeat("0", 40),
138
+
NewSha: strings.Repeat("f", 40),
139
+
},
140
+
},
141
+
shouldMatch: true,
142
+
expectedCount: 1,
143
+
},
144
+
{
145
+
name: "matches v* tag pattern",
146
+
trigger: tangled.Pipeline_TriggerMetadata{
147
+
Kind: string(TriggerKindPush),
148
+
Push: &tangled.Pipeline_PushTriggerData{
149
+
Ref: "refs/tags/v1.0.0",
150
+
OldSha: strings.Repeat("0", 40),
151
+
NewSha: strings.Repeat("f", 40),
152
+
},
153
+
},
154
+
shouldMatch: true,
155
+
expectedCount: 1,
156
+
},
157
+
{
158
+
name: "matches v* tag pattern with different version",
159
+
trigger: tangled.Pipeline_TriggerMetadata{
160
+
Kind: string(TriggerKindPush),
161
+
Push: &tangled.Pipeline_PushTriggerData{
162
+
Ref: "refs/tags/v2.5.3",
163
+
OldSha: strings.Repeat("0", 40),
164
+
NewSha: strings.Repeat("f", 40),
165
+
},
166
+
},
167
+
shouldMatch: true,
168
+
expectedCount: 1,
169
+
},
170
+
{
171
+
name: "does not match master branch",
172
+
trigger: tangled.Pipeline_TriggerMetadata{
173
+
Kind: string(TriggerKindPush),
174
+
Push: &tangled.Pipeline_PushTriggerData{
175
+
Ref: "refs/heads/master",
176
+
OldSha: strings.Repeat("0", 40),
177
+
NewSha: strings.Repeat("f", 40),
178
+
},
179
+
},
180
+
shouldMatch: false,
181
+
expectedCount: 0,
182
+
},
183
+
{
184
+
name: "does not match non-v tag",
185
+
trigger: tangled.Pipeline_TriggerMetadata{
186
+
Kind: string(TriggerKindPush),
187
+
Push: &tangled.Pipeline_PushTriggerData{
188
+
Ref: "refs/tags/release-1.0",
189
+
OldSha: strings.Repeat("0", 40),
190
+
NewSha: strings.Repeat("f", 40),
191
+
},
192
+
},
193
+
shouldMatch: false,
194
+
expectedCount: 0,
195
+
},
196
+
{
197
+
name: "does not match feature branch",
198
+
trigger: tangled.Pipeline_TriggerMetadata{
199
+
Kind: string(TriggerKindPush),
200
+
Push: &tangled.Pipeline_PushTriggerData{
201
+
Ref: "refs/heads/feature/new-feature",
202
+
OldSha: strings.Repeat("0", 40),
203
+
NewSha: strings.Repeat("f", 40),
204
+
},
205
+
},
206
+
shouldMatch: false,
207
+
expectedCount: 0,
208
+
},
209
+
}
210
+
211
+
for _, tt := range tests {
212
+
t.Run(tt.name, func(t *testing.T) {
213
+
c := Compiler{Trigger: tt.trigger}
214
+
cp := c.Compile([]Workflow{wf})
215
+
216
+
assert.Len(t, cp.Workflows, tt.expectedCount)
217
+
if tt.shouldMatch {
218
+
assert.Equal(t, wf.Name, cp.Workflows[0].Name)
219
+
}
220
+
})
221
+
}
222
+
}
+61
-19
workflow/def.go
+61
-19
workflow/def.go
···
8
8
9
9
"tangled.org/core/api/tangled"
10
10
11
+
"github.com/bmatcuk/doublestar/v4"
11
12
"github.com/go-git/go-git/v5/plumbing"
12
13
"gopkg.in/yaml.v3"
13
14
)
···
33
34
34
35
Constraint struct {
35
36
Event StringList `yaml:"event"`
36
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
37
+
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
38
+
Tag StringList `yaml:"tag"` // optional; only applies to push events
37
39
}
38
40
39
41
CloneOpts struct {
···
59
61
return strings.ReplaceAll(string(t), "_", " ")
60
62
}
61
63
64
+
// matchesPattern checks if a name matches any of the given patterns.
65
+
// Patterns can be exact matches or glob patterns using * and **.
66
+
// * matches any sequence of non-separator characters
67
+
// ** matches any sequence of characters including separators
68
+
func matchesPattern(name string, patterns []string) (bool, error) {
69
+
for _, pattern := range patterns {
70
+
matched, err := doublestar.Match(pattern, name)
71
+
if err != nil {
72
+
return false, err
73
+
}
74
+
if matched {
75
+
return true, nil
76
+
}
77
+
}
78
+
return false, nil
79
+
}
80
+
62
81
func FromFile(name string, contents []byte) (Workflow, error) {
63
82
var wf Workflow
64
83
···
74
93
}
75
94
76
95
// if any of the constraints on a workflow is true, return true
77
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
96
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
78
97
// manual triggers always run the workflow
79
98
if trigger.Manual != nil {
80
-
return true
99
+
return true, nil
81
100
}
82
101
83
102
// if not manual, run through the constraint list and see if any one matches
84
103
for _, c := range w.When {
85
-
if c.Match(trigger) {
86
-
return true
104
+
matched, err := c.Match(trigger)
105
+
if err != nil {
106
+
return false, err
107
+
}
108
+
if matched {
109
+
return true, nil
87
110
}
88
111
}
89
112
90
113
// no constraints, always run this workflow
91
114
if len(w.When) == 0 {
92
-
return true
115
+
return true, nil
93
116
}
94
117
95
-
return false
118
+
return false, nil
96
119
}
97
120
98
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
121
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
99
122
match := true
100
123
101
124
// manual triggers always pass this constraint
102
125
if trigger.Manual != nil {
103
-
return true
126
+
return true, nil
104
127
}
105
128
106
129
// apply event constraints
···
108
131
109
132
// apply branch constraints for PRs
110
133
if trigger.PullRequest != nil {
111
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
134
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
+
if err != nil {
136
+
return false, err
137
+
}
138
+
match = match && matched
112
139
}
113
140
114
141
// apply ref constraints for pushes
115
142
if trigger.Push != nil {
116
-
match = match && c.MatchRef(trigger.Push.Ref)
143
+
matched, err := c.MatchRef(trigger.Push.Ref)
144
+
if err != nil {
145
+
return false, err
146
+
}
147
+
match = match && matched
117
148
}
118
149
119
-
return match
120
-
}
121
-
122
-
func (c *Constraint) MatchBranch(branch string) bool {
123
-
return slices.Contains(c.Branch, branch)
150
+
return match, nil
124
151
}
125
152
126
-
func (c *Constraint) MatchRef(ref string) bool {
153
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
127
154
refName := plumbing.ReferenceName(ref)
155
+
shortName := refName.Short()
156
+
128
157
if refName.IsBranch() {
129
-
return slices.Contains(c.Branch, refName.Short())
158
+
return c.MatchBranch(shortName)
130
159
}
131
-
return false
160
+
161
+
if refName.IsTag() {
162
+
return c.MatchTag(shortName)
163
+
}
164
+
165
+
return false, nil
166
+
}
167
+
168
+
func (c *Constraint) MatchBranch(branch string) (bool, error) {
169
+
return matchesPattern(branch, c.Branch)
170
+
}
171
+
172
+
func (c *Constraint) MatchTag(tag string) (bool, error) {
173
+
return matchesPattern(tag, c.Tag)
132
174
}
133
175
134
176
func (c *Constraint) MatchEvent(event string) bool {
+284
-1
workflow/def_test.go
+284
-1
workflow/def_test.go
···
6
6
"github.com/stretchr/testify/assert"
7
7
)
8
8
9
-
func TestUnmarshalWorkflow(t *testing.T) {
9
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
10
10
yamlData := `
11
11
when:
12
12
- event: ["push", "pull_request"]
···
38
38
39
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
40
40
}
41
+
42
+
func TestUnmarshalWorkflowWithTags(t *testing.T) {
43
+
yamlData := `
44
+
when:
45
+
- event: ["push"]
46
+
tag: ["v*", "release-*"]`
47
+
48
+
wf, err := FromFile("test.yml", []byte(yamlData))
49
+
assert.NoError(t, err, "YAML should unmarshal without error")
50
+
51
+
assert.Len(t, wf.When, 1, "Should have one constraint")
52
+
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
53
+
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
54
+
}
55
+
56
+
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
57
+
yamlData := `
58
+
when:
59
+
- event: ["push"]
60
+
branch: ["main", "develop"]
61
+
tag: ["v*"]`
62
+
63
+
wf, err := FromFile("test.yml", []byte(yamlData))
64
+
assert.NoError(t, err, "YAML should unmarshal without error")
65
+
66
+
assert.Len(t, wf.When, 1, "Should have one constraint")
67
+
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
68
+
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
69
+
}
70
+
71
+
func TestMatchesPattern(t *testing.T) {
72
+
tests := []struct {
73
+
name string
74
+
input string
75
+
patterns []string
76
+
expected bool
77
+
}{
78
+
{"exact match", "main", []string{"main"}, true},
79
+
{"exact match in list", "develop", []string{"main", "develop"}, true},
80
+
{"no match", "feature", []string{"main", "develop"}, false},
81
+
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
82
+
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
83
+
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
84
+
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
85
+
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
86
+
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
87
+
{"double star no match", "feature/test", []string{"release/**"}, false},
88
+
{"no patterns matches nothing", "anything", []string{}, false},
89
+
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
90
+
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
91
+
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
92
+
}
93
+
94
+
for _, tt := range tests {
95
+
t.Run(tt.name, func(t *testing.T) {
96
+
result, _ := matchesPattern(tt.input, tt.patterns)
97
+
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
98
+
})
99
+
}
100
+
}
101
+
102
+
func TestConstraintMatchRef_Branches(t *testing.T) {
103
+
tests := []struct {
104
+
name string
105
+
constraint Constraint
106
+
ref string
107
+
expected bool
108
+
}{
109
+
{
110
+
name: "exact branch match",
111
+
constraint: Constraint{Branch: []string{"main"}},
112
+
ref: "refs/heads/main",
113
+
expected: true,
114
+
},
115
+
{
116
+
name: "branch glob match",
117
+
constraint: Constraint{Branch: []string{"feature-*"}},
118
+
ref: "refs/heads/feature-123",
119
+
expected: true,
120
+
},
121
+
{
122
+
name: "branch no match",
123
+
constraint: Constraint{Branch: []string{"main"}},
124
+
ref: "refs/heads/develop",
125
+
expected: false,
126
+
},
127
+
{
128
+
name: "no constraints matches nothing",
129
+
constraint: Constraint{},
130
+
ref: "refs/heads/anything",
131
+
expected: false,
132
+
},
133
+
}
134
+
135
+
for _, tt := range tests {
136
+
t.Run(tt.name, func(t *testing.T) {
137
+
result, _ := tt.constraint.MatchRef(tt.ref)
138
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
139
+
})
140
+
}
141
+
}
142
+
143
+
func TestConstraintMatchRef_Tags(t *testing.T) {
144
+
tests := []struct {
145
+
name string
146
+
constraint Constraint
147
+
ref string
148
+
expected bool
149
+
}{
150
+
{
151
+
name: "exact tag match",
152
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
153
+
ref: "refs/tags/v1.0.0",
154
+
expected: true,
155
+
},
156
+
{
157
+
name: "tag glob match",
158
+
constraint: Constraint{Tag: []string{"v*"}},
159
+
ref: "refs/tags/v1.2.3",
160
+
expected: true,
161
+
},
162
+
{
163
+
name: "tag glob with pattern",
164
+
constraint: Constraint{Tag: []string{"release-*"}},
165
+
ref: "refs/tags/release-2024",
166
+
expected: true,
167
+
},
168
+
{
169
+
name: "tag no match",
170
+
constraint: Constraint{Tag: []string{"v*"}},
171
+
ref: "refs/tags/release-1.0",
172
+
expected: false,
173
+
},
174
+
{
175
+
name: "tag not matched when only branch constraint",
176
+
constraint: Constraint{Branch: []string{"main"}},
177
+
ref: "refs/tags/v1.0.0",
178
+
expected: false,
179
+
},
180
+
}
181
+
182
+
for _, tt := range tests {
183
+
t.Run(tt.name, func(t *testing.T) {
184
+
result, _ := tt.constraint.MatchRef(tt.ref)
185
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
186
+
})
187
+
}
188
+
}
189
+
190
+
func TestConstraintMatchRef_Combined(t *testing.T) {
191
+
tests := []struct {
192
+
name string
193
+
constraint Constraint
194
+
ref string
195
+
expected bool
196
+
}{
197
+
{
198
+
name: "matches branch in combined constraint",
199
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
200
+
ref: "refs/heads/main",
201
+
expected: true,
202
+
},
203
+
{
204
+
name: "matches tag in combined constraint",
205
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
206
+
ref: "refs/tags/v1.0.0",
207
+
expected: true,
208
+
},
209
+
{
210
+
name: "no match in combined constraint",
211
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
212
+
ref: "refs/heads/develop",
213
+
expected: false,
214
+
},
215
+
{
216
+
name: "glob patterns in combined constraint - branch",
217
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
218
+
ref: "refs/heads/release-2024",
219
+
expected: true,
220
+
},
221
+
{
222
+
name: "glob patterns in combined constraint - tag",
223
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
224
+
ref: "refs/tags/v2.0.0",
225
+
expected: true,
226
+
},
227
+
}
228
+
229
+
for _, tt := range tests {
230
+
t.Run(tt.name, func(t *testing.T) {
231
+
result, _ := tt.constraint.MatchRef(tt.ref)
232
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
233
+
})
234
+
}
235
+
}
236
+
237
+
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
238
+
tests := []struct {
239
+
name string
240
+
constraint Constraint
241
+
branch string
242
+
expected bool
243
+
}{
244
+
{
245
+
name: "exact match",
246
+
constraint: Constraint{Branch: []string{"main"}},
247
+
branch: "main",
248
+
expected: true,
249
+
},
250
+
{
251
+
name: "glob match",
252
+
constraint: Constraint{Branch: []string{"feature-*"}},
253
+
branch: "feature-123",
254
+
expected: true,
255
+
},
256
+
{
257
+
name: "no match",
258
+
constraint: Constraint{Branch: []string{"main"}},
259
+
branch: "develop",
260
+
expected: false,
261
+
},
262
+
{
263
+
name: "multiple patterns with match",
264
+
constraint: Constraint{Branch: []string{"main", "release-*"}},
265
+
branch: "release-1.0",
266
+
expected: true,
267
+
},
268
+
}
269
+
270
+
for _, tt := range tests {
271
+
t.Run(tt.name, func(t *testing.T) {
272
+
result, _ := tt.constraint.MatchBranch(tt.branch)
273
+
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
274
+
})
275
+
}
276
+
}
277
+
278
+
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
279
+
tests := []struct {
280
+
name string
281
+
constraint Constraint
282
+
tag string
283
+
expected bool
284
+
}{
285
+
{
286
+
name: "exact match",
287
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
288
+
tag: "v1.0.0",
289
+
expected: true,
290
+
},
291
+
{
292
+
name: "glob match",
293
+
constraint: Constraint{Tag: []string{"v*"}},
294
+
tag: "v2.3.4",
295
+
expected: true,
296
+
},
297
+
{
298
+
name: "no match",
299
+
constraint: Constraint{Tag: []string{"v*"}},
300
+
tag: "release-1.0",
301
+
expected: false,
302
+
},
303
+
{
304
+
name: "multiple patterns with match",
305
+
constraint: Constraint{Tag: []string{"v*", "release-*"}},
306
+
tag: "release-2024",
307
+
expected: true,
308
+
},
309
+
{
310
+
name: "empty tag list matches nothing",
311
+
constraint: Constraint{Tag: []string{}},
312
+
tag: "v1.0.0",
313
+
expected: false,
314
+
},
315
+
}
316
+
317
+
for _, tt := range tests {
318
+
t.Run(tt.name, func(t *testing.T) {
319
+
result, _ := tt.constraint.MatchTag(tt.tag)
320
+
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
321
+
})
322
+
}
323
+
}
+5
-4
xrpc/serviceauth/service_auth.go
+5
-4
xrpc/serviceauth/service_auth.go
···
9
9
10
10
"github.com/bluesky-social/indigo/atproto/auth"
11
11
"tangled.org/core/idresolver"
12
+
"tangled.org/core/log"
12
13
xrpcerr "tangled.org/core/xrpc/errors"
13
14
)
14
15
···
22
23
23
24
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
24
25
return &ServiceAuth{
25
-
logger: logger,
26
+
logger: log.SubLogger(logger, "serviceauth"),
26
27
resolver: resolver,
27
28
audienceDid: audienceDid,
28
29
}
···
30
31
31
32
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
32
33
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
-
l := sa.logger.With("url", r.URL)
34
-
35
34
token := r.Header.Get("Authorization")
36
35
token = strings.TrimPrefix(token, "Bearer ")
37
36
···
42
41
43
42
did, err := s.Validate(r.Context(), token, nil)
44
43
if err != nil {
45
-
l.Error("signature verification failed", "err", err)
44
+
sa.logger.Error("signature verification failed", "err", err)
46
45
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
47
46
return
48
47
}
48
+
49
+
sa.logger.Debug("valid signature", ActorDid, did)
49
50
50
51
r = r.WithContext(
51
52
context.WithValue(r.Context(), ActorDid, did),