+13
.editorconfig
+13
.editorconfig
+1
.gitignore
+1
.gitignore
+3
-1
api/tangled/actorprofile.go
+3
-1
api/tangled/actorprofile.go
···
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
}
···
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
33
}
+196
-2
api/tangled/cbor_gen.go
+196
-2
api/tangled/cbor_gen.go
···
26
}
27
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 7
30
31
if t.Description == nil {
32
fieldCount--
···
41
}
42
43
if t.PinnedRepositories == nil {
44
fieldCount--
45
}
46
···
186
return err
187
}
188
if _, err := cw.WriteString(string(*t.Location)); err != nil {
189
return err
190
}
191
}
···
430
}
431
432
t.Location = (*string)(&sval)
433
}
434
}
435
// t.Description (string) (string)
···
5806
}
5807
5808
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5810
5811
if t.Description == nil {
5812
fieldCount--
···
5821
}
5822
5823
if t.Spindle == nil {
5824
fieldCount--
5825
}
5826
···
5961
}
5962
}
5963
5964
// t.Spindle (string) (string)
5965
if t.Spindle != nil {
5966
···
5993
}
5994
}
5995
5996
// t.CreatedAt (string) (string)
5997
if len("createdAt") > 1000000 {
5998
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
t.Source = (*string)(&sval)
6186
}
6187
}
6188
// t.Spindle (string) (string)
6189
case "spindle":
6190
···
6204
}
6205
6206
t.Spindle = (*string)(&sval)
6207
}
6208
}
6209
// t.CreatedAt (string) (string)
···
26
}
27
28
cw := cbg.NewCborWriter(w)
29
+
fieldCount := 8
30
31
if t.Description == nil {
32
fieldCount--
···
41
}
42
43
if t.PinnedRepositories == nil {
44
+
fieldCount--
45
+
}
46
+
47
+
if t.Pronouns == nil {
48
fieldCount--
49
}
50
···
190
return err
191
}
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 {
225
return err
226
}
227
}
···
466
}
467
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)
490
}
491
}
492
// t.Description (string) (string)
···
5863
}
5864
5865
cw := cbg.NewCborWriter(w)
5866
+
fieldCount := 10
5867
5868
if t.Description == nil {
5869
fieldCount--
···
5878
}
5879
5880
if t.Spindle == nil {
5881
+
fieldCount--
5882
+
}
5883
+
5884
+
if t.Topics == nil {
5885
+
fieldCount--
5886
+
}
5887
+
5888
+
if t.Website == nil {
5889
fieldCount--
5890
}
5891
···
6026
}
6027
}
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
+
6065
// t.Spindle (string) (string)
6066
if t.Spindle != nil {
6067
···
6094
}
6095
}
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
+
6129
// t.CreatedAt (string) (string)
6130
if len("createdAt") > 1000000 {
6131
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6318
t.Source = (*string)(&sval)
6319
}
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
+
}
6361
// t.Spindle (string) (string)
6362
case "spindle":
6363
···
6377
}
6378
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)
6401
}
6402
}
6403
// t.CreatedAt (string) (string)
+13
-1
api/tangled/repoblob.go
+13
-1
api/tangled/repoblob.go
···
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
type RepoBlob_Output struct {
32
// content: File content (base64 encoded for binary files)
33
-
Content string `json:"content" cborgen:"content"`
34
// encoding: Content encoding
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
// isBinary: Whether the file is binary
···
44
Ref string `json:"ref" cborgen:"ref"`
45
// size: File size in bytes
46
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
}
48
49
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
54
Name string `json:"name" cborgen:"name"`
55
// when: Author timestamp
56
When string `json:"when" cborgen:"when"`
57
}
58
59
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
···
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
type RepoBlob_Output struct {
32
// content: File content (base64 encoded for binary files)
33
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
34
// encoding: Content encoding
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
// isBinary: Whether the file is binary
···
44
Ref string `json:"ref" cborgen:"ref"`
45
// size: File size in bytes
46
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
+
// submodule: Submodule information if path is a submodule
48
+
Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"`
49
}
50
51
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
56
Name string `json:"name" cborgen:"name"`
57
// when: Author timestamp
58
When string `json:"when" cborgen:"when"`
59
+
}
60
+
61
+
// RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema.
62
+
type RepoBlob_Submodule struct {
63
+
// branch: Branch to track in the submodule
64
+
Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"`
65
+
// name: Submodule name
66
+
Name string `json:"name" cborgen:"name"`
67
+
// url: Submodule repository URL
68
+
Url string `json:"url" cborgen:"url"`
69
}
70
71
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
-4
api/tangled/repotree.go
···
47
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
49
type RepoTree_TreeEntry struct {
50
-
// is_file: Whether this entry is a file
51
-
Is_file bool `json:"is_file" cborgen:"is_file"`
52
-
// is_subtree: Whether this entry is a directory/subtree
53
-
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
54
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
55
// mode: File mode
56
Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
+4
api/tangled/tangledrepo.go
···
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
// spindle: CI runner to send jobs to and receive results from
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"`
37
}
+15
-2
appview/config/config.go
+15
-2
appview/config/config.go
···
13
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
DbPath string `env:"DB_PATH, default=appview.db"`
15
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
Dev bool `env:"DEV, default=false"`
18
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
···
25
}
26
27
type OAuthConfig struct {
28
-
Jwks string `env:"JWKS"`
29
}
30
31
type JetstreamConfig struct {
···
78
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
79
}
80
81
func (cfg RedisConfig) ToURL() string {
82
u := &url.URL{
83
Scheme: "redis",
···
103
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
104
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
105
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
106
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
107
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
108
}
109
110
func LoadConfig(ctx context.Context) (*Config, error) {
···
13
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
DbPath string `env:"DB_PATH, default=appview.db"`
15
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"`
17
+
AppviewName string `env:"APPVIEW_Name, default=Tangled"`
18
Dev bool `env:"DEV, default=false"`
19
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
20
···
26
}
27
28
type OAuthConfig struct {
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"`
35
}
36
37
type JetstreamConfig struct {
···
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"`
90
+
}
91
+
92
func (cfg RedisConfig) ToURL() string {
93
u := &url.URL{
94
Scheme: "redis",
···
114
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
115
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
116
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
118
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
119
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
121
}
122
123
func LoadConfig(ctx context.Context) (*Config, error) {
+22
appview/db/db.go
+22
appview/db/db.go
···
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,
+81
-5
appview/db/issues.go
+81
-5
appview/db/issues.go
···
101
pLower := FilterGte("row_num", page.Offset+1)
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
104
-
args = append(args, pLower.Arg()...)
105
-
args = append(args, pUpper.Arg()...)
106
-
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
107
108
query := fmt.Sprintf(
109
`
···
128
%s
129
`,
130
whereClause,
131
-
pagination,
132
)
133
134
rows, err := e.Query(query, args...)
···
243
return issues, nil
244
}
245
246
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
-
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
248
}
249
250
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
···
101
pLower := FilterGte("row_num", page.Offset+1)
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
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
+
}
110
111
query := fmt.Sprintf(
112
`
···
131
%s
132
`,
133
whereClause,
134
+
pageClause,
135
)
136
137
rows, err := e.Query(query, args...)
···
246
return issues, nil
247
}
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
+
266
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
267
+
return GetIssuesPaginated(e, pagination.Page{}, filters...)
268
+
}
269
+
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
+
}
291
+
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...)
308
+
if err != nil {
309
+
return nil, err
310
+
}
311
+
defer rows.Close()
312
+
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)
321
+
}
322
+
323
+
return ids, nil
324
}
325
326
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+22
-9
appview/db/notifications.go
+22
-9
appview/db/notifications.go
···
60
whereClause += " AND " + condition
61
}
62
}
63
64
query := fmt.Sprintf(`
65
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
66
from notifications
67
%s
68
order by created desc
69
-
limit ? offset ?
70
-
`, whereClause)
71
-
72
-
args = append(args, page.Limit, page.Offset)
73
74
rows, err := e.QueryContext(context.Background(), query, args...)
75
if err != nil {
···
131
select
132
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
133
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
134
-
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
135
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,
136
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
137
from notifications n
···
160
var issue models.Issue
161
var pull models.Pull
162
var rId, iId, pId sql.NullInt64
163
-
var rDid, rName, rDescription sql.NullString
164
var iDid sql.NullString
165
var iIssueId sql.NullInt64
166
var iTitle sql.NullString
···
173
err := rows.Scan(
174
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
175
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
176
-
&rId, &rDid, &rName, &rDescription,
177
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
178
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
179
)
···
200
}
201
if rDescription.Valid {
202
repo.Description = rDescription.String
203
}
204
nwe.Repo = &repo
205
}
···
391
pull_created,
392
pull_commented,
393
followed,
394
pull_merged,
395
issue_closed,
396
email_notifications
···
416
&prefs.PullCreated,
417
&prefs.PullCommented,
418
&prefs.Followed,
419
&prefs.PullMerged,
420
&prefs.IssueClosed,
421
&prefs.EmailNotifications,
···
437
query := `
438
INSERT OR REPLACE INTO notification_preferences
439
(user_did, repo_starred, issue_created, issue_commented, pull_created,
440
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
441
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
442
`
443
444
result, err := d.DB.ExecContext(ctx, query,
···
449
prefs.PullCreated,
450
prefs.PullCommented,
451
prefs.Followed,
452
prefs.PullMerged,
453
prefs.IssueClosed,
454
prefs.EmailNotifications,
···
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 {
···
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
···
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
···
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
)
···
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
}
···
400
pull_created,
401
pull_commented,
402
followed,
403
+
user_mentioned,
404
pull_merged,
405
issue_closed,
406
email_notifications
···
426
&prefs.PullCreated,
427
&prefs.PullCommented,
428
&prefs.Followed,
429
+
&prefs.UserMentioned,
430
&prefs.PullMerged,
431
&prefs.IssueClosed,
432
&prefs.EmailNotifications,
···
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,
···
461
prefs.PullCreated,
462
prefs.PullCommented,
463
prefs.Followed,
464
+
prefs.UserMentioned,
465
prefs.PullMerged,
466
prefs.IssueClosed,
467
prefs.EmailNotifications,
+26
-6
appview/db/profile.go
+26
-6
appview/db/profile.go
···
129
did,
130
description,
131
include_bluesky,
132
-
location
133
)
134
-
values (?, ?, ?, ?)`,
135
profile.Did,
136
profile.Description,
137
includeBskyValue,
138
profile.Location,
139
)
140
141
if err != nil {
···
216
did,
217
description,
218
include_bluesky,
219
-
location
220
from
221
profile
222
%s`,
···
231
for rows.Next() {
232
var profile models.Profile
233
var includeBluesky int
234
235
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
236
if err != nil {
237
return nil, err
238
}
239
240
if includeBluesky != 0 {
241
profile.IncludeBluesky = true
242
}
243
244
profileMap[profile.Did] = &profile
···
302
303
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
var profile models.Profile
305
profile.Did = did
306
307
includeBluesky := 0
308
err := e.QueryRow(
309
-
`select description, include_bluesky, location from profile where did = ?`,
310
did,
311
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
312
if err == sql.ErrNoRows {
313
profile := models.Profile{}
314
profile.Did = did
···
321
322
if includeBluesky != 0 {
323
profile.IncludeBluesky = true
324
}
325
326
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
412
// ensure description is not too long
413
if len(profile.Location) > 40 {
414
return fmt.Errorf("Entered location is too long.")
415
}
416
417
// ensure links are in order
···
129
did,
130
description,
131
include_bluesky,
132
+
location,
133
+
pronouns
134
)
135
+
values (?, ?, ?, ?, ?)`,
136
profile.Did,
137
profile.Description,
138
includeBskyValue,
139
profile.Location,
140
+
profile.Pronouns,
141
)
142
143
if err != nil {
···
218
did,
219
description,
220
include_bluesky,
221
+
location,
222
+
pronouns
223
from
224
profile
225
%s`,
···
234
for rows.Next() {
235
var profile models.Profile
236
var includeBluesky int
237
+
var pronouns sql.Null[string]
238
239
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
240
if err != nil {
241
return nil, err
242
}
243
244
if includeBluesky != 0 {
245
profile.IncludeBluesky = true
246
+
}
247
+
248
+
if pronouns.Valid {
249
+
profile.Pronouns = pronouns.V
250
}
251
252
profileMap[profile.Did] = &profile
···
310
311
func GetProfile(e Execer, did string) (*models.Profile, error) {
312
var profile models.Profile
313
+
var pronouns sql.Null[string]
314
+
315
profile.Did = did
316
317
includeBluesky := 0
318
+
319
err := e.QueryRow(
320
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
321
did,
322
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
323
if err == sql.ErrNoRows {
324
profile := models.Profile{}
325
profile.Did = did
···
332
333
if includeBluesky != 0 {
334
profile.IncludeBluesky = true
335
+
}
336
+
337
+
if pronouns.Valid {
338
+
profile.Pronouns = pronouns.V
339
}
340
341
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
427
// ensure description is not too long
428
if len(profile.Location) > 40 {
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.")
435
}
436
437
// ensure links are in order
+66
-5
appview/db/pulls.go
+66
-5
appview/db/pulls.go
···
92
_, err = tx.Exec(`
93
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
values (?, ?, ?, ?, ?)
95
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
101
if err != nil {
102
return "", err
103
}
104
-
return pull.PullAt(), err
105
}
106
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
-
pulls[pull.PullAt()] = &pull
218
}
219
220
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
-
pullAts = append(pullAts, p.PullAt())
223
}
224
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
···
281
return GetPullsWithLimit(e, 0, filters...)
282
}
283
284
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
286
if err != nil {
287
return nil, err
288
}
289
-
if pulls == nil {
290
return nil, sql.ErrNoRows
291
}
292
···
92
_, err = tx.Exec(`
93
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
values (?, ?, ?, ?, ?)
95
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
101
if err != nil {
102
return "", err
103
}
104
+
return pull.AtUri(), err
105
}
106
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
+
pulls[pull.AtUri()] = &pull
218
}
219
220
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
+
pullAts = append(pullAts, p.AtUri())
223
}
224
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
···
281
return GetPullsWithLimit(e, 0, filters...)
282
}
283
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
+
`
316
+
select
317
+
id
318
+
from
319
+
pulls
320
+
%s
321
+
%s`,
322
+
whereClause,
323
+
pageClause,
324
+
)
325
+
args = append(args, opts.Page.Limit, opts.Page.Offset)
326
+
rows, err := e.Query(query, args...)
327
+
if err != nil {
328
+
return nil, err
329
+
}
330
+
defer rows.Close()
331
+
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))
347
if err != nil {
348
return nil, err
349
}
350
+
if len(pulls) == 0 {
351
return nil, sql.ErrNoRows
352
}
353
+50
-12
appview/db/repos.go
+50
-12
appview/db/repos.go
···
70
rkey,
71
created,
72
description,
73
source,
74
spindle
75
from
···
89
for rows.Next() {
90
var repo models.Repo
91
var createdAt string
92
-
var description, source, spindle sql.NullString
93
94
err := rows.Scan(
95
&repo.Id,
···
99
&repo.Rkey,
100
&createdAt,
101
&description,
102
&source,
103
&spindle,
104
)
···
111
}
112
if description.Valid {
113
repo.Description = description.String
114
}
115
if source.Valid {
116
repo.Source = source.String
···
356
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
var repo models.Repo
358
var nullableDescription sql.NullString
359
360
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
361
362
var createdAt string
363
-
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
364
return nil, err
365
}
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
368
369
if nullableDescription.Valid {
370
repo.Description = nullableDescription.String
371
-
} else {
372
-
repo.Description = ""
373
}
374
375
return &repo, nil
376
}
377
378
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
_, err := tx.Exec(
380
`insert into repos
381
-
(did, name, knot, rkey, at_uri, description, source)
382
-
values (?, ?, ?, ?, ?, ?, ?)`,
383
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
384
)
385
if err != nil {
386
return fmt.Errorf("failed to insert repo: %w", err)
···
416
var repos []models.Repo
417
418
rows, err := e.Query(
419
-
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
420
from repos r
421
left join collaborators c on r.at_uri = c.repo_at
422
where (r.did = ? or c.subject_did = ?)
···
434
var repo models.Repo
435
var createdAt string
436
var nullableDescription sql.NullString
437
var nullableSource sql.NullString
438
439
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
440
if err != nil {
441
return nil, err
442
}
···
470
var repo models.Repo
471
var createdAt string
472
var nullableDescription sql.NullString
473
var nullableSource sql.NullString
474
475
row := e.QueryRow(
476
-
`select id, did, name, knot, rkey, description, created, source
477
from repos
478
where did = ? and name = ? and source is not null and source != ''`,
479
did, name,
480
)
481
482
-
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
483
if err != nil {
484
return nil, err
485
}
486
487
if nullableDescription.Valid {
488
repo.Description = nullableDescription.String
489
}
490
491
if nullableSource.Valid {
···
70
rkey,
71
created,
72
description,
73
+
website,
74
+
topics,
75
source,
76
spindle
77
from
···
91
for rows.Next() {
92
var repo models.Repo
93
var createdAt string
94
+
var description, website, topicStr, source, spindle sql.NullString
95
96
err := rows.Scan(
97
&repo.Id,
···
101
&repo.Rkey,
102
&createdAt,
103
&description,
104
+
&website,
105
+
&topicStr,
106
&source,
107
&spindle,
108
)
···
115
}
116
if description.Valid {
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)
124
}
125
if source.Valid {
126
repo.Source = source.String
···
366
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
367
var repo models.Repo
368
var nullableDescription sql.NullString
369
+
var nullableWebsite sql.NullString
370
+
var nullableTopicStr sql.NullString
371
372
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
373
374
var createdAt string
375
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
376
return nil, err
377
}
378
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
380
381
if nullableDescription.Valid {
382
repo.Description = nullableDescription.String
383
+
}
384
+
if nullableWebsite.Valid {
385
+
repo.Website = nullableWebsite.String
386
+
}
387
+
if nullableTopicStr.Valid {
388
+
repo.Topics = strings.Fields(nullableTopicStr.String)
389
}
390
391
return &repo, nil
392
+
}
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
405
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
406
_, err := tx.Exec(
407
`insert into repos
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,
411
)
412
if err != nil {
413
return fmt.Errorf("failed to insert repo: %w", err)
···
443
var repos []models.Repo
444
445
rows, err := e.Query(
446
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
447
from repos r
448
left join collaborators c on r.at_uri = c.repo_at
449
where (r.did = ? or c.subject_did = ?)
···
461
var repo models.Repo
462
var createdAt string
463
var nullableDescription sql.NullString
464
+
var nullableWebsite sql.NullString
465
var nullableSource sql.NullString
466
467
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
468
if err != nil {
469
return nil, err
470
}
···
498
var repo models.Repo
499
var createdAt string
500
var nullableDescription sql.NullString
501
+
var nullableWebsite sql.NullString
502
+
var nullableTopicStr sql.NullString
503
var nullableSource sql.NullString
504
505
row := e.QueryRow(
506
+
`select id, did, name, knot, rkey, description, website, topics, created, source
507
from repos
508
where did = ? and name = ? and source is not null and source != ''`,
509
did, name,
510
)
511
512
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
513
if err != nil {
514
return nil, err
515
}
516
517
if nullableDescription.Valid {
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)
527
}
528
529
if nullableSource.Valid {
+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
+
}
+6
appview/ingester.go
+6
appview/ingester.go
···
291
292
includeBluesky := record.Bluesky
293
294
+
pronouns := ""
295
+
if record.Pronouns != nil {
296
+
pronouns = *record.Pronouns
297
+
}
298
+
299
location := ""
300
if record.Location != nil {
301
location = *record.Location
···
330
Links: links,
331
Stats: stats,
332
PinnedRepos: pinned,
333
+
Pronouns: pronouns,
334
}
335
336
ddb, ok := i.Db.Execer.(*db.DB)
+68
-15
appview/issues/issues.go
+68
-15
appview/issues/issues.go
···
19
"tangled.org/core/api/tangled"
20
"tangled.org/core/appview/config"
21
"tangled.org/core/appview/db"
22
"tangled.org/core/appview/models"
23
"tangled.org/core/appview/notify"
24
"tangled.org/core/appview/oauth"
25
"tangled.org/core/appview/pages"
26
"tangled.org/core/appview/pagination"
27
"tangled.org/core/appview/reporesolver"
28
"tangled.org/core/appview/validator"
···
40
notifier notify.Notifier
41
logger *slog.Logger
42
validator *validator.Validator
43
}
44
45
func New(
···
51
config *config.Config,
52
notifier notify.Notifier,
53
validator *validator.Validator,
54
logger *slog.Logger,
55
) *Issues {
56
return &Issues{
···
63
notifier: notifier,
64
logger: logger,
65
validator: validator,
66
}
67
}
68
···
259
return
260
}
261
262
// return to all issues page
263
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
264
}
···
299
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
300
return
301
}
302
303
// notify about the issue closure
304
-
rp.notifier.NewIssueClosed(r.Context(), issue)
305
306
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
307
return
···
347
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
348
return
349
}
350
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
351
return
352
} else {
···
439
440
// notify about the new comment
441
comment.Id = commentId
442
-
rp.notifier.NewIssueComment(r.Context(), &comment)
443
444
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
445
}
···
770
isOpen = true
771
}
772
773
-
page, ok := r.Context().Value("page").(pagination.Page)
774
-
if !ok {
775
-
l.Error("failed to get page")
776
-
page = pagination.FirstPage()
777
-
}
778
779
user := rp.oauth.GetUser(r)
780
f, err := rp.repoResolver.Resolve(r)
···
783
return
784
}
785
786
-
openVal := 0
787
-
if isOpen {
788
-
openVal = 1
789
}
790
-
issues, err := db.GetIssuesPaginated(
791
rp.db,
792
-
page,
793
-
db.FilterEq("repo_at", f.RepoAt()),
794
-
db.FilterEq("open", openVal),
795
)
796
if err != nil {
797
l.Error("failed to get issues", "err", err)
···
821
Issues: issues,
822
LabelDefs: defs,
823
FilteringByOpen: isOpen,
824
Page: page,
825
})
826
}
···
847
Rkey: tid.TID(),
848
Title: r.FormValue("title"),
849
Body: r.FormValue("body"),
850
Did: user.Did,
851
Created: time.Now(),
852
Repo: &f.Repo,
···
916
917
// everything is successful, do not rollback the atproto record
918
atUri = ""
919
-
rp.notifier.NewIssue(r.Context(), issue)
920
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
921
return
922
}
···
19
"tangled.org/core/api/tangled"
20
"tangled.org/core/appview/config"
21
"tangled.org/core/appview/db"
22
+
issues_indexer "tangled.org/core/appview/indexer/issues"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/markup"
28
"tangled.org/core/appview/pagination"
29
"tangled.org/core/appview/reporesolver"
30
"tangled.org/core/appview/validator"
···
42
notifier notify.Notifier
43
logger *slog.Logger
44
validator *validator.Validator
45
+
indexer *issues_indexer.Indexer
46
}
47
48
func New(
···
54
config *config.Config,
55
notifier notify.Notifier,
56
validator *validator.Validator,
57
+
indexer *issues_indexer.Indexer,
58
logger *slog.Logger,
59
) *Issues {
60
return &Issues{
···
67
notifier: notifier,
68
logger: logger,
69
validator: validator,
70
+
indexer: indexer,
71
}
72
}
73
···
264
return
265
}
266
267
+
rp.notifier.DeleteIssue(r.Context(), issue)
268
+
269
// return to all issues page
270
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
271
}
···
306
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
307
return
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)
314
315
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
316
return
···
356
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
357
return
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
+
365
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
366
return
367
} else {
···
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
469
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
470
}
···
795
isOpen = true
796
}
797
798
+
page := pagination.FromContext(r.Context())
799
800
user := rp.oauth.GetUser(r)
801
f, err := rp.repoResolver.Resolve(r)
···
804
return
805
}
806
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))
831
}
832
+
833
+
issues, err := db.GetIssues(
834
rp.db,
835
+
db.FilterIn("id", ids),
836
)
837
if err != nil {
838
l.Error("failed to get issues", "err", err)
···
862
Issues: issues,
863
LabelDefs: defs,
864
FilteringByOpen: isOpen,
865
+
FilterQuery: keyword,
866
Page: page,
867
})
868
}
···
889
Rkey: tid.TID(),
890
Title: r.FormValue("title"),
891
Body: r.FormValue("body"),
892
+
Open: true,
893
Did: user.Did,
894
Created: time.Now(),
895
Repo: &f.Repo,
···
959
960
// everything is successful, do not rollback the atproto record
961
atUri = ""
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)
973
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
974
return
975
}
+5
-5
appview/issues/opengraph.go
+5
-5
appview/issues/opengraph.go
···
143
var statusBgColor color.RGBA
144
145
if issue.Open {
146
-
statusIcon = "static/icons/circle-dot.svg"
147
statusText = "open"
148
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
} else {
150
-
statusIcon = "static/icons/circle-dot.svg"
151
statusText = "closed"
152
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
}
···
155
badgeIconSize := 36
156
157
// Draw icon with status color (no background)
158
-
err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
if err != nil {
160
log.Printf("failed to draw status icon: %v", err)
161
}
···
172
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
174
// Draw comment count
175
-
err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
if err != nil {
177
log.Printf("failed to draw comment icon: %v", err)
178
}
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
}
···
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
}
···
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
}
···
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
}
···
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
}
+9
appview/knots/knots.go
+9
appview/knots/knots.go
···
6
"log/slog"
7
"net/http"
8
"slices"
9
"time"
10
11
"github.com/go-chi/chi/v5"
···
145
}
146
147
domain := r.FormValue("domain")
148
if domain == "" {
149
k.Pages.Notice(w, noticeId, "Incomplete form.")
150
return
···
526
}
527
528
member := r.FormValue("member")
529
if member == "" {
530
l.Error("empty member")
531
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
626
}
627
628
member := r.FormValue("member")
629
if member == "" {
630
l.Error("empty member")
631
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
6
"log/slog"
7
"net/http"
8
"slices"
9
+
"strings"
10
"time"
11
12
"github.com/go-chi/chi/v5"
···
146
}
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, "/")
155
if domain == "" {
156
k.Pages.Notice(w, noticeId, "Incomplete form.")
157
return
···
533
}
534
535
member := r.FormValue("member")
536
+
member = strings.TrimPrefix(member, "@")
537
if member == "" {
538
l.Error("empty member")
539
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
634
}
635
636
member := r.FormValue("member")
637
+
member = strings.TrimPrefix(member, "@")
638
if member == "" {
639
l.Error("empty member")
640
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1
-1
appview/labels/labels.go
+1
-1
appview/labels/labels.go
+11
-16
appview/middleware/middleware.go
+11
-16
appview/middleware/middleware.go
···
105
}
106
}
107
108
-
ctx := context.WithValue(r.Context(), "page", page)
109
next.ServeHTTP(w, r.WithContext(ctx))
110
})
111
}
···
180
return func(next http.Handler) http.Handler {
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
didOrHandle := chi.URLParam(req, "user")
183
if slices.Contains(excluded, didOrHandle) {
184
next.ServeHTTP(w, req)
185
return
186
}
187
-
188
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
if err != nil {
···
206
return func(next http.Handler) http.Handler {
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
repoName := chi.URLParam(req, "repo")
209
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
if !ok {
211
log.Println("malformed middleware")
···
244
prId := chi.URLParam(r, "pull")
245
prIdInt, err := strconv.Atoi(prId)
246
if err != nil {
247
-
http.Error(w, "bad pr id", http.StatusBadRequest)
248
log.Println("failed to parse pr id", err)
249
return
250
}
251
252
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
253
if err != nil {
254
log.Println("failed to get pull and comments", err)
255
return
256
}
257
···
292
issueId, err := strconv.Atoi(issueIdStr)
293
if err != nil {
294
log.Println("failed to fully resolve issue ID", err)
295
-
mw.pages.ErrorKnot404(w)
296
return
297
}
298
299
-
issues, err := db.GetIssues(
300
-
mw.db,
301
-
db.FilterEq("repo_at", f.RepoAt()),
302
-
db.FilterEq("issue_id", issueId),
303
-
)
304
if err != nil {
305
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))
310
-
return
311
-
}
312
-
issue := issues[0]
313
314
-
ctx := context.WithValue(r.Context(), "issue", &issue)
315
next.ServeHTTP(w, r.WithContext(ctx))
316
})
317
}
···
105
}
106
}
107
108
+
ctx := pagination.IntoContext(r.Context(), page)
109
next.ServeHTTP(w, r.WithContext(ctx))
110
})
111
}
···
180
return func(next http.Handler) http.Handler {
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
didOrHandle := chi.URLParam(req, "user")
183
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
184
+
185
if slices.Contains(excluded, didOrHandle) {
186
next.ServeHTTP(w, req)
187
return
188
}
189
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
if err != nil {
···
206
return func(next http.Handler) http.Handler {
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
212
if !ok {
213
log.Println("malformed middleware")
···
246
prId := chi.URLParam(r, "pull")
247
prIdInt, err := strconv.Atoi(prId)
248
if err != nil {
249
log.Println("failed to parse pr id", err)
250
+
mw.pages.Error404(w)
251
return
252
}
253
254
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
255
if err != nil {
256
log.Println("failed to get pull and comments", err)
257
+
mw.pages.Error404(w)
258
return
259
}
260
···
295
issueId, err := strconv.Atoi(issueIdStr)
296
if err != nil {
297
log.Println("failed to fully resolve issue ID", err)
298
+
mw.pages.Error404(w)
299
return
300
}
301
302
+
issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId)
303
if err != nil {
304
log.Println("failed to get issues", "err", err)
305
+
mw.pages.Error404(w)
306
return
307
}
308
309
+
ctx := context.WithValue(r.Context(), "issue", issue)
310
next.ServeHTTP(w, r.WithContext(ctx))
311
})
312
}
+25
-43
appview/models/label.go
+25
-43
appview/models/label.go
···
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
"github.com/bluesky-social/indigo/xrpc"
16
"tangled.org/core/api/tangled"
17
-
"tangled.org/core/consts"
18
"tangled.org/core/idresolver"
19
)
20
···
461
return result
462
}
463
464
-
var (
465
-
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
-
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
-
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
-
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
-
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
-
)
471
472
-
func DefaultLabelDefs() []string {
473
-
return []string{
474
-
LabelWontfix,
475
-
LabelDuplicate,
476
-
LabelAssignee,
477
-
LabelGoodFirstIssue,
478
-
LabelDocumentation,
479
-
}
480
-
}
481
482
-
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
483
-
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
484
-
if err != nil {
485
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
486
-
}
487
-
pdsEndpoint := resolved.PDSEndpoint()
488
-
if pdsEndpoint == "" {
489
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
490
-
}
491
-
client := &xrpc.Client{
492
-
Host: pdsEndpoint,
493
-
}
494
495
-
var labelDefs []LabelDefinition
496
497
-
for _, dl := range DefaultLabelDefs() {
498
-
atUri := syntax.ATURI(dl)
499
-
parsedUri, err := syntax.ParseATURI(string(atUri))
500
-
if err != nil {
501
-
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
502
-
}
503
record, err := atproto.RepoGetRecord(
504
-
context.Background(),
505
-
client,
506
"",
507
-
parsedUri.Collection().String(),
508
-
parsedUri.Authority().String(),
509
-
parsedUri.RecordKey().String(),
510
)
511
if err != nil {
512
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
526
}
527
528
labelDef, err := LabelDefinitionFromRecord(
529
-
parsedUri.Authority().String(),
530
-
parsedUri.RecordKey().String(),
531
labelRecord,
532
)
533
if err != nil {
···
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
"github.com/bluesky-social/indigo/xrpc"
16
"tangled.org/core/api/tangled"
17
"tangled.org/core/idresolver"
18
)
19
···
460
return result
461
}
462
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
+
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
466
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
+
}
475
476
+
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
477
+
if err != nil {
478
+
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
479
+
}
480
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
483
+
}
484
485
record, err := atproto.RepoGetRecord(
486
+
ctx,
487
+
&xrpcc,
488
"",
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
492
)
493
if err != nil {
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
508
}
509
510
labelDef, err := LabelDefinitionFromRecord(
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
513
labelRecord,
514
)
515
if err != nil {
+17
appview/models/notifications.go
+17
appview/models/notifications.go
···
17
NotificationTypeFollowed NotificationType = "followed"
18
NotificationTypePullMerged NotificationType = "pull_merged"
19
NotificationTypeIssueClosed NotificationType = "issue_closed"
20
NotificationTypePullClosed NotificationType = "pull_closed"
21
)
22
23
type Notification struct {
···
47
return "message-square"
48
case NotificationTypeIssueClosed:
49
return "ban"
50
case NotificationTypePullCreated:
51
return "git-pull-request-create"
52
case NotificationTypePullCommented:
···
55
return "git-merge"
56
case NotificationTypePullClosed:
57
return "git-pull-request-closed"
58
case NotificationTypeFollowed:
59
return "user-plus"
60
default:
61
return ""
62
}
···
78
PullCreated bool
79
PullCommented bool
80
Followed bool
81
PullMerged bool
82
IssueClosed bool
83
EmailNotifications bool
···
93
return prefs.IssueCommented
94
case NotificationTypeIssueClosed:
95
return prefs.IssueClosed
96
case NotificationTypePullCreated:
97
return prefs.PullCreated
98
case NotificationTypePullCommented:
···
101
return prefs.PullMerged
102
case NotificationTypePullClosed:
103
return prefs.PullMerged // same pref for now
104
case NotificationTypeFollowed:
105
return prefs.Followed
106
default:
107
return false
108
}
···
117
PullCreated: true,
118
PullCommented: true,
119
Followed: true,
120
PullMerged: true,
121
IssueClosed: true,
122
EmailNotifications: false,
···
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 {
···
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:
···
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
}
···
87
PullCreated bool
88
PullCommented bool
89
Followed bool
90
+
UserMentioned bool
91
PullMerged bool
92
IssueClosed bool
93
EmailNotifications bool
···
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:
···
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
}
···
133
PullCreated: true,
134
PullCommented: true,
135
Followed: true,
136
+
UserMentioned: true,
137
PullMerged: true,
138
IssueClosed: true,
139
EmailNotifications: false,
+1
appview/models/profile.go
+1
appview/models/profile.go
+1
-1
appview/models/pull.go
+1
-1
appview/models/pull.go
+61
-1
appview/models/repo.go
+61
-1
appview/models/repo.go
···
2
3
import (
4
"fmt"
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
···
17
Rkey string
18
Created time.Time
19
Description string
20
Spindle string
21
Labels []string
22
···
28
}
29
30
func (r *Repo) AsRecord() tangled.Repo {
31
-
var source, spindle, description *string
32
33
if r.Source != "" {
34
source = &r.Source
···
42
description = &r.Description
43
}
44
45
return tangled.Repo{
46
Knot: r.Knot,
47
Name: r.Name,
48
Description: description,
49
CreatedAt: r.Created.Format(time.RFC3339),
50
Source: source,
51
Spindle: spindle,
···
60
func (r Repo) DidSlashRepo() string {
61
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
return p
63
}
64
65
type RepoStats struct {
···
91
Repo *Repo
92
Issues []Issue
93
}
···
2
3
import (
4
"fmt"
5
+
"strings"
6
"time"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
18
Rkey string
19
Created time.Time
20
Description string
21
+
Website string
22
+
Topics []string
23
Spindle string
24
Labels []string
25
···
31
}
32
33
func (r *Repo) AsRecord() tangled.Repo {
34
+
var source, spindle, description, website *string
35
36
if r.Source != "" {
37
source = &r.Source
···
45
description = &r.Description
46
}
47
48
+
if r.Website != "" {
49
+
website = &r.Website
50
+
}
51
+
52
return tangled.Repo{
53
Knot: r.Knot,
54
Name: r.Name,
55
Description: description,
56
+
Website: website,
57
+
Topics: r.Topics,
58
CreatedAt: r.Created.Format(time.RFC3339),
59
Source: source,
60
Spindle: spindle,
···
69
func (r Repo) DidSlashRepo() string {
70
p, _ := securejoin.SecureJoin(r.Did, r.Name)
71
return p
72
+
}
73
+
74
+
func (r Repo) TopicStr() string {
75
+
return strings.Join(r.Topics, " ")
76
}
77
78
type RepoStats struct {
···
104
Repo *Repo
105
Issues []Issue
106
}
107
+
108
+
type BlobContentType int
109
+
110
+
const (
111
+
BlobContentTypeCode BlobContentType = iota
112
+
BlobContentTypeMarkup
113
+
BlobContentTypeImage
114
+
BlobContentTypeSvg
115
+
BlobContentTypeVideo
116
+
BlobContentTypeSubmodule
117
+
)
118
+
119
+
func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode }
120
+
func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup }
121
+
func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage }
122
+
func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg }
123
+
func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo }
124
+
func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule }
125
+
126
+
type BlobView struct {
127
+
HasTextView bool // can show as code/text
128
+
HasRenderedView bool // can show rendered (markup/image/video/submodule)
129
+
HasRawView bool // can download raw (everything except submodule)
130
+
131
+
// current display mode
132
+
ShowingRendered bool // currently in rendered mode
133
+
ShowingText bool // currently in text/code mode
134
+
135
+
// content type flags
136
+
ContentType BlobContentType
137
+
138
+
// Content data
139
+
Contents string
140
+
ContentSrc string // URL for media files
141
+
Lines int
142
+
SizeHint uint64
143
+
}
144
+
145
+
// if both views are available, then show a toggle between them
146
+
func (b BlobView) ShowToggle() bool {
147
+
return b.HasTextView && b.HasRenderedView
148
+
}
149
+
150
+
func (b BlobView) IsUnsupported() bool {
151
+
// no view available, only raw
152
+
return !(b.HasRenderedView || b.HasTextView)
153
+
}
+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
+
// }
+1
-5
appview/notifications/notifications.go
+1
-5
appview/notifications/notifications.go
+71
-63
appview/notify/db/db.go
+71
-63
appview/notify/db/db.go
···
13
"tangled.org/core/idresolver"
14
)
15
16
type databaseNotifier struct {
17
db *db.DB
18
res *idresolver.Resolver
···
64
// no-op
65
}
66
67
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
68
69
// build the recipients list
70
// - owner of the repo
···
81
}
82
83
actorDid := syntax.DID(issue.Did)
84
-
eventType := models.NotificationTypeIssueCreated
85
entityType := "issue"
86
entityId := issue.AtUri().String()
87
repoId := &issue.Repo.Id
···
91
n.notifyEvent(
92
actorDid,
93
recipients,
94
-
eventType,
95
entityType,
96
entityId,
97
repoId,
···
100
)
101
}
102
103
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
104
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
105
if err != nil {
106
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
132
}
133
134
actorDid := syntax.DID(comment.Did)
135
-
eventType := models.NotificationTypeIssueCommented
136
entityType := "issue"
137
entityId := issue.AtUri().String()
138
repoId := &issue.Repo.Id
···
142
n.notifyEvent(
143
actorDid,
144
recipients,
145
-
eventType,
146
entityType,
147
entityId,
148
repoId,
···
151
)
152
}
153
154
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
155
actorDid := syntax.DID(follow.UserDid)
156
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
···
199
actorDid := syntax.DID(pull.OwnerDid)
200
eventType := models.NotificationTypePullCreated
201
entityType := "pull"
202
-
entityId := pull.PullAt().String()
203
repoId := &repo.Id
204
var issueId *int64
205
p := int64(pull.ID)
···
217
)
218
}
219
220
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
221
pull, err := db.GetPull(n.db,
222
syntax.ATURI(comment.RepoAt),
223
comment.PullId,
···
245
actorDid := syntax.DID(comment.OwnerDid)
246
eventType := models.NotificationTypePullCommented
247
entityType := "pull"
248
-
entityId := pull.PullAt().String()
249
repoId := &repo.Id
250
var issueId *int64
251
p := int64(pull.ID)
···
261
issueId,
262
pullId,
263
)
264
}
265
266
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
279
// no-op
280
}
281
282
-
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
283
// build up the recipients list:
284
// - repo owner
285
// - repo collaborators
···
298
recipients = append(recipients, syntax.DID(p))
299
}
300
301
-
actorDid := syntax.DID(issue.Repo.Did)
302
-
eventType := models.NotificationTypeIssueClosed
303
entityType := "pull"
304
entityId := issue.AtUri().String()
305
repoId := &issue.Repo.Id
306
issueId := &issue.Id
307
var pullId *int64
308
-
309
-
n.notifyEvent(
310
-
actorDid,
311
-
recipients,
312
-
eventType,
313
-
entityType,
314
-
entityId,
315
-
repoId,
316
-
issueId,
317
-
pullId,
318
-
)
319
-
}
320
321
-
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
322
-
// Get repo details
323
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
324
-
if err != nil {
325
-
log.Printf("NewPullMerged: failed to get repos: %v", err)
326
-
return
327
-
}
328
-
329
-
// build up the recipients list:
330
-
// - repo owner
331
-
// - all pull participants
332
-
var recipients []syntax.DID
333
-
recipients = append(recipients, syntax.DID(repo.Did))
334
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
335
-
if err != nil {
336
-
log.Printf("failed to fetch collaborators: %v", err)
337
-
return
338
-
}
339
-
for _, c := range collaborators {
340
-
recipients = append(recipients, c.SubjectDid)
341
-
}
342
-
for _, p := range pull.Participants() {
343
-
recipients = append(recipients, syntax.DID(p))
344
}
345
346
-
actorDid := syntax.DID(repo.Did)
347
-
eventType := models.NotificationTypePullMerged
348
-
entityType := "pull"
349
-
entityId := pull.PullAt().String()
350
-
repoId := &repo.Id
351
-
var issueId *int64
352
-
p := int64(pull.ID)
353
-
pullId := &p
354
-
355
n.notifyEvent(
356
-
actorDid,
357
recipients,
358
eventType,
359
entityType,
···
364
)
365
}
366
367
-
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
368
// Get repo details
369
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
370
if err != nil {
371
-
log.Printf("NewPullMerged: failed to get repos: %v", err)
372
return
373
}
374
···
389
recipients = append(recipients, syntax.DID(p))
390
}
391
392
-
actorDid := syntax.DID(repo.Did)
393
-
eventType := models.NotificationTypePullClosed
394
entityType := "pull"
395
-
entityId := pull.PullAt().String()
396
repoId := &repo.Id
397
var issueId *int64
398
p := int64(pull.ID)
399
pullId := &p
400
401
n.notifyEvent(
402
-
actorDid,
403
recipients,
404
eventType,
405
entityType,
···
420
issueId *int64,
421
pullId *int64,
422
) {
423
recipientSet := make(map[syntax.DID]struct{})
424
for _, did := range recipients {
425
// everybody except actor themselves
···
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
···
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
···
85
}
86
87
actorDid := syntax.DID(issue.Did)
88
entityType := "issue"
89
entityId := issue.AtUri().String()
90
repoId := &issue.Repo.Id
···
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,
···
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)
···
145
}
146
147
actorDid := syntax.DID(comment.Did)
148
entityType := "issue"
149
entityId := issue.AtUri().String()
150
repoId := &issue.Repo.Id
···
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,
···
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)}
···
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)
···
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,
···
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)
···
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) {
···
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
···
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,
···
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
···
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,
···
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
+57
-59
appview/notify/merged_notifier.go
+57
-59
appview/notify/merged_notifier.go
···
2
3
import (
4
"context"
5
6
"tangled.org/core/appview/models"
7
)
8
9
type mergedNotifier struct {
10
notifiers []Notifier
11
}
12
13
-
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
-
return &mergedNotifier{notifiers}
15
}
16
17
var _ Notifier = &mergedNotifier{}
18
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
22
}
23
}
24
25
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
29
}
30
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
34
}
35
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
40
}
41
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
-
for _, notifier := range m.notifiers {
43
-
notifier.NewIssueComment(ctx, comment)
44
-
}
45
}
46
47
-
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.NewIssueClosed(ctx, issue)
50
-
}
51
}
52
53
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewFollow(ctx, follow)
56
-
}
57
}
58
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.DeleteFollow(ctx, follow)
61
-
}
62
}
63
64
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
65
-
for _, notifier := range m.notifiers {
66
-
notifier.NewPull(ctx, pull)
67
-
}
68
-
}
69
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
-
for _, notifier := range m.notifiers {
71
-
notifier.NewPullComment(ctx, comment)
72
-
}
73
}
74
75
-
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
-
for _, notifier := range m.notifiers {
77
-
notifier.NewPullMerged(ctx, pull)
78
-
}
79
}
80
81
-
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
-
for _, notifier := range m.notifiers {
83
-
notifier.NewPullClosed(ctx, pull)
84
-
}
85
}
86
87
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
-
for _, notifier := range m.notifiers {
89
-
notifier.UpdateProfile(ctx, profile)
90
-
}
91
}
92
93
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
-
for _, notifier := range m.notifiers {
95
-
notifier.NewString(ctx, string)
96
-
}
97
}
98
99
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
-
for _, notifier := range m.notifiers {
101
-
notifier.EditString(ctx, string)
102
-
}
103
}
104
105
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
for _, notifier := range m.notifiers {
107
-
notifier.DeleteString(ctx, did, rkey)
108
-
}
109
}
···
2
3
import (
4
"context"
5
+
"log/slog"
6
+
"reflect"
7
+
"sync"
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/log"
12
)
13
14
type mergedNotifier struct {
15
notifiers []Notifier
16
+
logger *slog.Logger
17
}
18
19
+
func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier {
20
+
return &mergedNotifier{notifiers, logger}
21
}
22
23
var _ Notifier = &mergedNotifier{}
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
+
45
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
46
+
m.fanout("NewRepo", ctx, repo)
47
}
48
49
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
50
+
m.fanout("NewStar", ctx, star)
51
}
52
+
53
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
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)
67
+
}
68
+
69
+
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
70
+
m.fanout("DeleteIssue", ctx, issue)
71
}
72
73
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
74
+
m.fanout("NewFollow", ctx, follow)
75
}
76
+
77
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
78
+
m.fanout("DeleteFollow", ctx, follow)
79
}
80
81
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
82
+
m.fanout("NewPull", ctx, pull)
83
}
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)
91
}
92
93
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
94
+
m.fanout("UpdateProfile", ctx, profile)
95
}
96
97
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
98
+
m.fanout("NewString", ctx, s)
99
}
100
101
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
102
+
m.fanout("EditString", ctx, s)
103
}
104
105
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
+
m.fanout("DeleteString", ctx, did, rkey)
107
}
+16
-13
appview/notify/notifier.go
+16
-13
appview/notify/notifier.go
···
3
import (
4
"context"
5
6
"tangled.org/core/appview/models"
7
)
8
···
12
NewStar(ctx context.Context, star *models.Star)
13
DeleteStar(ctx context.Context, star *models.Star)
14
15
-
NewIssue(ctx context.Context, issue *models.Issue)
16
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
-
NewIssueClosed(ctx context.Context, issue *models.Issue)
18
19
NewFollow(ctx context.Context, follow *models.Follow)
20
DeleteFollow(ctx context.Context, follow *models.Follow)
21
22
NewPull(ctx context.Context, pull *models.Pull)
23
-
NewPullComment(ctx context.Context, comment *models.PullComment)
24
-
NewPullMerged(ctx context.Context, pull *models.Pull)
25
-
NewPullClosed(ctx context.Context, pull *models.Pull)
26
27
UpdateProfile(ctx context.Context, profile *models.Profile)
28
···
41
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
-
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
-
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
47
48
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
50
51
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
-
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
-
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
55
56
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
···
3
import (
4
"context"
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
"tangled.org/core/appview/models"
8
)
9
···
13
NewStar(ctx context.Context, star *models.Star)
14
DeleteStar(ctx context.Context, star *models.Star)
15
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)
20
21
NewFollow(ctx context.Context, follow *models.Follow)
22
DeleteFollow(ctx context.Context, follow *models.Follow)
23
24
NewPull(ctx context.Context, pull *models.Pull)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
+
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
27
28
UpdateProfile(ctx context.Context, profile *models.Profile)
29
···
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
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) {}
50
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
53
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) {}
58
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
60
+33
-9
appview/notify/posthog/notifier.go
+33
-9
appview/notify/posthog/notifier.go
···
4
"context"
5
"log"
6
7
"github.com/posthog/posthog-go"
8
"tangled.org/core/appview/models"
9
"tangled.org/core/appview/notify"
···
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 {
···
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 {
···
177
}
178
}
179
180
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
181
err := n.client.Enqueue(posthog.Capture{
182
DistinctId: comment.Did,
183
Event: "new_issue_comment",
184
Properties: posthog.Properties{
185
"issue_at": comment.IssueAt,
186
},
187
})
188
if err != nil {
···
190
}
191
}
192
193
-
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
194
err := n.client.Enqueue(posthog.Capture{
195
DistinctId: issue.Did,
196
-
Event: "issue_closed",
197
Properties: posthog.Properties{
198
"repo_at": issue.RepoAt.String(),
199
"issue_id": issue.IssueId,
200
},
201
})
···
204
}
205
}
206
207
-
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
208
err := n.client.Enqueue(posthog.Capture{
209
DistinctId: pull.OwnerDid,
210
-
Event: "pull_merged",
211
Properties: posthog.Properties{
212
"repo_at": pull.RepoAt,
213
"pull_id": pull.PullId,
214
},
215
})
216
if err != nil {
···
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"
···
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 {
···
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 {
···
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 {
···
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
})
···
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 {
+18
-15
appview/oauth/handler.go
+18
-15
appview/oauth/handler.go
···
4
"bytes"
5
"context"
6
"encoding/json"
7
"fmt"
8
"net/http"
9
"slices"
10
"time"
11
12
"github.com/go-chi/chi/v5"
13
-
"github.com/lestrrat-go/jwx/v2/jwk"
14
"github.com/posthog/posthog-go"
15
"tangled.org/core/api/tangled"
16
"tangled.org/core/appview/db"
···
30
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
31
doc := o.ClientApp.Config.ClientMetadata()
32
doc.JWKSURI = &o.JwksUri
33
34
w.Header().Set("Content-Type", "application/json")
35
if err := json.NewEncoder(w).Encode(doc); err != nil {
···
39
}
40
41
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
42
-
jwks := o.Config.OAuth.Jwks
43
-
pubKey, err := pubKeyFromJwk(jwks)
44
-
if err != nil {
45
-
o.Logger.Error("error parsing public key", "err", err)
46
http.Error(w, err.Error(), http.StatusInternalServerError)
47
return
48
}
49
-
50
-
response := map[string]any{
51
-
"keys": []jwk.Key{pubKey},
52
-
}
53
-
54
-
w.Header().Set("Content-Type", "application/json")
55
-
w.WriteHeader(http.StatusOK)
56
-
json.NewEncoder(w).Encode(response)
57
}
58
59
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
60
ctx := r.Context()
61
62
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
63
if err != nil {
64
-
http.Error(w, err.Error(), http.StatusInternalServerError)
65
return
66
}
67
68
if err := o.SaveSession(w, r, sessData); err != nil {
69
-
http.Error(w, err.Error(), http.StatusInternalServerError)
70
return
71
}
72
···
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"
···
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 {
···
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
+35
-22
appview/oauth/oauth.go
+35
-22
appview/oauth/oauth.go
···
10
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
atpclient "github.com/bluesky-social/indigo/atproto/client"
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
xrpc "github.com/bluesky-social/indigo/xrpc"
15
"github.com/gorilla/sessions"
16
-
"github.com/lestrrat-go/jwx/v2/jwk"
17
"github.com/posthog/posthog-go"
18
"tangled.org/core/appview/config"
19
"tangled.org/core/appview/db"
···
26
SessStore *sessions.CookieStore
27
Config *config.Config
28
JwksUri string
29
Posthog posthog.Client
30
Db *db.DB
31
Enforcer *rbac.Enforcer
···
34
}
35
36
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
37
-
38
var oauthConfig oauth.ClientConfig
39
var clientUri string
40
-
41
if config.Core.Dev {
42
clientUri = "http://127.0.0.1:3000"
43
callbackUri := clientUri + "/oauth/callback"
···
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"})
50
}
51
52
jwksUri := clientUri + "/oauth/jwks.json"
53
54
-
authStore, err := NewRedisStore(config.Redis.ToURL())
55
if err != nil {
56
return nil, err
57
}
58
59
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
60
61
return &OAuth{
62
-
ClientApp: oauth.NewClientApp(&oauthConfig, authStore),
63
Config: config,
64
SessStore: sessStore,
65
JwksUri: jwksUri,
66
Posthog: ph,
67
Db: db,
68
Enforcer: enforcer,
···
137
return errors.Join(err1, err2)
138
}
139
140
-
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
141
-
k, err := jwk.ParseKey([]byte(jwks))
142
-
if err != nil {
143
-
return nil, err
144
-
}
145
-
pubKey, err := k.PublicKey()
146
-
if err != nil {
147
-
return nil, err
148
-
}
149
-
return pubKey, nil
150
-
}
151
-
152
type User struct {
153
Did string
154
Pds string
155
}
156
157
func (o *OAuth) GetUser(r *http.Request) *User {
158
-
sess, err := o.SessStore.Get(r, SessionName)
159
-
160
-
if err != nil || sess.IsNew {
161
return nil
162
}
163
164
return &User{
165
-
Did: sess.Values[SessionDid].(string),
166
-
Pds: sess.Values[SessionPds].(string),
167
}
168
}
169
···
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"
16
"github.com/gorilla/sessions"
17
"github.com/posthog/posthog-go"
18
"tangled.org/core/appview/config"
19
"tangled.org/core/appview/db"
···
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
···
36
}
37
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"
···
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"})
50
+
}
51
+
52
+
// configure client secret
53
+
priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
58
+
return nil, err
59
}
60
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
+
})
69
if err != nil {
70
return nil, err
71
}
72
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
80
+
}
81
+
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,
···
163
return errors.Join(err1, err2)
164
}
165
166
type User struct {
167
Did string
168
Pds string
169
}
170
171
func (o *OAuth) GetUser(r *http.Request) *User {
172
+
sess, err := o.ResumeSession(r)
173
+
if err != nil {
174
return nil
175
}
176
177
return &User{
178
+
Did: sess.Data.AccountDID.String(),
179
+
Pds: sess.Data.HostURL,
180
}
181
}
182
+110
-11
appview/oauth/store.go
+110
-11
appview/oauth/store.go
···
11
"github.com/redis/go-redis/v9"
12
)
13
14
// redis-backed implementation of ClientAuthStore.
15
type RedisStore struct {
16
-
client *redis.Client
17
-
SessionTTL time.Duration
18
-
AuthRequestTTL time.Duration
19
}
20
21
var _ oauth.ClientAuthStore = &RedisStore{}
22
23
-
func NewRedisStore(redisURL string) (*RedisStore, error) {
24
-
opts, err := redis.ParseURL(redisURL)
25
if err != nil {
26
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
27
}
···
37
}
38
39
return &RedisStore{
40
-
client: client,
41
-
SessionTTL: 30 * 24 * time.Hour, // 30 days
42
-
AuthRequestTTL: 10 * time.Minute, // 10 minutes
43
}, nil
44
}
45
···
51
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
52
}
53
54
func authRequestKey(state string) string {
55
return fmt.Sprintf("oauth:auth_request:%s", state)
56
}
57
58
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
59
key := sessionKey(did, sessionID)
60
data, err := r.client.Get(ctx, key).Bytes()
61
if err == redis.Nil {
62
return nil, fmt.Errorf("session not found: %s", did)
···
75
76
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
77
key := sessionKey(sess.AccountDID, sess.SessionID)
78
79
data, err := json.Marshal(sess)
80
if err != nil {
81
return fmt.Errorf("failed to marshal session: %w", err)
82
}
83
84
-
if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil {
85
return fmt.Errorf("failed to save session: %w", err)
86
}
87
88
return nil
89
}
90
91
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
92
key := sessionKey(did, sessionID)
93
-
if err := r.client.Del(ctx, key).Err(); err != nil {
94
return fmt.Errorf("failed to delete session: %w", err)
95
}
96
return nil
···
131
return fmt.Errorf("failed to marshal auth request: %w", err)
132
}
133
134
-
if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil {
135
return fmt.Errorf("failed to save auth request: %w", err)
136
}
137
···
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
}
···
67
}
68
69
return &RedisStore{
70
+
client: client,
71
+
cfg: cfg,
72
}, nil
73
}
74
···
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)
···
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
···
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
+59
-10
appview/ogcard/card.go
+59
-10
appview/ogcard/card.go
···
7
import (
8
"bytes"
9
"fmt"
10
"image"
11
"image/color"
12
"io"
···
279
return width, nil
280
}
281
282
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
-
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
-
svgData, err := pages.Files.ReadFile(svgPath)
285
-
if err != nil {
286
-
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
-
}
288
-
289
// Convert color to hex string for SVG
290
rgba, isRGBA := iconColor.(color.RGBA)
291
if !isRGBA {
···
304
// Parse SVG
305
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
if err != nil {
307
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
}
309
310
// Set the icon size
311
w, h := float64(size), float64(size)
312
icon.SetTarget(0, 0, w, h)
···
334
}
335
336
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
-
338
-
return nil
339
}
340
341
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
···
7
import (
8
"bytes"
9
"fmt"
10
+
"html/template"
11
"image"
12
"image/color"
13
"io"
···
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 {
···
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)
···
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
+65
-6
appview/pages/funcmap.go
+65
-6
appview/pages/funcmap.go
···
1
package pages
2
3
import (
4
"context"
5
"crypto/hmac"
6
"crypto/sha256"
···
17
"strings"
18
"time"
19
20
"github.com/dustin/go-humanize"
21
"github.com/go-enry/go-enry/v2"
22
"tangled.org/core/appview/filetree"
···
38
"contains": func(s string, target string) bool {
39
return strings.Contains(s, target)
40
},
41
"mapContains": func(m any, key any) bool {
42
mapValue := reflect.ValueOf(m)
43
if mapValue.Kind() != reflect.Map {
···
57
return "handle.invalid"
58
}
59
60
-
return "@" + identity.Handle.String()
61
},
62
"truncateAt30": func(s string) string {
63
if len(s) <= 30 {
···
67
},
68
"splitOn": func(s, sep string) []string {
69
return strings.Split(s, sep)
70
},
71
"int64": func(a int) int64 {
72
return int64(a)
···
117
return b
118
},
119
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
122
} else {
123
return did
124
}
···
236
sanitized := p.rctx.SanitizeDescription(htmlString)
237
return template.HTML(sanitized)
238
},
239
"isNil": func(t any) bool {
240
// returns false for other "zero" values
241
return t == nil
···
281
u, _ := url.PathUnescape(s)
282
return u
283
},
284
-
285
"tinyAvatar": func(handle string) string {
286
return p.AvatarUrl(handle, "tiny")
287
},
···
297
},
298
299
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
302
},
303
"sshFingerprint": func(pubKey string) string {
304
fp, err := crypto.SSHFingerprint(pubKey)
···
1
package pages
2
3
import (
4
+
"bytes"
5
"context"
6
"crypto/hmac"
7
"crypto/sha256"
···
18
"strings"
19
"time"
20
21
+
"github.com/alecthomas/chroma/v2"
22
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23
+
"github.com/alecthomas/chroma/v2/lexers"
24
+
"github.com/alecthomas/chroma/v2/styles"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
26
"github.com/dustin/go-humanize"
27
"github.com/go-enry/go-enry/v2"
28
"tangled.org/core/appview/filetree"
···
44
"contains": func(s string, target string) bool {
45
return strings.Contains(s, target)
46
},
47
+
"stripPort": func(hostname string) string {
48
+
if strings.Contains(hostname, ":") {
49
+
return strings.Split(hostname, ":")[0]
50
+
}
51
+
return hostname
52
+
},
53
"mapContains": func(m any, key any) bool {
54
mapValue := reflect.ValueOf(m)
55
if mapValue.Kind() != reflect.Map {
···
69
return "handle.invalid"
70
}
71
72
+
return identity.Handle.String()
73
},
74
"truncateAt30": func(s string) string {
75
if len(s) <= 30 {
···
79
},
80
"splitOn": func(s, sep string) []string {
81
return strings.Split(s, sep)
82
+
},
83
+
"string": func(v any) string {
84
+
return fmt.Sprint(v)
85
},
86
"int64": func(a int) int64 {
87
return int64(a)
···
132
return b
133
},
134
"didOrHandle": func(did, handle string) string {
135
+
if handle != "" && handle != syntax.HandleInvalid.String() {
136
+
return handle
137
} else {
138
return did
139
}
···
251
sanitized := p.rctx.SanitizeDescription(htmlString)
252
return template.HTML(sanitized)
253
},
254
+
"readme": func(text string) template.HTML {
255
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
256
+
htmlString := p.rctx.RenderMarkdown(text)
257
+
sanitized := p.rctx.SanitizeDefault(htmlString)
258
+
return template.HTML(sanitized)
259
+
},
260
+
"code": func(content, path string) string {
261
+
var style *chroma.Style = styles.Get("catpuccin-latte")
262
+
formatter := chromahtml.New(
263
+
chromahtml.InlineCode(false),
264
+
chromahtml.WithLineNumbers(true),
265
+
chromahtml.WithLinkableLineNumbers(true, "L"),
266
+
chromahtml.Standalone(false),
267
+
chromahtml.WithClasses(true),
268
+
)
269
+
270
+
lexer := lexers.Get(filepath.Base(path))
271
+
if lexer == nil {
272
+
lexer = lexers.Fallback
273
+
}
274
+
275
+
iterator, err := lexer.Tokenise(nil, content)
276
+
if err != nil {
277
+
p.logger.Error("chroma tokenize", "err", "err")
278
+
return ""
279
+
}
280
+
281
+
var code bytes.Buffer
282
+
err = formatter.Format(&code, style, iterator)
283
+
if err != nil {
284
+
p.logger.Error("chroma format", "err", "err")
285
+
return ""
286
+
}
287
+
288
+
return code.String()
289
+
},
290
+
"trimUriScheme": func(text string) string {
291
+
text = strings.TrimPrefix(text, "https://")
292
+
text = strings.TrimPrefix(text, "http://")
293
+
return text
294
+
},
295
"isNil": func(t any) bool {
296
// returns false for other "zero" values
297
return t == nil
···
337
u, _ := url.PathUnescape(s)
338
return u
339
},
340
+
"safeUrl": func(s string) template.URL {
341
+
return template.URL(s)
342
+
},
343
"tinyAvatar": func(handle string) string {
344
return p.AvatarUrl(handle, "tiny")
345
},
···
355
},
356
357
"normalizeForHtmlId": func(s string) string {
358
+
normalized := strings.ReplaceAll(s, ":", "_")
359
+
normalized = strings.ReplaceAll(normalized, ".", "_")
360
+
return normalized
361
},
362
"sshFingerprint": func(pubKey string) string {
363
fp, err := crypto.SSHFingerprint(pubKey)
+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
+
}
+32
-1
appview/pages/markup/markdown.go
+32
-1
appview/pages/markup/markdown.go
···
25
htmlparse "golang.org/x/net/html"
26
27
"tangled.org/core/api/tangled"
28
"tangled.org/core/appview/pages/repoinfo"
29
)
30
···
50
Files fs.FS
51
}
52
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
md := goldmark.New(
55
goldmark.WithExtensions(
56
extension.GFM,
···
66
),
67
treeblood.MathML(),
68
callout.CalloutExtention,
69
),
70
goldmark.WithParserOptions(
71
parser.WithAutoHeadingID(),
72
),
73
goldmark.WithRendererOptions(html.WithUnsafe()),
74
)
75
76
if rctx != nil {
77
var transformers []util.PrioritizedValue
···
293
}
294
295
return path.Join(rctx.CurrentDir, dst)
296
}
297
298
func isAbsoluteUrl(link string) bool {
···
25
htmlparse "golang.org/x/net/html"
26
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
29
"tangled.org/core/appview/pages/repoinfo"
30
)
31
···
51
Files fs.FS
52
}
53
54
+
func NewMarkdown() goldmark.Markdown {
55
md := goldmark.New(
56
goldmark.WithExtensions(
57
extension.GFM,
···
67
),
68
treeblood.MathML(),
69
callout.CalloutExtention,
70
+
textension.AtExt,
71
),
72
goldmark.WithParserOptions(
73
parser.WithAutoHeadingID(),
74
),
75
goldmark.WithRendererOptions(html.WithUnsafe()),
76
)
77
+
return md
78
+
}
79
+
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
md := NewMarkdown()
82
83
if rctx != nil {
84
var transformers []util.PrioritizedValue
···
300
}
301
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
327
}
328
329
func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
···
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
80
// centering content
81
policy.AllowElements("center")
82
···
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
80
+
// at-mentions
81
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
+
83
// centering content
84
policy.AllowElements("center")
85
+46
-130
appview/pages/pages.go
+46
-130
appview/pages/pages.go
···
1
package pages
2
3
import (
4
-
"bytes"
5
"crypto/sha256"
6
"embed"
7
"encoding/hex"
···
15
"path/filepath"
16
"strings"
17
"sync"
18
19
"tangled.org/core/api/tangled"
20
"tangled.org/core/appview/commitverify"
···
28
"tangled.org/core/patchutil"
29
"tangled.org/core/types"
30
31
-
"github.com/alecthomas/chroma/v2"
32
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
33
-
"github.com/alecthomas/chroma/v2/lexers"
34
-
"github.com/alecthomas/chroma/v2/styles"
35
"github.com/bluesky-social/indigo/atproto/identity"
36
"github.com/bluesky-social/indigo/atproto/syntax"
37
"github.com/go-git/go-git/v5/plumbing"
···
221
222
type LoginParams struct {
223
ReturnUrl string
224
}
225
226
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
638
return p.executePlain("repo/fragments/repoStar", w, params)
639
}
640
641
-
type RepoDescriptionParams struct {
642
-
RepoInfo repoinfo.RepoInfo
643
-
}
644
-
645
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
646
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
647
-
}
648
-
649
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
650
-
return p.executePlain("repo/fragments/repoDescription", w, params)
651
-
}
652
-
653
type RepoIndexParams struct {
654
LoggedInUser *oauth.User
655
RepoInfo repoinfo.RepoInfo
···
659
TagsTrunc []*types.TagReference
660
BranchesTrunc []types.Branch
661
// ForkInfo *types.ForkInfo
662
-
HTMLReadme template.HTML
663
-
Raw bool
664
-
EmailToDidOrHandle map[string]string
665
-
VerifiedCommits commitverify.VerifiedCommits
666
-
Languages []types.RepoLanguageDetails
667
-
Pipelines map[string]models.Pipeline
668
-
NeedsKnotUpgrade bool
669
types.RepoIndexResponse
670
}
671
···
700
}
701
702
type RepoLogParams struct {
703
-
LoggedInUser *oauth.User
704
-
RepoInfo repoinfo.RepoInfo
705
-
TagMap map[string][]string
706
types.RepoLogResponse
707
-
Active string
708
-
EmailToDidOrHandle map[string]string
709
-
VerifiedCommits commitverify.VerifiedCommits
710
-
Pipelines map[string]models.Pipeline
711
}
712
713
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
716
}
717
718
type RepoCommitParams struct {
719
-
LoggedInUser *oauth.User
720
-
RepoInfo repoinfo.RepoInfo
721
-
Active string
722
-
EmailToDidOrHandle map[string]string
723
-
Pipeline *models.Pipeline
724
-
DiffOpts types.DiffOpts
725
726
// singular because it's always going to be just one
727
VerifiedCommit commitverify.VerifiedCommits
···
753
func (r RepoTreeParams) TreeStats() RepoTreeStats {
754
numFolders, numFiles := 0, 0
755
for _, f := range r.Files {
756
-
if !f.IsFile {
757
numFolders += 1
758
-
} else if f.IsFile {
759
numFiles += 1
760
}
761
}
···
826
}
827
828
type RepoBlobParams struct {
829
-
LoggedInUser *oauth.User
830
-
RepoInfo repoinfo.RepoInfo
831
-
Active string
832
-
Unsupported bool
833
-
IsImage bool
834
-
IsVideo bool
835
-
ContentSrc string
836
-
BreadCrumbs [][]string
837
-
ShowRendered bool
838
-
RenderToggle bool
839
-
RenderedContents template.HTML
840
*tangled.RepoBlob_Output
841
-
// Computed fields for template compatibility
842
-
Contents string
843
-
Lines int
844
-
SizeHint uint64
845
-
IsBinary bool
846
}
847
848
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
849
-
var style *chroma.Style = styles.Get("catpuccin-latte")
850
-
851
-
if params.ShowRendered {
852
-
switch markup.GetFormat(params.Path) {
853
-
case markup.FormatMarkdown:
854
-
p.rctx.RepoInfo = params.RepoInfo
855
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
856
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
857
-
sanitized := p.rctx.SanitizeDefault(htmlString)
858
-
params.RenderedContents = template.HTML(sanitized)
859
-
}
860
}
861
862
-
c := params.Contents
863
-
formatter := chromahtml.New(
864
-
chromahtml.InlineCode(false),
865
-
chromahtml.WithLineNumbers(true),
866
-
chromahtml.WithLinkableLineNumbers(true, "L"),
867
-
chromahtml.Standalone(false),
868
-
chromahtml.WithClasses(true),
869
-
)
870
-
871
-
lexer := lexers.Get(filepath.Base(params.Path))
872
-
if lexer == nil {
873
-
lexer = lexers.Fallback
874
-
}
875
-
876
-
iterator, err := lexer.Tokenise(nil, c)
877
-
if err != nil {
878
-
return fmt.Errorf("chroma tokenize: %w", err)
879
-
}
880
-
881
-
var code bytes.Buffer
882
-
err = formatter.Format(&code, style, iterator)
883
-
if err != nil {
884
-
return fmt.Errorf("chroma format: %w", err)
885
-
}
886
-
887
-
params.Contents = code.String()
888
params.Active = "overview"
889
return p.executeRepo("repo/blob", w, params)
890
}
···
970
LabelDefs map[string]*models.LabelDefinition
971
Page pagination.Page
972
FilteringByOpen bool
973
}
974
975
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
1100
Pulls []*models.Pull
1101
Active string
1102
FilteringBy models.PullState
1103
Stacks map[string]models.Stack
1104
Pipelines map[string]models.Pipeline
1105
LabelDefs map[string]*models.LabelDefinition
···
1357
Name string
1358
Command string
1359
Collapsed bool
1360
}
1361
1362
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1363
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1364
}
1365
1366
type LogLineParams struct {
1367
Id int
1368
Content string
···
1428
}
1429
1430
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1431
-
var style *chroma.Style = styles.Get("catpuccin-latte")
1432
-
1433
-
if params.ShowRendered {
1434
-
switch markup.GetFormat(params.String.Filename) {
1435
-
case markup.FormatMarkdown:
1436
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1437
-
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1438
-
sanitized := p.rctx.SanitizeDefault(htmlString)
1439
-
params.RenderedContents = template.HTML(sanitized)
1440
-
}
1441
-
}
1442
-
1443
-
c := params.String.Contents
1444
-
formatter := chromahtml.New(
1445
-
chromahtml.InlineCode(false),
1446
-
chromahtml.WithLineNumbers(true),
1447
-
chromahtml.WithLinkableLineNumbers(true, "L"),
1448
-
chromahtml.Standalone(false),
1449
-
chromahtml.WithClasses(true),
1450
-
)
1451
-
1452
-
lexer := lexers.Get(filepath.Base(params.String.Filename))
1453
-
if lexer == nil {
1454
-
lexer = lexers.Fallback
1455
-
}
1456
-
1457
-
iterator, err := lexer.Tokenise(nil, c)
1458
-
if err != nil {
1459
-
return fmt.Errorf("chroma tokenize: %w", err)
1460
-
}
1461
-
1462
-
var code bytes.Buffer
1463
-
err = formatter.Format(&code, style, iterator)
1464
-
if err != nil {
1465
-
return fmt.Errorf("chroma format: %w", err)
1466
-
}
1467
-
1468
-
params.String.Contents = code.String()
1469
return p.execute("strings/string", w, params)
1470
}
1471
···
1
package pages
2
3
import (
4
"crypto/sha256"
5
"embed"
6
"encoding/hex"
···
14
"path/filepath"
15
"strings"
16
"sync"
17
+
"time"
18
19
"tangled.org/core/api/tangled"
20
"tangled.org/core/appview/commitverify"
···
28
"tangled.org/core/patchutil"
29
"tangled.org/core/types"
30
31
"github.com/bluesky-social/indigo/atproto/identity"
32
"github.com/bluesky-social/indigo/atproto/syntax"
33
"github.com/go-git/go-git/v5/plumbing"
···
217
218
type LoginParams struct {
219
ReturnUrl string
220
+
ErrorCode string
221
}
222
223
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
635
return p.executePlain("repo/fragments/repoStar", w, params)
636
}
637
638
type RepoIndexParams struct {
639
LoggedInUser *oauth.User
640
RepoInfo repoinfo.RepoInfo
···
644
TagsTrunc []*types.TagReference
645
BranchesTrunc []types.Branch
646
// ForkInfo *types.ForkInfo
647
+
HTMLReadme template.HTML
648
+
Raw bool
649
+
EmailToDid map[string]string
650
+
VerifiedCommits commitverify.VerifiedCommits
651
+
Languages []types.RepoLanguageDetails
652
+
Pipelines map[string]models.Pipeline
653
+
NeedsKnotUpgrade bool
654
types.RepoIndexResponse
655
}
656
···
685
}
686
687
type RepoLogParams struct {
688
+
LoggedInUser *oauth.User
689
+
RepoInfo repoinfo.RepoInfo
690
+
TagMap map[string][]string
691
+
Active string
692
+
EmailToDid map[string]string
693
+
VerifiedCommits commitverify.VerifiedCommits
694
+
Pipelines map[string]models.Pipeline
695
+
696
types.RepoLogResponse
697
}
698
699
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
702
}
703
704
type RepoCommitParams struct {
705
+
LoggedInUser *oauth.User
706
+
RepoInfo repoinfo.RepoInfo
707
+
Active string
708
+
EmailToDid map[string]string
709
+
Pipeline *models.Pipeline
710
+
DiffOpts types.DiffOpts
711
712
// singular because it's always going to be just one
713
VerifiedCommit commitverify.VerifiedCommits
···
739
func (r RepoTreeParams) TreeStats() RepoTreeStats {
740
numFolders, numFiles := 0, 0
741
for _, f := range r.Files {
742
+
if !f.IsFile() {
743
numFolders += 1
744
+
} else if f.IsFile() {
745
numFiles += 1
746
}
747
}
···
812
}
813
814
type RepoBlobParams struct {
815
+
LoggedInUser *oauth.User
816
+
RepoInfo repoinfo.RepoInfo
817
+
Active string
818
+
BreadCrumbs [][]string
819
+
BlobView models.BlobView
820
*tangled.RepoBlob_Output
821
}
822
823
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
824
+
switch params.BlobView.ContentType {
825
+
case models.BlobContentTypeMarkup:
826
+
p.rctx.RepoInfo = params.RepoInfo
827
}
828
829
params.Active = "overview"
830
return p.executeRepo("repo/blob", w, params)
831
}
···
911
LabelDefs map[string]*models.LabelDefinition
912
Page pagination.Page
913
FilteringByOpen bool
914
+
FilterQuery string
915
}
916
917
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
1042
Pulls []*models.Pull
1043
Active string
1044
FilteringBy models.PullState
1045
+
FilterQuery string
1046
Stacks map[string]models.Stack
1047
Pipelines map[string]models.Pipeline
1048
LabelDefs map[string]*models.LabelDefinition
···
1300
Name string
1301
Command string
1302
Collapsed bool
1303
+
StartTime time.Time
1304
}
1305
1306
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1307
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1308
}
1309
1310
+
type LogBlockEndParams struct {
1311
+
Id int
1312
+
StartTime time.Time
1313
+
EndTime time.Time
1314
+
}
1315
+
1316
+
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1317
+
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1318
+
}
1319
+
1320
type LogLineParams struct {
1321
Id int
1322
Content string
···
1382
}
1383
1384
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1385
return p.execute("strings/string", w, params)
1386
}
1387
+7
-7
appview/pages/repoinfo/repoinfo.go
+7
-7
appview/pages/repoinfo/repoinfo.go
···
1
package repoinfo
2
3
import (
4
-
"fmt"
5
"path"
6
"slices"
7
-
"strings"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/models"
11
"tangled.org/core/appview/state/userutil"
12
)
13
14
-
func (r RepoInfo) OwnerWithAt() string {
15
if r.OwnerHandle != "" {
16
-
return fmt.Sprintf("@%s", r.OwnerHandle)
17
} else {
18
return r.OwnerDid
19
}
20
}
21
22
func (r RepoInfo) FullName() string {
23
-
return path.Join(r.OwnerWithAt(), r.Name)
24
}
25
26
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
-
return after
29
} else {
30
return userutil.FlattenDid(r.OwnerDid)
31
}
···
56
OwnerDid string
57
OwnerHandle string
58
Description string
59
Knot string
60
Spindle string
61
RepoAt syntax.ATURI
···
1
package repoinfo
2
3
import (
4
"path"
5
"slices"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
"tangled.org/core/appview/models"
9
"tangled.org/core/appview/state/userutil"
10
)
11
12
+
func (r RepoInfo) Owner() string {
13
if r.OwnerHandle != "" {
14
+
return r.OwnerHandle
15
} else {
16
return r.OwnerDid
17
}
18
}
19
20
func (r RepoInfo) FullName() string {
21
+
return path.Join(r.Owner(), r.Name)
22
}
23
24
func (r RepoInfo) OwnerWithoutAt() string {
25
+
if r.OwnerHandle != "" {
26
+
return r.OwnerHandle
27
} else {
28
return userutil.FlattenDid(r.OwnerDid)
29
}
···
54
OwnerDid string
55
OwnerHandle string
56
Description string
57
+
Website string
58
+
Topics []string
59
Knot string
60
Spindle string
61
RepoAt syntax.ATURI
+82
-54
appview/pages/templates/fragments/dolly/logo.html
+82
-54
appview/pages/templates/fragments/dolly/logo.html
···
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>
56
{{ end }}
···
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_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>
84
{{ end }}
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
···
2
<svg
3
version="1.1"
4
id="svg1"
5
-
width="32"
6
-
height="32"
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_silhouette.png"
9
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
xmlns="http://www.w3.org/2000/svg"
12
-
xmlns:svg="http://www.w3.org/2000/svg">
13
-
<style>
14
-
.dolly {
15
-
color: #000000;
16
-
}
17
18
-
@media (prefers-color-scheme: dark) {
19
-
.dolly {
20
-
color: #ffffff;
21
-
}
22
-
}
23
-
</style>
24
-
<title>Dolly</title>
25
-
<defs
26
-
id="defs1" />
27
<sodipodi:namedview
28
id="namedview1"
29
pagecolor="#ffffff"
···
32
inkscape:showpageshadow="2"
33
inkscape:pageopacity="0.0"
34
inkscape:pagecheckerboard="true"
35
-
inkscape:deskcolor="#d1d1d1">
36
<inkscape:page
37
x="0"
38
y="0"
···
45
<g
46
inkscape:groupmode="layer"
47
inkscape:label="Image"
48
-
id="g1">
49
<path
50
class="dolly"
51
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" />
55
</g>
56
</svg>
57
{{ end }}
···
2
<svg
3
version="1.1"
4
id="svg1"
5
+
width="25"
6
+
height="25"
7
viewBox="0 0 25 25"
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)"
13
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
xmlns="http://www.w3.org/2000/svg"
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
+
}
23
24
+
@media (prefers-color-scheme: dark) {
25
+
.dolly {
26
+
color: #ffffff;
27
+
}
28
+
}
29
+
</style>
30
<sodipodi:namedview
31
id="namedview1"
32
pagecolor="#ffffff"
···
35
inkscape:showpageshadow="2"
36
inkscape:pageopacity="0.0"
37
inkscape:pagecheckerboard="true"
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">
49
<inkscape:page
50
x="0"
51
y="0"
···
58
<g
59
inkscape:groupmode="layer"
60
inkscape:label="Image"
61
+
id="g1"
62
+
transform="translate(-0.42924038,-0.87777209)">
63
<path
64
class="dolly"
65
fill="currentColor"
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" />
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>
94
</svg>
95
{{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
-44
appview/pages/templates/fragments/dolly/silhouette.svg
···
1
-
<svg
2
-
version="1.1"
3
-
id="svg1"
4
-
width="32"
5
-
height="32"
6
-
viewBox="0 0 25 25"
7
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
9
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
-
xmlns="http://www.w3.org/2000/svg"
11
-
xmlns:svg="http://www.w3.org/2000/svg">
12
-
<title>Dolly</title>
13
-
<defs
14
-
id="defs1" />
15
-
<sodipodi:namedview
16
-
id="namedview1"
17
-
pagecolor="#ffffff"
18
-
bordercolor="#000000"
19
-
borderopacity="0.25"
20
-
inkscape:showpageshadow="2"
21
-
inkscape:pageopacity="0.0"
22
-
inkscape:pagecheckerboard="true"
23
-
inkscape:deskcolor="#d1d1d1">
24
-
<inkscape:page
25
-
x="0"
26
-
y="0"
27
-
width="25"
28
-
height="25"
29
-
id="page2"
30
-
margin="0"
31
-
bleed="0" />
32
-
</sodipodi:namedview>
33
-
<g
34
-
inkscape:groupmode="layer"
35
-
inkscape:label="Image"
36
-
id="g1">
37
-
<path
38
-
class="dolly"
39
-
fill="currentColor"
40
-
style="stroke-width:1.12248"
41
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
42
-
id="path1" />
43
-
</g>
44
-
</svg>
···
+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 }}
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Id }}"
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">
17
{{ block "addKnotMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
···
29
ADD MEMBER
30
</label>
31
<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
-
/>
39
<div class="flex gap-2 pt-2">
40
<button
41
type="button"
···
54
</div>
55
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
</form>
57
-
{{ end }}
···
13
<div
14
id="add-member-{{ .Id }}"
15
popover
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">
19
{{ block "addKnotMemberPopover" . }} {{ end }}
20
</div>
21
{{ end }}
···
31
ADD MEMBER
32
</label>
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
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>
47
<div class="flex gap-2 pt-2">
48
<button
49
type="button"
···
62
</div>
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
64
</form>
65
+
{{ end }}
+1
appview/pages/templates/layouts/base.html
+1
appview/pages/templates/layouts/base.html
+5
-9
appview/pages/templates/layouts/fragments/topbar.html
+5
-9
appview/pages/templates/layouts/fragments/topbar.html
···
15
{{ with .LoggedInUser }}
16
{{ block "newButton" . }} {{ end }}
17
{{ template "notifications/fragments/bell" }}
18
-
{{ block "dropDown" . }} {{ end }}
19
{{ else }}
20
<a href="/login">login</a>
21
<span class="text-gray-500 dark:text-gray-400">or</span>
···
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
</summary>
36
-
<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">
37
<a href="/repo/new" class="flex items-center gap-2">
38
{{ i "book-plus" "w-4 h-4" }}
39
new repository
···
46
</details>
47
{{ end }}
48
49
-
{{ define "dropDown" }}
50
<details class="relative inline-block text-left nav-dropdown">
51
-
<summary
52
-
class="cursor-pointer list-none flex items-center gap-1"
53
-
>
54
{{ $user := .Did }}
55
<img
56
src="{{ tinyAvatar $user }}"
···
59
/>
60
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
61
</summary>
62
-
<div
63
-
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"
64
-
>
65
<a href="/{{ $user }}">profile</a>
66
<a href="/{{ $user }}?tab=repos">repositories</a>
67
<a href="/{{ $user }}?tab=strings">strings</a>
···
15
{{ with .LoggedInUser }}
16
{{ block "newButton" . }} {{ end }}
17
{{ template "notifications/fragments/bell" }}
18
+
{{ block "profileDropdown" . }} {{ end }}
19
{{ else }}
20
<a href="/login">login</a>
21
<span class="text-gray-500 dark:text-gray-400">or</span>
···
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
</summary>
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">
37
<a href="/repo/new" class="flex items-center gap-2">
38
{{ i "book-plus" "w-4 h-4" }}
39
new repository
···
46
</details>
47
{{ end }}
48
49
+
{{ define "profileDropdown" }}
50
<details class="relative inline-block text-left nav-dropdown">
51
+
<summary class="cursor-pointer list-none flex items-center gap-1">
52
{{ $user := .Did }}
53
<img
54
src="{{ tinyAvatar $user }}"
···
57
/>
58
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
59
</summary>
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">
61
<a href="/{{ $user }}">profile</a>
62
<a href="/{{ $user }}?tab=repos">repositories</a>
63
<a href="/{{ $user }}?tab=strings">strings</a>
+53
-25
appview/pages/templates/layouts/repobase.html
+53
-25
appview/pages/templates/layouts/repobase.html
···
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
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>
20
</div>
21
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>
29
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
<a
31
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
···
36
fork
37
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
</a>
39
</div>
40
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
42
</section>
43
44
<section class="w-full flex flex-col" >
···
79
</div>
80
</nav>
81
{{ block "repoContentLayout" . }}
82
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
83
{{ block "repoContent" . }}{{ end }}
84
</section>
85
{{ block "repoAfter" . }}{{ end }}
···
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "content" }}
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>
49
</div>
50
51
+
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
52
{{ template "repo/fragments/repoStar" .RepoInfo }}
53
<a
54
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
···
59
fork
60
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
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>
68
</div>
69
</div>
70
</section>
71
72
<section class="w-full flex flex-col" >
···
107
</div>
108
</nav>
109
{{ block "repoContentLayout" . }}
110
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
111
{{ block "repoContent" . }}{{ end }}
112
</section>
113
{{ block "repoAfter" . }}{{ end }}
+6
appview/pages/templates/notifications/fragments/item.html
+6
appview/pages/templates/notifications/fragments/item.html
···
40
commented on an issue
41
{{ else if eq .Type "issue_closed" }}
42
closed an issue
43
{{ else if eq .Type "pull_created" }}
44
created a pull request
45
{{ else if eq .Type "pull_commented" }}
···
48
merged a pull request
49
{{ else if eq .Type "pull_closed" }}
50
closed a pull request
51
{{ else if eq .Type "followed" }}
52
followed you
53
{{ else }}
54
{{ end }}
55
{{ end }}
···
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" }}
···
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
-39
appview/pages/templates/repo/blob.html
+62
-39
appview/pages/templates/repo/blob.html
···
11
{{ end }}
12
13
{{ define "repoContent" }}
14
-
{{ $lines := split .Contents }}
15
-
{{ $tot_lines := len $lines }}
16
-
{{ $tot_chars := len (printf "%d" $tot_lines) }}
17
-
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
18
{{ $linkstyle := "no-underline hover:underline" }}
19
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
20
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
36
</div>
37
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
38
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
39
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
40
-
<span>{{ .Lines }} lines</span>
41
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
42
-
<span>{{ byteFmt .SizeHint }}</span>
43
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
44
-
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
-
{{ if .RenderToggle }}
46
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
-
hx-boost="true"
50
-
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
{{ end }}
52
</div>
53
</div>
54
</div>
55
-
{{ if and .IsBinary .Unsupported }}
56
-
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
Previews are not supported for this file type.
58
-
</p>
59
-
{{ else if .IsBinary }}
60
-
<div class="text-center">
61
-
{{ if .IsImage }}
62
-
<img src="{{ .ContentSrc }}"
63
-
alt="{{ .Path }}"
64
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65
-
{{ else if .IsVideo }}
66
-
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67
-
<source src="{{ .ContentSrc }}">
68
-
Your browser does not support the video tag.
69
-
</video>
70
-
{{ end }}
71
-
</div>
72
-
{{ else }}
73
-
<div class="overflow-auto relative">
74
-
{{ if .ShowRendered }}
75
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
76
{{ else }}
77
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
78
{{ end }}
79
-
</div>
80
{{ end }}
81
{{ template "fragments/multiline-select" }}
82
{{ end }}
···
11
{{ end }}
12
13
{{ define "repoContent" }}
14
{{ $linkstyle := "no-underline hover:underline" }}
15
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
16
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
32
</div>
33
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
34
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
35
+
36
+
{{ if .BlobView.ShowingText }}
37
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
38
+
<span>{{ .Lines }} lines</span>
39
+
{{ end }}
40
+
41
+
{{ if .BlobView.SizeHint }}
42
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
43
+
<span>{{ byteFmt .BlobView.SizeHint }}</span>
44
+
{{ end }}
45
+
46
+
{{ if .BlobView.HasRawView }}
47
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
48
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
49
+
{{ end }}
50
+
51
+
{{ if .BlobView.ShowToggle }}
52
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
53
+
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true">
54
+
view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }}
55
+
</a>
56
{{ end }}
57
</div>
58
</div>
59
</div>
60
+
{{ if .BlobView.IsUnsupported }}
61
+
<p class="text-center text-gray-400 dark:text-gray-500">
62
+
Previews are not supported for this file type.
63
+
</p>
64
+
{{ else if .BlobView.ContentType.IsSubmodule }}
65
+
<p class="text-center text-gray-400 dark:text-gray-500">
66
+
This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>.
67
+
</p>
68
+
{{ else if .BlobView.ContentType.IsImage }}
69
+
<div class="text-center">
70
+
<img src="{{ .BlobView.ContentSrc }}"
71
+
alt="{{ .Path }}"
72
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
73
+
</div>
74
+
{{ else if .BlobView.ContentType.IsVideo }}
75
+
<div class="text-center">
76
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
77
+
<source src="{{ .BlobView.ContentSrc }}">
78
+
Your browser does not support the video tag.
79
+
</video>
80
+
</div>
81
+
{{ else if .BlobView.ContentType.IsSvg }}
82
+
<div class="overflow-auto relative">
83
+
{{ if .BlobView.ShowingRendered }}
84
+
<div class="text-center">
85
+
<img src="{{ .BlobView.ContentSrc }}"
86
+
alt="{{ .Path }}"
87
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
88
+
</div>
89
{{ else }}
90
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
91
+
{{ end }}
92
+
</div>
93
+
{{ else if .BlobView.ContentType.IsMarkup }}
94
+
<div class="overflow-auto relative">
95
+
{{ if .BlobView.ShowingRendered }}
96
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div>
97
+
{{ else }}
98
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
99
{{ end }}
100
+
</div>
101
+
{{ else if .BlobView.ContentType.IsCode }}
102
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
103
{{ end }}
104
{{ template "fragments/multiline-select" }}
105
{{ end }}
+11
-11
appview/pages/templates/repo/commit.html
+11
-11
appview/pages/templates/repo/commit.html
···
24
</div>
25
</div>
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 }}
30
31
-
{{ if $didOrHandle }}
32
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
33
{{ else }}
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
{{ end }}
36
<span class="px-1 select-none before:content-['\00B7']"></span>
37
{{ template "repo/fragments/time" $commit.Author.When }}
38
<span class="px-1 select-none before:content-['\00B7']"></span>
39
-
</p>
40
41
-
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
42
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
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>
46
{{ end }}
47
</p>
48
···
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
<div class="flex items-center gap-2 my-2">
60
{{ i "user" "w-4 h-4" }}
61
-
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
</div>
64
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
24
</div>
25
</div>
26
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
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
33
{{ else }}
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
{{ end }}
36
+
37
<span class="px-1 select-none before:content-['\00B7']"></span>
38
{{ template "repo/fragments/time" $commit.Author.When }}
39
<span class="px-1 select-none before:content-['\00B7']"></span>
40
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
{{ 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>
46
{{ end }}
47
</p>
48
···
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
<div class="flex items-center gap-2 my-2">
60
{{ i "user" "w-4 h-4" }}
61
+
{{ $committerDid := index $.EmailToDid $commit.Committer.Email }}
62
+
{{ template "user/fragments/picHandleLink" $committerDid }}
63
</div>
64
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
35
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
<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>
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
</div>
41
</div>
···
35
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
<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 | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
</div>
41
</div>
+4
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
+4
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
···
29
<code
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
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
<button
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
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
<code
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
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
<button
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
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"
···
29
<code
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
onclick="window.getSelection().selectAllChildren(this)"
32
+
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code>
34
<button
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
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
<code
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
onclick="window.getSelection().selectAllChildren(this)"
51
+
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
<button
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
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
{{ if .Split }}
6
{{ $active = "split" }}
7
{{ end }}
8
-
{{ $values := list "unified" "split" }}
9
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
10
</section>
11
{{ end }}
12
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 }}
···
5
{{ if .Split }}
6
{{ $active = "split" }}
7
{{ 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) }}
28
</section>
29
{{ end }}
30
-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
+
-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 }}
···
+11
-14
appview/pages/templates/repo/index.html
+11
-14
appview/pages/templates/repo/index.html
···
35
{{ end }}
36
37
{{ define "repoLanguages" }}
38
-
<details class="group -m-6 mb-4">
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
{{ range $value := .Languages }}
41
<div
···
129
{{ $icon := "folder" }}
130
{{ $iconStyle := "size-4 fill-current" }}
131
132
{{ if .IsFile }}
133
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
{{ $icon = "file" }}
135
{{ $iconStyle = "size-4" }}
136
{{ end }}
137
<a href="{{ $link }}" class="{{ $linkstyle }}">
138
<div class="flex items-center gap-2">
139
{{ i $icon $iconStyle "flex-shrink-0" }}
···
222
class="mx-1 before:content-['·'] before:select-none"
223
></span>
224
<span>
225
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
226
-
<a
227
-
href="{{ if $didOrHandle }}
228
-
/{{ $didOrHandle }}
229
-
{{ else }}
230
-
mailto:{{ .Author.Email }}
231
-
{{ end }}"
232
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
-
>
239
</span>
240
<div class="inline-block px-1 select-none after:content-['·']"></div>
241
{{ template "repo/fragments/time" .Committer.When }}
···
35
{{ end }}
36
37
{{ define "repoLanguages" }}
38
+
<details class="group -my-4 -m-6 mb-4">
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
{{ range $value := .Languages }}
41
<div
···
129
{{ $icon := "folder" }}
130
{{ $iconStyle := "size-4 fill-current" }}
131
132
+
{{ if .IsSubmodule }}
133
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
+
{{ $icon = "folder-input" }}
135
+
{{ $iconStyle = "size-4" }}
136
+
{{ end }}
137
+
138
{{ if .IsFile }}
139
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
140
{{ $icon = "file" }}
141
{{ $iconStyle = "size-4" }}
142
{{ end }}
143
+
144
<a href="{{ $link }}" class="{{ $linkstyle }}">
145
<div class="flex items-center gap-2">
146
{{ i $icon $iconStyle "flex-shrink-0" }}
···
229
class="mx-1 before:content-['·'] before:select-none"
230
></span>
231
<span>
232
+
{{ $did := index $.EmailToDid .Author.Email }}
233
+
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
234
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
235
+
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
236
</span>
237
<div class="inline-block px-1 select-none after:content-['·']"></div>
238
{{ template "repo/fragments/time" .Committer.When }}
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
34
35
{{ define "editIssueComment" }}
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
hx-swap="outerHTML"
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
45
{{ define "deleteIssueComment" }}
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
hx-confirm="Are you sure you want to delete your comment?"
50
hx-swap="outerHTML"
···
34
35
{{ define "editIssueComment" }}
36
<a
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
hx-swap="outerHTML"
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
45
{{ define "deleteIssueComment" }}
46
<a
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
hx-confirm="Are you sure you want to delete your comment?"
50
hx-swap="outerHTML"
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
+3
-2
appview/pages/templates/repo/issues/issue.html
+3
-2
appview/pages/templates/repo/issues/issue.html
···
20
"Subject" $.Issue.AtUri
21
"State" $.Issue.Labels) }}
22
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
</div>
24
</div>
25
{{ end }}
···
84
85
{{ define "editIssue" }}
86
<a
87
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
88
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
89
hx-swap="innerHTML"
90
hx-target="#issue-{{.Issue.IssueId}}">
···
94
95
{{ define "deleteIssue" }}
96
<a
97
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
98
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
99
hx-confirm="Are you sure you want to delete your issue?"
100
hx-swap="none">
···
20
"Subject" $.Issue.AtUri
21
"State" $.Issue.Labels) }}
22
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
+
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
24
</div>
25
</div>
26
{{ end }}
···
85
86
{{ define "editIssue" }}
87
<a
88
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
89
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
90
hx-swap="innerHTML"
91
hx-target="#issue-{{.Issue.IssueId}}">
···
95
96
{{ define "deleteIssue" }}
97
<a
98
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
99
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
100
hx-confirm="Are you sure you want to delete your issue?"
101
hx-swap="none">
+43
-24
appview/pages/templates/repo/issues/issues.html
+43
-24
appview/pages/templates/repo/issues/issues.html
···
8
{{ end }}
9
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
12
-
<div class="flex gap-4">
13
<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
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
-
>
32
{{ i "circle-plus" "w-4 h-4" }}
33
<span>new</span>
34
-
</a>
35
-
</div>
36
-
<div class="error" id="issues"></div>
37
{{ end }}
38
39
{{ define "repoAfter" }}
···
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 = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
59
>
60
{{ i "chevron-left" "w-4 h-4" }}
61
previous
···
69
<a
70
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
71
hx-boost="true"
72
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
73
>
74
next
75
{{ i "chevron-right" "w-4 h-4" }}
···
8
{{ end }}
9
10
{{ define "repoContent" }}
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>
47
<a
48
href="/{{ .RepoInfo.FullName }}/issues/new"
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
+
>
51
{{ i "circle-plus" "w-4 h-4" }}
52
<span>new</span>
53
+
</a>
54
+
</div>
55
+
<div class="error" id="issues"></div>
56
{{ end }}
57
58
{{ define "repoAfter" }}
···
74
<a
75
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
76
hx-boost="true"
77
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
78
>
79
{{ i "chevron-left" "w-4 h-4" }}
80
previous
···
88
<a
89
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
90
hx-boost="true"
91
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
92
>
93
next
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
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
<div class="{{ $grid }} py-3">
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 }}
33
{{ else }}
34
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
{{ end }}
···
153
</span>
154
<span class="mx-2 before:content-['·'] before:select-none"></span>
155
<span>
156
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
157
-
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
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 }}
160
</a>
161
</span>
162
<div class="inline-block px-1 select-none after:content-['·']"></div>
···
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
<div class="{{ $grid }} py-3">
29
<div class="align-top truncate col-span-2">
30
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
33
{{ else }}
34
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
{{ end }}
···
153
</span>
154
<span class="mx-2 before:content-['·'] before:select-none"></span>
155
<span>
156
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
+
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
+
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
</a>
161
</span>
162
<div class="inline-block px-1 select-none after:content-['·']"></div>
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
2
<div id="lines" hx-swap-oob="beforeend">
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
<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>
11
</summary>
12
<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
</details>
14
</div>
15
{{ end }}
···
2
<div id="lines" hx-swap-oob="beforeend">
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
<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">{{ template "stepHeader" . }}</div>
6
+
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
7
</summary>
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>
9
</details>
10
</div>
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
{{ range .Pipelines }}
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
{{ else }}
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>
30
{{ end }}
31
</div>
32
</div>
+6
appview/pages/templates/repo/pipelines/workflow.html
+6
appview/pages/templates/repo/pipelines/workflow.html
···
15
{{ block "logs" . }} {{ end }}
16
</div>
17
</section>
18
{{ end }}
19
20
{{ define "sidebar" }}
···
58
hx-ext="ws"
59
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
60
<div id="lines" class="flex flex-col gap-2">
61
</div>
62
</div>
63
{{ end }}
···
15
{{ block "logs" . }} {{ end }}
16
</div>
17
</section>
18
+
{{ template "fragments/workflow-timers" }}
19
{{ end }}
20
21
{{ define "sidebar" }}
···
59
hx-ext="ws"
60
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
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>
67
</div>
68
</div>
69
{{ end }}
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
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 .BranchDeleteStatus }}
37
-
<button
38
-
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
39
-
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
40
-
hx-swap="none"
41
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
42
-
{{ i "git-branch" "w-4 h-4" }}
43
-
<span>delete branch</span>
44
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
-
</button>
46
-
{{ end }}
47
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
48
-
{{ $disabled := "" }}
49
-
{{ if $isConflicted }}
50
-
{{ $disabled = "disabled" }}
51
-
{{ end }}
52
-
<button
53
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
54
-
hx-swap="none"
55
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
56
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
57
-
{{ i "git-merge" "w-4 h-4" }}
58
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
59
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
60
-
</button>
61
-
{{ end }}
62
63
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
64
-
{{ $disabled := "" }}
65
-
{{ if $isUpToDate }}
66
-
{{ $disabled = "disabled" }}
67
{{ end }}
68
-
<button id="resubmitBtn"
69
-
{{ if not .Pull.IsPatchBased }}
70
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
71
-
{{ else }}
72
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
73
-
hx-target="#actions-{{$roundNumber}}"
74
-
hx-swap="outerHtml"
75
-
{{ end }}
76
77
-
hx-disabled-elt="#resubmitBtn"
78
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
79
80
-
{{ if $disabled }}
81
-
title="Update this branch to resubmit this pull request"
82
-
{{ else }}
83
-
title="Resubmit this pull request"
84
-
{{ end }}
85
-
>
86
-
{{ i "rotate-ccw" "w-4 h-4" }}
87
-
<span>resubmit</span>
88
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
89
-
</button>
90
-
{{ end }}
91
92
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
93
-
<button
94
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
95
-
hx-swap="none"
96
-
class="btn p-2 flex items-center gap-2 group">
97
-
{{ i "ban" "w-4 h-4" }}
98
-
<span>close</span>
99
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
-
</button>
101
-
{{ end }}
102
103
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
104
-
<button
105
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
106
-
hx-swap="none"
107
-
class="btn p-2 flex items-center gap-2 group">
108
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
109
-
<span>reopen</span>
110
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
111
-
</button>
112
-
{{ end }}
113
-
</div>
114
</div>
115
{{ end }}
116
···
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
{{ $isUpToDate := .ResubmitCheck.No }}
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 }}
61
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"
74
{{ end }}
75
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
78
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 }}
90
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 }}
101
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 }}
112
</div>
113
{{ end }}
114
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+2
-1
appview/pages/templates/repo/pulls/pull.html
+2
-1
appview/pages/templates/repo/pulls/pull.html
···
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>
27
{{ end }}
+52
-34
appview/pages/templates/repo/pulls/pulls.html
+52
-34
appview/pages/templates/repo/pulls/pulls.html
···
8
{{ end }}
9
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>
42
</div>
43
-
<div class="error" id="pulls"></div>
44
{{ end }}
45
46
{{ define "repoAfter" }}
···
133
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
134
</div>
135
</summary>
136
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
137
</details>
138
{{ end }}
139
{{ end }}
···
142
</div>
143
{{ end }}
144
145
-
{{ define "pullList" }}
146
{{ $list := index . 0 }}
147
{{ $root := index . 1 }}
148
<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">
···
8
{{ end }}
9
10
{{ define "repoContent" }}
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) }}
52
</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>
62
{{ end }}
63
64
{{ define "repoAfter" }}
···
151
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
152
</div>
153
</summary>
154
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
155
</details>
156
{{ end }}
157
{{ end }}
···
160
</div>
161
{{ end }}
162
163
+
{{ define "stackedPullList" }}
164
{{ $list := index . 0 }}
165
{{ $root := index . 1 }}
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
<div
67
id="add-collaborator-modal"
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">
70
{{ template "addCollaboratorModal" . }}
71
</div>
72
{{ end }}
···
82
ADD COLLABORATOR
83
</label>
84
<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
-
/>
92
<div class="flex gap-2 pt-2">
93
<button
94
type="button"
···
66
<div
67
id="add-collaborator-modal"
68
popover
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">
73
{{ template "addCollaboratorModal" . }}
74
</div>
75
{{ end }}
···
85
ADD COLLABORATOR
86
</label>
87
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
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>
101
<div class="flex gap-2 pt-2">
102
<button
103
type="button"
+47
appview/pages/templates/repo/settings/general.html
+47
appview/pages/templates/repo/settings/general.html
···
6
{{ template "repo/settings/fragments/sidebar" . }}
7
</div>
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
{{ template "branchSettings" . }}
10
{{ template "defaultLabelSettings" . }}
11
{{ template "customLabelSettings" . }}
···
13
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
</div>
15
</section>
16
{{ end }}
17
18
{{ define "branchSettings" }}
···
6
{{ template "repo/settings/fragments/sidebar" . }}
7
</div>
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "baseSettings" . }}
10
{{ template "branchSettings" . }}
11
{{ template "defaultLabelSettings" . }}
12
{{ template "customLabelSettings" . }}
···
14
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
15
</div>
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>
63
{{ end }}
64
65
{{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
+8
appview/pages/templates/repo/tree.html
···
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
62
{{ if .IsFile }}
63
{{ $icon = "file" }}
64
{{ $iconStyle = "size-4" }}
65
{{ end }}
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
<div class="flex items-center gap-2">
68
{{ i $icon $iconStyle "flex-shrink-0" }}
···
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
62
+
{{ if .IsSubmodule }}
63
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
64
+
{{ $icon = "folder-input" }}
65
+
{{ $iconStyle = "size-4" }}
66
+
{{ end }}
67
+
68
{{ if .IsFile }}
69
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
70
{{ $icon = "file" }}
71
{{ $iconStyle = "size-4" }}
72
{{ end }}
73
+
74
<a href="{{ $link }}" class="{{ $linkstyle }}">
75
<div class="flex items-center gap-2">
76
{{ i $icon $iconStyle "flex-shrink-0" }}
+16
-8
appview/pages/templates/spindles/fragments/addMemberModal.html
+16
-8
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Instance }}"
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">
17
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
···
29
ADD MEMBER
30
</label>
31
<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
-
/>
39
<div class="flex gap-2 pt-2">
40
<button
41
type="button"
···
13
<div
14
id="add-member-{{ .Instance }}"
15
popover
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">
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
20
</div>
21
{{ end }}
···
31
ADD MEMBER
32
</label>
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
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>
47
<div class="flex gap-2 pt-2">
48
<button
49
type="button"
+3
-3
appview/pages/templates/strings/string.html
+3
-3
appview/pages/templates/strings/string.html
···
47
</span>
48
</section>
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">
51
<span>
52
{{ .String.Filename }}
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
···
75
</div>
76
<div class="overflow-x-auto overflow-y-hidden relative">
77
{{ if .ShowRendered }}
78
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
79
{{ else }}
80
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
81
{{ end }}
82
</div>
83
{{ template "fragments/multiline-select" }}
···
47
</span>
48
</section>
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
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
<span>
52
{{ .String.Filename }}
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
···
75
</div>
76
<div class="overflow-x-auto overflow-y-hidden relative">
77
{{ if .ShowRendered }}
78
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
79
{{ else }}
80
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div>
81
{{ end }}
82
</div>
83
{{ template "fragments/multiline-select" }}
+2
-2
appview/pages/templates/timeline/fragments/hero.html
+2
-2
appview/pages/templates/timeline/fragments/hero.html
···
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
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>.
8
</p>
9
<p class="text-lg">
10
-
we envision a place where developers have complete ownership of their
11
code, open source communities can freely self-govern and most
12
importantly, coding can be social and fun again.
13
</p>
···
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
6
<p class="text-lg">
7
+
Tangled is a decentralized Git hosting and collaboration platform.
8
</p>
9
<p class="text-lg">
10
+
We envision a place where developers have complete ownership of their
11
code, open source communities can freely self-govern and most
12
importantly, coding can be social and fun again.
13
</p>
+11
appview/pages/templates/user/fragments/editBio.html
+11
appview/pages/templates/user/fragments/editBio.html
···
20
</div>
21
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">
34
<label class="m-0 p-0" for="location">location</label>
35
<div class="flex items-center gap-2 w-full">
36
{{ $location := "" }}
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
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 }}" />
7
</div>
8
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
···
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
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 }}" alt="{{ $userIdent }}" />
7
</div>
8
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+19
-6
appview/pages/templates/user/fragments/profileCard.html
+19
-6
appview/pages/templates/user/fragments/profileCard.html
···
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
{{ $userIdent }}
14
</p>
15
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
</div>
17
18
<div class="md:hidden">
···
67
{{ end }}
68
</div>
69
{{ end }}
70
-
{{ if ne .FollowStatus.String "IsSelf" }}
71
-
{{ template "user/fragments/follow" . }}
72
-
{{ else }}
73
<button id="editBtn"
74
-
class="btn mt-2 w-full flex items-center gap-2 group"
75
hx-target="#profile-bio"
76
hx-get="/profile/edit-bio"
77
hx-swap="innerHTML">
···
79
edit
80
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
</button>
82
-
{{ end }}
83
</div>
84
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
85
</div>
···
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
{{ $userIdent }}
14
</p>
15
+
{{ with .Profile }}
16
+
{{ if .Pronouns }}
17
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
+
{{ end }}
19
+
{{ end }}
20
</div>
21
22
<div class="md:hidden">
···
71
{{ end }}
72
</div>
73
{{ end }}
74
+
75
+
<div class="flex mt-2 items-center gap-2">
76
+
{{ if ne .FollowStatus.String "IsSelf" }}
77
+
{{ template "user/fragments/follow" . }}
78
+
{{ else }}
79
<button id="editBtn"
80
+
class="btn w-full flex items-center gap-2 group"
81
hx-target="#profile-bio"
82
hx-get="/profile/edit-bio"
83
hx-swap="innerHTML">
···
85
edit
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
87
</button>
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
+
96
</div>
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
98
</div>
+23
-2
appview/pages/templates/user/login.html
+23
-2
appview/pages/templates/user/login.html
···
13
<title>login · tangled</title>
14
</head>
15
<body class="flex items-center justify-center min-h-screen">
16
-
<main class="max-w-md px-6 -mt-4">
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
18
{{ template "fragments/logotype" }}
19
</h1>
···
21
tightly-knit social coding.
22
</h2>
23
<form
24
-
class="mt-4 max-w-sm mx-auto"
25
hx-post="/login"
26
hx-swap="none"
27
hx-disabled-elt="#login-button"
···
29
<div class="flex flex-col">
30
<label for="handle">handle</label>
31
<input
32
type="text"
33
id="handle"
34
name="handle"
···
53
<span>login</span>
54
</button>
55
</form>
56
<p class="text-sm text-gray-500">
57
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
58
</p>
···
13
<title>login · tangled</title>
14
</head>
15
<body class="flex items-center justify-center min-h-screen">
16
+
<main class="max-w-md px-7 mt-4">
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
18
{{ template "fragments/logotype" }}
19
</h1>
···
21
tightly-knit social coding.
22
</h2>
23
<form
24
+
class="mt-4"
25
hx-post="/login"
26
hx-swap="none"
27
hx-disabled-elt="#login-button"
···
29
<div class="flex flex-col">
30
<label for="handle">handle</label>
31
<input
32
+
autocapitalize="none"
33
+
autocorrect="off"
34
+
autocomplete="username"
35
type="text"
36
id="handle"
37
name="handle"
···
56
<span>login</span>
57
</button>
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 }}
77
<p class="text-sm text-gray-500">
78
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
79
</p>
+14
appview/pages/templates/user/settings/notifications.html
+14
appview/pages/templates/user/settings/notifications.html
···
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">Email notifications</span>
148
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
<span>Receive notifications via email in addition to in-app notifications.</span>
···
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>
+46
appview/pagination/page.go
+46
appview/pagination/page.go
···
1
package pagination
2
3
+
import "context"
4
+
5
type Page struct {
6
Offset int // where to start from
7
Limit int // number of items in a page
···
12
Offset: 0,
13
Limit: 30,
14
}
15
+
}
16
+
17
+
type ctxKey struct{}
18
+
19
+
func IntoContext(ctx context.Context, page Page) context.Context {
20
+
return context.WithValue(ctx, ctxKey{}, page)
21
+
}
22
+
23
+
func FromContext(ctx context.Context) Page {
24
+
if ctx == nil {
25
+
return FirstPage()
26
+
}
27
+
v := ctx.Value(ctxKey{})
28
+
if v == nil {
29
+
return FirstPage()
30
+
}
31
+
page, ok := v.(Page)
32
+
if !ok {
33
+
return FirstPage()
34
+
}
35
+
return page
36
}
37
38
func (p Page) Previous() Page {
···
52
Limit: p.Limit,
53
}
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
+
}
+34
-13
appview/pipelines/pipelines.go
+34
-13
appview/pipelines/pipelines.go
···
35
logger *slog.Logger
36
}
37
38
func New(
39
oauth *oauth.OAuth,
40
repoResolver *reporesolver.RepoResolver,
···
227
// start a goroutine to read from spindle
228
go readLogs(spindleConn, evChan)
229
230
-
stepIdx := 0
231
var fragment bytes.Buffer
232
for {
233
select {
···
259
260
switch logLine.Kind {
261
case spindlemodel.LogKindControl:
262
-
// control messages create a new step block
263
-
stepIdx++
264
-
collapsed := false
265
-
if logLine.StepKind == spindlemodel.StepKindSystem {
266
-
collapsed = true
267
}
268
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
269
-
Id: stepIdx,
270
-
Name: logLine.Content,
271
-
Command: logLine.StepCommand,
272
-
Collapsed: collapsed,
273
-
})
274
case spindlemodel.LogKindData:
275
// data messages simply insert new log lines into current step
276
err = p.pages.LogLine(&fragment, pages.LogLineParams{
277
-
Id: stepIdx,
278
Content: logLine.Content,
279
})
280
}
···
35
logger *slog.Logger
36
}
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
+
47
func New(
48
oauth *oauth.OAuth,
49
repoResolver *reporesolver.RepoResolver,
···
236
// start a goroutine to read from spindle
237
go readLogs(spindleConn, evChan)
238
239
+
stepStartTimes := make(map[int]time.Time)
240
var fragment bytes.Buffer
241
for {
242
select {
···
268
269
switch logLine.Kind {
270
case spindlemodel.LogKindControl:
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
+
})
293
}
294
+
295
case spindlemodel.LogKindData:
296
// data messages simply insert new log lines into current step
297
err = p.pages.LogLine(&fragment, pages.LogLineParams{
298
+
Id: logLine.StepId,
299
Content: logLine.Content,
300
})
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
-
}
···
+7
-7
appview/pulls/opengraph.go
+7
-7
appview/pulls/opengraph.go
···
146
var statusColor color.RGBA
147
148
if pull.State.IsOpen() {
149
-
statusIcon = "static/icons/git-pull-request.svg"
150
statusText = "open"
151
statusColor = color.RGBA{34, 139, 34, 255} // green
152
} else if pull.State.IsMerged() {
153
-
statusIcon = "static/icons/git-merge.svg"
154
statusText = "merged"
155
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
} else {
157
-
statusIcon = "static/icons/git-pull-request-closed.svg"
158
statusText = "closed"
159
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
}
···
162
statusIconSize := 36
163
164
// Draw icon with status color
165
-
err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
if err != nil {
167
log.Printf("failed to draw status icon: %v", err)
168
}
···
179
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
181
// Draw comment count
182
-
err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
if err != nil {
184
log.Printf("failed to draw comment icon: %v", err)
185
}
···
198
currentX += commentTextWidth + 40
199
200
// Draw files changed
201
-
err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
if err != nil {
203
log.Printf("failed to draw file diff icon: %v", err)
204
}
···
241
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
245
if err != nil {
246
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
}
···
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
}
···
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
}
···
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
}
···
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
}
···
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
}
+62
-10
appview/pulls/pulls.go
+62
-10
appview/pulls/pulls.go
···
17
"tangled.org/core/api/tangled"
18
"tangled.org/core/appview/config"
19
"tangled.org/core/appview/db"
20
"tangled.org/core/appview/models"
21
"tangled.org/core/appview/notify"
22
"tangled.org/core/appview/oauth"
···
32
"tangled.org/core/types"
33
34
comatproto "github.com/bluesky-social/indigo/api/atproto"
35
lexutil "github.com/bluesky-social/indigo/lex/util"
36
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
37
"github.com/go-chi/chi/v5"
···
49
enforcer *rbac.Enforcer
50
logger *slog.Logger
51
validator *validator.Validator
52
}
53
54
func New(
···
61
notifier notify.Notifier,
62
enforcer *rbac.Enforcer,
63
validator *validator.Validator,
64
logger *slog.Logger,
65
) *Pulls {
66
return &Pulls{
···
74
enforcer: enforcer,
75
logger: logger,
76
validator: validator,
77
}
78
}
79
···
187
m[p.Sha] = p
188
}
189
190
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
191
if err != nil {
192
log.Println("failed to get pull reactions")
193
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
195
196
userReactions := map[models.ReactionKind]bool{}
197
if user != nil {
198
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
199
}
200
201
labelDefs, err := db.GetLabelDefinitions(
···
544
}
545
546
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
547
user := s.oauth.GetUser(r)
548
params := r.URL.Query()
549
···
561
return
562
}
563
564
pulls, err := db.GetPulls(
565
s.db,
566
-
db.FilterEq("repo_at", f.RepoAt()),
567
-
db.FilterEq("state", state),
568
)
569
if err != nil {
570
log.Println("failed to get pulls", err)
···
651
Pulls: pulls,
652
LabelDefs: defs,
653
FilteringBy: state,
654
Stacks: stacks,
655
Pipelines: m,
656
})
657
}
658
659
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
660
user := s.oauth.GetUser(r)
661
f, err := s.repoResolver.Resolve(r)
662
if err != nil {
···
718
Rkey: tid.TID(),
719
Record: &lexutil.LexiconTypeDecoder{
720
Val: &tangled.RepoPullComment{
721
-
Pull: pull.PullAt().String(),
722
Body: body,
723
CreatedAt: createdAt,
724
},
···
754
return
755
}
756
757
-
s.notifier.NewPullComment(r.Context(), comment)
758
759
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
760
return
···
1805
}
1806
defer tx.Rollback()
1807
1808
-
pullAt := pull.PullAt()
1809
newRoundNumber := len(pull.Submissions)
1810
newPatch := patch
1811
newSourceRev := sourceRev
···
2002
}
2003
2004
// resubmit the new pull
2005
-
pullAt := op.PullAt()
2006
newRoundNumber := len(op.Submissions)
2007
newPatch := np.LatestPatch()
2008
combinedPatch := np.LatestSubmission().Combined
···
2073
}
2074
2075
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2076
f, err := s.repoResolver.Resolve(r)
2077
if err != nil {
2078
log.Println("failed to resolve repo:", err)
···
2170
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2171
return
2172
}
2173
}
2174
2175
err = tx.Commit()
···
2182
2183
// notify about the pull merge
2184
for _, p := range pullsToMerge {
2185
-
s.notifier.NewPullMerged(r.Context(), p)
2186
}
2187
2188
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2243
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2244
return
2245
}
2246
}
2247
2248
// Commit the transaction
···
2253
}
2254
2255
for _, p := range pullsToClose {
2256
-
s.notifier.NewPullClosed(r.Context(), p)
2257
}
2258
2259
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2315
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2316
return
2317
}
2318
}
2319
2320
// Commit the transaction
···
2322
log.Println("failed to commit transaction", err)
2323
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2324
return
2325
}
2326
2327
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
17
"tangled.org/core/api/tangled"
18
"tangled.org/core/appview/config"
19
"tangled.org/core/appview/db"
20
+
pulls_indexer "tangled.org/core/appview/indexer/pulls"
21
"tangled.org/core/appview/models"
22
"tangled.org/core/appview/notify"
23
"tangled.org/core/appview/oauth"
···
33
"tangled.org/core/types"
34
35
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
+
"github.com/bluesky-social/indigo/atproto/syntax"
37
lexutil "github.com/bluesky-social/indigo/lex/util"
38
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
39
"github.com/go-chi/chi/v5"
···
51
enforcer *rbac.Enforcer
52
logger *slog.Logger
53
validator *validator.Validator
54
+
indexer *pulls_indexer.Indexer
55
}
56
57
func New(
···
64
notifier notify.Notifier,
65
enforcer *rbac.Enforcer,
66
validator *validator.Validator,
67
+
indexer *pulls_indexer.Indexer,
68
logger *slog.Logger,
69
) *Pulls {
70
return &Pulls{
···
78
enforcer: enforcer,
79
logger: logger,
80
validator: validator,
81
+
indexer: indexer,
82
}
83
}
84
···
192
m[p.Sha] = p
193
}
194
195
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
196
if err != nil {
197
log.Println("failed to get pull reactions")
198
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
200
201
userReactions := map[models.ReactionKind]bool{}
202
if user != nil {
203
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
204
}
205
206
labelDefs, err := db.GetLabelDefinitions(
···
549
}
550
551
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
552
+
l := s.logger.With("handler", "RepoPulls")
553
+
554
user := s.oauth.GetUser(r)
555
params := r.URL.Query()
556
···
568
return
569
}
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
+
598
pulls, err := db.GetPulls(
599
s.db,
600
+
db.FilterIn("id", ids),
601
)
602
if err != nil {
603
log.Println("failed to get pulls", err)
···
684
Pulls: pulls,
685
LabelDefs: defs,
686
FilteringBy: state,
687
+
FilterQuery: keyword,
688
Stacks: stacks,
689
Pipelines: m,
690
})
691
}
692
693
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
694
+
l := s.logger.With("handler", "PullComment")
695
user := s.oauth.GetUser(r)
696
f, err := s.repoResolver.Resolve(r)
697
if err != nil {
···
753
Rkey: tid.TID(),
754
Record: &lexutil.LexiconTypeDecoder{
755
Val: &tangled.RepoPullComment{
756
+
Pull: pull.AtUri().String(),
757
Body: body,
758
CreatedAt: createdAt,
759
},
···
789
return
790
}
791
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)
802
803
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
804
return
···
1849
}
1850
defer tx.Rollback()
1851
1852
+
pullAt := pull.AtUri()
1853
newRoundNumber := len(pull.Submissions)
1854
newPatch := patch
1855
newSourceRev := sourceRev
···
2046
}
2047
2048
// resubmit the new pull
2049
+
pullAt := op.AtUri()
2050
newRoundNumber := len(op.Submissions)
2051
newPatch := np.LatestPatch()
2052
combinedPatch := np.LatestSubmission().Combined
···
2117
}
2118
2119
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2120
+
user := s.oauth.GetUser(r)
2121
f, err := s.repoResolver.Resolve(r)
2122
if err != nil {
2123
log.Println("failed to resolve repo:", err)
···
2215
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2216
return
2217
}
2218
+
p.State = models.PullMerged
2219
}
2220
2221
err = tx.Commit()
···
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
2234
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2289
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2290
return
2291
}
2292
+
p.State = models.PullClosed
2293
}
2294
2295
// Commit the transaction
···
2300
}
2301
2302
for _, p := range pullsToClose {
2303
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2304
}
2305
2306
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2362
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2363
return
2364
}
2365
+
p.State = models.PullOpen
2366
}
2367
2368
// Commit the transaction
···
2370
log.Println("failed to commit transaction", err)
2371
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2372
return
2373
+
}
2374
+
2375
+
for _, p := range pullsToReopen {
2376
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2377
}
2378
2379
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+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
+
}
+291
appview/repo/blob.go
+291
appview/repo/blob.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"fmt"
6
+
"io"
7
+
"net/http"
8
+
"net/url"
9
+
"path/filepath"
10
+
"slices"
11
+
"strings"
12
+
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/models"
16
+
"tangled.org/core/appview/pages"
17
+
"tangled.org/core/appview/pages/markup"
18
+
"tangled.org/core/appview/reporesolver"
19
+
xrpcclient "tangled.org/core/appview/xrpcclient"
20
+
21
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
+
"github.com/go-chi/chi/v5"
23
+
)
24
+
25
+
// the content can be one of the following:
26
+
//
27
+
// - code : text | | raw
28
+
// - markup : text | rendered | raw
29
+
// - svg : text | rendered | raw
30
+
// - png : | rendered | raw
31
+
// - video : | rendered | raw
32
+
// - submodule : | rendered |
33
+
// - rest : | |
34
+
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
35
+
l := rp.logger.With("handler", "RepoBlob")
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
+
ref := chi.URLParam(r, "ref")
44
+
ref, _ = url.PathUnescape(ref)
45
+
46
+
filePath := chi.URLParam(r, "*")
47
+
filePath, _ = url.PathUnescape(filePath)
48
+
49
+
scheme := "http"
50
+
if !rp.config.Core.Dev {
51
+
scheme = "https"
52
+
}
53
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
54
+
xrpcc := &indigoxrpc.Client{
55
+
Host: host,
56
+
}
57
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
58
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
59
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
60
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
61
+
rp.pages.Error503(w)
62
+
return
63
+
}
64
+
65
+
// Use XRPC response directly instead of converting to internal types
66
+
var breadcrumbs [][]string
67
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
68
+
if filePath != "" {
69
+
for idx, elem := range strings.Split(filePath, "/") {
70
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
71
+
}
72
+
}
73
+
74
+
// Create the blob view
75
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
76
+
77
+
user := rp.oauth.GetUser(r)
78
+
79
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
80
+
LoggedInUser: user,
81
+
RepoInfo: f.RepoInfo(user),
82
+
BreadCrumbs: breadcrumbs,
83
+
BlobView: blobView,
84
+
RepoBlob_Output: resp,
85
+
})
86
+
}
87
+
88
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
89
+
l := rp.logger.With("handler", "RepoBlobRaw")
90
+
91
+
f, err := rp.repoResolver.Resolve(r)
92
+
if err != nil {
93
+
l.Error("failed to get repo and knot", "err", err)
94
+
w.WriteHeader(http.StatusBadRequest)
95
+
return
96
+
}
97
+
98
+
ref := chi.URLParam(r, "ref")
99
+
ref, _ = url.PathUnescape(ref)
100
+
101
+
filePath := chi.URLParam(r, "*")
102
+
filePath, _ = url.PathUnescape(filePath)
103
+
104
+
scheme := "http"
105
+
if !rp.config.Core.Dev {
106
+
scheme = "https"
107
+
}
108
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
109
+
baseURL := &url.URL{
110
+
Scheme: scheme,
111
+
Host: f.Knot,
112
+
Path: "/xrpc/sh.tangled.repo.blob",
113
+
}
114
+
query := baseURL.Query()
115
+
query.Set("repo", repo)
116
+
query.Set("ref", ref)
117
+
query.Set("path", filePath)
118
+
query.Set("raw", "true")
119
+
baseURL.RawQuery = query.Encode()
120
+
blobURL := baseURL.String()
121
+
req, err := http.NewRequest("GET", blobURL, nil)
122
+
if err != nil {
123
+
l.Error("failed to create request", "err", err)
124
+
return
125
+
}
126
+
127
+
// forward the If-None-Match header
128
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
129
+
req.Header.Set("If-None-Match", clientETag)
130
+
}
131
+
client := &http.Client{}
132
+
133
+
resp, err := client.Do(req)
134
+
if err != nil {
135
+
l.Error("failed to reach knotserver", "err", err)
136
+
rp.pages.Error503(w)
137
+
return
138
+
}
139
+
140
+
defer resp.Body.Close()
141
+
142
+
// forward 304 not modified
143
+
if resp.StatusCode == http.StatusNotModified {
144
+
w.WriteHeader(http.StatusNotModified)
145
+
return
146
+
}
147
+
148
+
if resp.StatusCode != http.StatusOK {
149
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
150
+
w.WriteHeader(resp.StatusCode)
151
+
_, _ = io.Copy(w, resp.Body)
152
+
return
153
+
}
154
+
155
+
contentType := resp.Header.Get("Content-Type")
156
+
body, err := io.ReadAll(resp.Body)
157
+
if err != nil {
158
+
l.Error("error reading response body from knotserver", "err", err)
159
+
w.WriteHeader(http.StatusInternalServerError)
160
+
return
161
+
}
162
+
163
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
164
+
// serve all textual content as text/plain
165
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
166
+
w.Write(body)
167
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
168
+
// serve images and videos with their original content type
169
+
w.Header().Set("Content-Type", contentType)
170
+
w.Write(body)
171
+
} else {
172
+
w.WriteHeader(http.StatusUnsupportedMediaType)
173
+
w.Write([]byte("unsupported content type"))
174
+
return
175
+
}
176
+
}
177
+
178
+
// NewBlobView creates a BlobView from the XRPC response
179
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
180
+
view := models.BlobView{
181
+
Contents: "",
182
+
Lines: 0,
183
+
}
184
+
185
+
// Set size
186
+
if resp.Size != nil {
187
+
view.SizeHint = uint64(*resp.Size)
188
+
} else if resp.Content != nil {
189
+
view.SizeHint = uint64(len(*resp.Content))
190
+
}
191
+
192
+
if resp.Submodule != nil {
193
+
view.ContentType = models.BlobContentTypeSubmodule
194
+
view.HasRenderedView = true
195
+
view.ContentSrc = resp.Submodule.Url
196
+
return view
197
+
}
198
+
199
+
// Determine if binary
200
+
if resp.IsBinary != nil && *resp.IsBinary {
201
+
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
202
+
ext := strings.ToLower(filepath.Ext(resp.Path))
203
+
204
+
switch ext {
205
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
206
+
view.ContentType = models.BlobContentTypeImage
207
+
view.HasRawView = true
208
+
view.HasRenderedView = true
209
+
view.ShowingRendered = true
210
+
211
+
case ".svg":
212
+
view.ContentType = models.BlobContentTypeSvg
213
+
view.HasRawView = true
214
+
view.HasTextView = true
215
+
view.HasRenderedView = true
216
+
view.ShowingRendered = queryParams.Get("code") != "true"
217
+
if resp.Content != nil {
218
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
219
+
view.Contents = string(bytes)
220
+
view.Lines = strings.Count(view.Contents, "\n") + 1
221
+
}
222
+
223
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
224
+
view.ContentType = models.BlobContentTypeVideo
225
+
view.HasRawView = true
226
+
view.HasRenderedView = true
227
+
view.ShowingRendered = true
228
+
}
229
+
230
+
return view
231
+
}
232
+
233
+
// otherwise, we are dealing with text content
234
+
view.HasRawView = true
235
+
view.HasTextView = true
236
+
237
+
if resp.Content != nil {
238
+
view.Contents = *resp.Content
239
+
view.Lines = strings.Count(view.Contents, "\n") + 1
240
+
}
241
+
242
+
// with text, we may be dealing with markdown
243
+
format := markup.GetFormat(resp.Path)
244
+
if format == markup.FormatMarkdown {
245
+
view.ContentType = models.BlobContentTypeMarkup
246
+
view.HasRenderedView = true
247
+
view.ShowingRendered = queryParams.Get("code") != "true"
248
+
}
249
+
250
+
return view
251
+
}
252
+
253
+
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
254
+
scheme := "http"
255
+
if !config.Core.Dev {
256
+
scheme = "https"
257
+
}
258
+
259
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
260
+
baseURL := &url.URL{
261
+
Scheme: scheme,
262
+
Host: f.Knot,
263
+
Path: "/xrpc/sh.tangled.repo.blob",
264
+
}
265
+
query := baseURL.Query()
266
+
query.Set("repo", repoName)
267
+
query.Set("ref", ref)
268
+
query.Set("path", filePath)
269
+
query.Set("raw", "true")
270
+
baseURL.RawQuery = query.Encode()
271
+
blobURL := baseURL.String()
272
+
273
+
if !config.Core.Dev {
274
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
275
+
}
276
+
return blobURL
277
+
}
278
+
279
+
func isTextualMimeType(mimeType string) bool {
280
+
textualTypes := []string{
281
+
"application/json",
282
+
"application/xml",
283
+
"application/yaml",
284
+
"application/x-yaml",
285
+
"application/toml",
286
+
"application/javascript",
287
+
"application/ecmascript",
288
+
"message/",
289
+
}
290
+
return slices.Contains(textualTypes, mimeType)
291
+
}
+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
+10
-11
appview/repo/index.go
+10
-11
appview/repo/index.go
···
30
"github.com/go-enry/go-enry/v2"
31
)
32
33
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
34
l := rp.logger.With("handler", "RepoIndex")
35
36
ref := chi.URLParam(r, "ref")
···
154
CommitsTrunc: commitsTrunc,
155
TagsTrunc: tagsTrunc,
156
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
157
-
BranchesTrunc: branchesTrunc,
158
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
159
-
VerifiedCommits: vc,
160
-
Languages: languageInfo,
161
-
Pipelines: pipelines,
162
})
163
}
164
···
351
if treeResp != nil && treeResp.Files != nil {
352
for _, file := range treeResp.Files {
353
niceFile := types.NiceTree{
354
-
IsFile: file.Is_file,
355
-
IsSubtree: file.Is_subtree,
356
-
Name: file.Name,
357
-
Mode: file.Mode,
358
-
Size: file.Size,
359
}
360
if file.Last_commit != nil {
361
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
362
niceFile.LastCommit = &types.LastCommitInfo{
···
30
"github.com/go-enry/go-enry/v2"
31
)
32
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
l := rp.logger.With("handler", "RepoIndex")
35
36
ref := chi.URLParam(r, "ref")
···
154
CommitsTrunc: commitsTrunc,
155
TagsTrunc: tagsTrunc,
156
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
157
+
BranchesTrunc: branchesTrunc,
158
+
EmailToDid: emailToDidMap,
159
+
VerifiedCommits: vc,
160
+
Languages: languageInfo,
161
+
Pipelines: pipelines,
162
})
163
}
164
···
351
if treeResp != nil && treeResp.Files != nil {
352
for _, file := range treeResp.Files {
353
niceFile := types.NiceTree{
354
+
Name: file.Name,
355
+
Mode: file.Mode,
356
+
Size: file.Size,
357
}
358
+
359
if file.Last_commit != nil {
360
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
361
niceFile.LastCommit = &types.LastCommitInfo{
+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
+
}
+5
-5
appview/repo/opengraph.go
+5
-5
appview/repo/opengraph.go
···
158
// Draw star icon, count, and label
159
// Align icon baseline with text baseline
160
iconBaselineOffset := int(textSize) / 2
161
-
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
if err != nil {
163
log.Printf("failed to draw star icon: %v", err)
164
}
···
185
186
// Draw issues icon, count, and label
187
issueStartX := currentX
188
-
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
if err != nil {
190
log.Printf("failed to draw circle-dot icon: %v", err)
191
}
···
210
211
// Draw pull request icon, count, and label
212
prStartX := currentX
213
-
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
if err != nil {
215
log.Printf("failed to draw git-pull-request icon: %v", err)
216
}
···
236
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
240
if err != nil {
241
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
}
···
327
return nil
328
}
329
330
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
331
f, err := rp.repoResolver.Resolve(r)
332
if err != nil {
333
log.Println("failed to get repo and knot", err)
···
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
}
···
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
}
···
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
}
···
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
}
···
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)
+2
-1378
appview/repo/repo.go
+2
-1378
appview/repo/repo.go
···
3
import (
4
"context"
5
"database/sql"
6
-
"encoding/json"
7
"errors"
8
"fmt"
9
-
"io"
10
"log/slog"
11
"net/http"
12
"net/url"
13
-
"path/filepath"
14
"slices"
15
-
"strconv"
16
"strings"
17
"time"
18
19
"tangled.org/core/api/tangled"
20
-
"tangled.org/core/appview/commitverify"
21
"tangled.org/core/appview/config"
22
"tangled.org/core/appview/db"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
"tangled.org/core/appview/reporesolver"
29
"tangled.org/core/appview/validator"
30
xrpcclient "tangled.org/core/appview/xrpcclient"
31
"tangled.org/core/eventconsumer"
32
"tangled.org/core/idresolver"
33
-
"tangled.org/core/patchutil"
34
"tangled.org/core/rbac"
35
"tangled.org/core/tid"
36
-
"tangled.org/core/types"
37
"tangled.org/core/xrpc/serviceauth"
38
39
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
"github.com/bluesky-social/indigo/atproto/syntax"
42
lexutil "github.com/bluesky-social/indigo/lex/util"
43
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44
securejoin "github.com/cyphar/filepath-securejoin"
45
"github.com/go-chi/chi/v5"
46
-
"github.com/go-git/go-git/v5/plumbing"
47
)
48
49
type Repo struct {
···
88
}
89
}
90
91
-
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
92
-
l := rp.logger.With("handler", "DownloadArchive")
93
-
94
-
ref := chi.URLParam(r, "ref")
95
-
ref, _ = url.PathUnescape(ref)
96
-
97
-
f, err := rp.repoResolver.Resolve(r)
98
-
if err != nil {
99
-
l.Error("failed to get repo and knot", "err", err)
100
-
return
101
-
}
102
-
103
-
scheme := "http"
104
-
if !rp.config.Core.Dev {
105
-
scheme = "https"
106
-
}
107
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
108
-
xrpcc := &indigoxrpc.Client{
109
-
Host: host,
110
-
}
111
-
112
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
113
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
114
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
115
-
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
116
-
rp.pages.Error503(w)
117
-
return
118
-
}
119
-
120
-
// Set headers for file download, just pass along whatever the knot specifies
121
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
122
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
123
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
124
-
w.Header().Set("Content-Type", "application/gzip")
125
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
126
-
127
-
// Write the archive data directly
128
-
w.Write(archiveBytes)
129
-
}
130
-
131
-
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
132
-
l := rp.logger.With("handler", "RepoLog")
133
-
134
-
f, err := rp.repoResolver.Resolve(r)
135
-
if err != nil {
136
-
l.Error("failed to fully resolve repo", "err", err)
137
-
return
138
-
}
139
-
140
-
page := 1
141
-
if r.URL.Query().Get("page") != "" {
142
-
page, err = strconv.Atoi(r.URL.Query().Get("page"))
143
-
if err != nil {
144
-
page = 1
145
-
}
146
-
}
147
-
148
-
ref := chi.URLParam(r, "ref")
149
-
ref, _ = url.PathUnescape(ref)
150
-
151
-
scheme := "http"
152
-
if !rp.config.Core.Dev {
153
-
scheme = "https"
154
-
}
155
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
156
-
xrpcc := &indigoxrpc.Client{
157
-
Host: host,
158
-
}
159
-
160
-
limit := int64(60)
161
-
cursor := ""
162
-
if page > 1 {
163
-
// Convert page number to cursor (offset)
164
-
offset := (page - 1) * int(limit)
165
-
cursor = strconv.Itoa(offset)
166
-
}
167
-
168
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
169
-
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
170
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
171
-
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
172
-
rp.pages.Error503(w)
173
-
return
174
-
}
175
-
176
-
var xrpcResp types.RepoLogResponse
177
-
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
178
-
l.Error("failed to decode XRPC response", "err", err)
179
-
rp.pages.Error503(w)
180
-
return
181
-
}
182
-
183
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
184
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
185
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
186
-
rp.pages.Error503(w)
187
-
return
188
-
}
189
-
190
-
tagMap := make(map[string][]string)
191
-
if tagBytes != nil {
192
-
var tagResp types.RepoTagsResponse
193
-
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
194
-
for _, tag := range tagResp.Tags {
195
-
hash := tag.Hash
196
-
if tag.Tag != nil {
197
-
hash = tag.Tag.Target.String()
198
-
}
199
-
tagMap[hash] = append(tagMap[hash], tag.Name)
200
-
}
201
-
}
202
-
}
203
-
204
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
205
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
206
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
207
-
rp.pages.Error503(w)
208
-
return
209
-
}
210
-
211
-
if branchBytes != nil {
212
-
var branchResp types.RepoBranchesResponse
213
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
214
-
for _, branch := range branchResp.Branches {
215
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
216
-
}
217
-
}
218
-
}
219
-
220
-
user := rp.oauth.GetUser(r)
221
-
222
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
223
-
if err != nil {
224
-
l.Error("failed to fetch email to did mapping", "err", err)
225
-
}
226
-
227
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
228
-
if err != nil {
229
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
230
-
}
231
-
232
-
repoInfo := f.RepoInfo(user)
233
-
234
-
var shas []string
235
-
for _, c := range xrpcResp.Commits {
236
-
shas = append(shas, c.Hash.String())
237
-
}
238
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
239
-
if err != nil {
240
-
l.Error("failed to getPipelineStatuses", "err", err)
241
-
// non-fatal
242
-
}
243
-
244
-
rp.pages.RepoLog(w, pages.RepoLogParams{
245
-
LoggedInUser: user,
246
-
TagMap: tagMap,
247
-
RepoInfo: repoInfo,
248
-
RepoLogResponse: xrpcResp,
249
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
250
-
VerifiedCommits: vc,
251
-
Pipelines: pipelines,
252
-
})
253
-
}
254
-
255
-
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
256
-
l := rp.logger.With("handler", "RepoDescriptionEdit")
257
-
258
-
f, err := rp.repoResolver.Resolve(r)
259
-
if err != nil {
260
-
l.Error("failed to get repo and knot", "err", err)
261
-
w.WriteHeader(http.StatusBadRequest)
262
-
return
263
-
}
264
-
265
-
user := rp.oauth.GetUser(r)
266
-
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
267
-
RepoInfo: f.RepoInfo(user),
268
-
})
269
-
}
270
-
271
-
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
272
-
l := rp.logger.With("handler", "RepoDescription")
273
-
274
-
f, err := rp.repoResolver.Resolve(r)
275
-
if err != nil {
276
-
l.Error("failed to get repo and knot", "err", err)
277
-
w.WriteHeader(http.StatusBadRequest)
278
-
return
279
-
}
280
-
281
-
repoAt := f.RepoAt()
282
-
rkey := repoAt.RecordKey().String()
283
-
if rkey == "" {
284
-
l.Error("invalid aturi for repo", "err", err)
285
-
w.WriteHeader(http.StatusInternalServerError)
286
-
return
287
-
}
288
-
289
-
user := rp.oauth.GetUser(r)
290
-
291
-
switch r.Method {
292
-
case http.MethodGet:
293
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
294
-
RepoInfo: f.RepoInfo(user),
295
-
})
296
-
return
297
-
case http.MethodPut:
298
-
newDescription := r.FormValue("description")
299
-
client, err := rp.oauth.AuthorizedClient(r)
300
-
if err != nil {
301
-
l.Error("failed to get client")
302
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
303
-
return
304
-
}
305
-
306
-
// optimistic update
307
-
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
308
-
if err != nil {
309
-
l.Error("failed to perform update-description query", "err", err)
310
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
311
-
return
312
-
}
313
-
314
-
newRepo := f.Repo
315
-
newRepo.Description = newDescription
316
-
record := newRepo.AsRecord()
317
-
318
-
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
319
-
//
320
-
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
321
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
322
-
if err != nil {
323
-
// failed to get record
324
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
325
-
return
326
-
}
327
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
328
-
Collection: tangled.RepoNSID,
329
-
Repo: newRepo.Did,
330
-
Rkey: newRepo.Rkey,
331
-
SwapRecord: ex.Cid,
332
-
Record: &lexutil.LexiconTypeDecoder{
333
-
Val: &record,
334
-
},
335
-
})
336
-
337
-
if err != nil {
338
-
l.Error("failed to perferom update-description query", "err", err)
339
-
// failed to get record
340
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
341
-
return
342
-
}
343
-
344
-
newRepoInfo := f.RepoInfo(user)
345
-
newRepoInfo.Description = newDescription
346
-
347
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
348
-
RepoInfo: newRepoInfo,
349
-
})
350
-
return
351
-
}
352
-
}
353
-
354
-
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
355
-
l := rp.logger.With("handler", "RepoCommit")
356
-
357
-
f, err := rp.repoResolver.Resolve(r)
358
-
if err != nil {
359
-
l.Error("failed to fully resolve repo", "err", err)
360
-
return
361
-
}
362
-
ref := chi.URLParam(r, "ref")
363
-
ref, _ = url.PathUnescape(ref)
364
-
365
-
var diffOpts types.DiffOpts
366
-
if d := r.URL.Query().Get("diff"); d == "split" {
367
-
diffOpts.Split = true
368
-
}
369
-
370
-
if !plumbing.IsHash(ref) {
371
-
rp.pages.Error404(w)
372
-
return
373
-
}
374
-
375
-
scheme := "http"
376
-
if !rp.config.Core.Dev {
377
-
scheme = "https"
378
-
}
379
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
380
-
xrpcc := &indigoxrpc.Client{
381
-
Host: host,
382
-
}
383
-
384
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
385
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
386
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
387
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
388
-
rp.pages.Error503(w)
389
-
return
390
-
}
391
-
392
-
var result types.RepoCommitResponse
393
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
394
-
l.Error("failed to decode XRPC response", "err", err)
395
-
rp.pages.Error503(w)
396
-
return
397
-
}
398
-
399
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
400
-
if err != nil {
401
-
l.Error("failed to get email to did mapping", "err", err)
402
-
}
403
-
404
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
405
-
if err != nil {
406
-
l.Error("failed to GetVerifiedCommits", "err", err)
407
-
}
408
-
409
-
user := rp.oauth.GetUser(r)
410
-
repoInfo := f.RepoInfo(user)
411
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
412
-
if err != nil {
413
-
l.Error("failed to getPipelineStatuses", "err", err)
414
-
// non-fatal
415
-
}
416
-
var pipeline *models.Pipeline
417
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
418
-
pipeline = &p
419
-
}
420
-
421
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
422
-
LoggedInUser: user,
423
-
RepoInfo: f.RepoInfo(user),
424
-
RepoCommitResponse: result,
425
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
426
-
VerifiedCommit: vc,
427
-
Pipeline: pipeline,
428
-
DiffOpts: diffOpts,
429
-
})
430
-
}
431
-
432
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
433
-
l := rp.logger.With("handler", "RepoTree")
434
-
435
-
f, err := rp.repoResolver.Resolve(r)
436
-
if err != nil {
437
-
l.Error("failed to fully resolve repo", "err", err)
438
-
return
439
-
}
440
-
441
-
ref := chi.URLParam(r, "ref")
442
-
ref, _ = url.PathUnescape(ref)
443
-
444
-
// if the tree path has a trailing slash, let's strip it
445
-
// so we don't 404
446
-
treePath := chi.URLParam(r, "*")
447
-
treePath, _ = url.PathUnescape(treePath)
448
-
treePath = strings.TrimSuffix(treePath, "/")
449
-
450
-
scheme := "http"
451
-
if !rp.config.Core.Dev {
452
-
scheme = "https"
453
-
}
454
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
455
-
xrpcc := &indigoxrpc.Client{
456
-
Host: host,
457
-
}
458
-
459
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
460
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
461
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
462
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
463
-
rp.pages.Error503(w)
464
-
return
465
-
}
466
-
467
-
// Convert XRPC response to internal types.RepoTreeResponse
468
-
files := make([]types.NiceTree, len(xrpcResp.Files))
469
-
for i, xrpcFile := range xrpcResp.Files {
470
-
file := types.NiceTree{
471
-
Name: xrpcFile.Name,
472
-
Mode: xrpcFile.Mode,
473
-
Size: int64(xrpcFile.Size),
474
-
IsFile: xrpcFile.Is_file,
475
-
IsSubtree: xrpcFile.Is_subtree,
476
-
}
477
-
478
-
// Convert last commit info if present
479
-
if xrpcFile.Last_commit != nil {
480
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
481
-
file.LastCommit = &types.LastCommitInfo{
482
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
483
-
Message: xrpcFile.Last_commit.Message,
484
-
When: commitWhen,
485
-
}
486
-
}
487
-
488
-
files[i] = file
489
-
}
490
-
491
-
result := types.RepoTreeResponse{
492
-
Ref: xrpcResp.Ref,
493
-
Files: files,
494
-
}
495
-
496
-
if xrpcResp.Parent != nil {
497
-
result.Parent = *xrpcResp.Parent
498
-
}
499
-
if xrpcResp.Dotdot != nil {
500
-
result.DotDot = *xrpcResp.Dotdot
501
-
}
502
-
if xrpcResp.Readme != nil {
503
-
result.ReadmeFileName = xrpcResp.Readme.Filename
504
-
result.Readme = xrpcResp.Readme.Contents
505
-
}
506
-
507
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
508
-
// so we can safely redirect to the "parent" (which is the same file).
509
-
if len(result.Files) == 0 && result.Parent == treePath {
510
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
511
-
http.Redirect(w, r, redirectTo, http.StatusFound)
512
-
return
513
-
}
514
-
515
-
user := rp.oauth.GetUser(r)
516
-
517
-
var breadcrumbs [][]string
518
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
519
-
if treePath != "" {
520
-
for idx, elem := range strings.Split(treePath, "/") {
521
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
522
-
}
523
-
}
524
-
525
-
sortFiles(result.Files)
526
-
527
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
528
-
LoggedInUser: user,
529
-
BreadCrumbs: breadcrumbs,
530
-
TreePath: treePath,
531
-
RepoInfo: f.RepoInfo(user),
532
-
RepoTreeResponse: result,
533
-
})
534
-
}
535
-
536
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
537
-
l := rp.logger.With("handler", "RepoTags")
538
-
539
-
f, err := rp.repoResolver.Resolve(r)
540
-
if err != nil {
541
-
l.Error("failed to get repo and knot", "err", err)
542
-
return
543
-
}
544
-
545
-
scheme := "http"
546
-
if !rp.config.Core.Dev {
547
-
scheme = "https"
548
-
}
549
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
550
-
xrpcc := &indigoxrpc.Client{
551
-
Host: host,
552
-
}
553
-
554
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
555
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
556
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
557
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
558
-
rp.pages.Error503(w)
559
-
return
560
-
}
561
-
562
-
var result types.RepoTagsResponse
563
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
564
-
l.Error("failed to decode XRPC response", "err", err)
565
-
rp.pages.Error503(w)
566
-
return
567
-
}
568
-
569
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
570
-
if err != nil {
571
-
l.Error("failed grab artifacts", "err", err)
572
-
return
573
-
}
574
-
575
-
// convert artifacts to map for easy UI building
576
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
577
-
for _, a := range artifacts {
578
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
579
-
}
580
-
581
-
var danglingArtifacts []models.Artifact
582
-
for _, a := range artifacts {
583
-
found := false
584
-
for _, t := range result.Tags {
585
-
if t.Tag != nil {
586
-
if t.Tag.Hash == a.Tag {
587
-
found = true
588
-
}
589
-
}
590
-
}
591
-
592
-
if !found {
593
-
danglingArtifacts = append(danglingArtifacts, a)
594
-
}
595
-
}
596
-
597
-
user := rp.oauth.GetUser(r)
598
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
599
-
LoggedInUser: user,
600
-
RepoInfo: f.RepoInfo(user),
601
-
RepoTagsResponse: result,
602
-
ArtifactMap: artifactMap,
603
-
DanglingArtifacts: danglingArtifacts,
604
-
})
605
-
}
606
-
607
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
608
-
l := rp.logger.With("handler", "RepoBranches")
609
-
610
-
f, err := rp.repoResolver.Resolve(r)
611
-
if err != nil {
612
-
l.Error("failed to get repo and knot", "err", err)
613
-
return
614
-
}
615
-
616
-
scheme := "http"
617
-
if !rp.config.Core.Dev {
618
-
scheme = "https"
619
-
}
620
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
621
-
xrpcc := &indigoxrpc.Client{
622
-
Host: host,
623
-
}
624
-
625
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
626
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
627
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
628
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
629
-
rp.pages.Error503(w)
630
-
return
631
-
}
632
-
633
-
var result types.RepoBranchesResponse
634
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
635
-
l.Error("failed to decode XRPC response", "err", err)
636
-
rp.pages.Error503(w)
637
-
return
638
-
}
639
-
640
-
sortBranches(result.Branches)
641
-
642
-
user := rp.oauth.GetUser(r)
643
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
644
-
LoggedInUser: user,
645
-
RepoInfo: f.RepoInfo(user),
646
-
RepoBranchesResponse: result,
647
-
})
648
-
}
649
-
650
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
651
-
l := rp.logger.With("handler", "DeleteBranch")
652
-
653
-
f, err := rp.repoResolver.Resolve(r)
654
-
if err != nil {
655
-
l.Error("failed to get repo and knot", "err", err)
656
-
return
657
-
}
658
-
659
-
noticeId := "delete-branch-error"
660
-
fail := func(msg string, err error) {
661
-
l.Error(msg, "err", err)
662
-
rp.pages.Notice(w, noticeId, msg)
663
-
}
664
-
665
-
branch := r.FormValue("branch")
666
-
if branch == "" {
667
-
fail("No branch provided.", nil)
668
-
return
669
-
}
670
-
671
-
client, err := rp.oauth.ServiceClient(
672
-
r,
673
-
oauth.WithService(f.Knot),
674
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
675
-
oauth.WithDev(rp.config.Core.Dev),
676
-
)
677
-
if err != nil {
678
-
fail("Failed to connect to knotserver", nil)
679
-
return
680
-
}
681
-
682
-
err = tangled.RepoDeleteBranch(
683
-
r.Context(),
684
-
client,
685
-
&tangled.RepoDeleteBranch_Input{
686
-
Branch: branch,
687
-
Repo: f.RepoAt().String(),
688
-
},
689
-
)
690
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
691
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
692
-
return
693
-
}
694
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
695
-
696
-
rp.pages.HxRefresh(w)
697
-
}
698
-
699
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
700
-
l := rp.logger.With("handler", "RepoBlob")
701
-
702
-
f, err := rp.repoResolver.Resolve(r)
703
-
if err != nil {
704
-
l.Error("failed to get repo and knot", "err", err)
705
-
return
706
-
}
707
-
708
-
ref := chi.URLParam(r, "ref")
709
-
ref, _ = url.PathUnescape(ref)
710
-
711
-
filePath := chi.URLParam(r, "*")
712
-
filePath, _ = url.PathUnescape(filePath)
713
-
714
-
scheme := "http"
715
-
if !rp.config.Core.Dev {
716
-
scheme = "https"
717
-
}
718
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
719
-
xrpcc := &indigoxrpc.Client{
720
-
Host: host,
721
-
}
722
-
723
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
724
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
725
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
726
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
727
-
rp.pages.Error503(w)
728
-
return
729
-
}
730
-
731
-
// Use XRPC response directly instead of converting to internal types
732
-
733
-
var breadcrumbs [][]string
734
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
735
-
if filePath != "" {
736
-
for idx, elem := range strings.Split(filePath, "/") {
737
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
738
-
}
739
-
}
740
-
741
-
showRendered := false
742
-
renderToggle := false
743
-
744
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
745
-
renderToggle = true
746
-
showRendered = r.URL.Query().Get("code") != "true"
747
-
}
748
-
749
-
var unsupported bool
750
-
var isImage bool
751
-
var isVideo bool
752
-
var contentSrc string
753
-
754
-
if resp.IsBinary != nil && *resp.IsBinary {
755
-
ext := strings.ToLower(filepath.Ext(resp.Path))
756
-
switch ext {
757
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
758
-
isImage = true
759
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
760
-
isVideo = true
761
-
default:
762
-
unsupported = true
763
-
}
764
-
765
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
766
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
767
-
768
-
baseURL := &url.URL{
769
-
Scheme: scheme,
770
-
Host: f.Knot,
771
-
Path: "/xrpc/sh.tangled.repo.blob",
772
-
}
773
-
query := baseURL.Query()
774
-
query.Set("repo", repoName)
775
-
query.Set("ref", ref)
776
-
query.Set("path", filePath)
777
-
query.Set("raw", "true")
778
-
baseURL.RawQuery = query.Encode()
779
-
blobURL := baseURL.String()
780
-
781
-
contentSrc = blobURL
782
-
if !rp.config.Core.Dev {
783
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
784
-
}
785
-
}
786
-
787
-
lines := 0
788
-
if resp.IsBinary == nil || !*resp.IsBinary {
789
-
lines = strings.Count(resp.Content, "\n") + 1
790
-
}
791
-
792
-
var sizeHint uint64
793
-
if resp.Size != nil {
794
-
sizeHint = uint64(*resp.Size)
795
-
} else {
796
-
sizeHint = uint64(len(resp.Content))
797
-
}
798
-
799
-
user := rp.oauth.GetUser(r)
800
-
801
-
// Determine if content is binary (dereference pointer)
802
-
isBinary := false
803
-
if resp.IsBinary != nil {
804
-
isBinary = *resp.IsBinary
805
-
}
806
-
807
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
808
-
LoggedInUser: user,
809
-
RepoInfo: f.RepoInfo(user),
810
-
BreadCrumbs: breadcrumbs,
811
-
ShowRendered: showRendered,
812
-
RenderToggle: renderToggle,
813
-
Unsupported: unsupported,
814
-
IsImage: isImage,
815
-
IsVideo: isVideo,
816
-
ContentSrc: contentSrc,
817
-
RepoBlob_Output: resp,
818
-
Contents: resp.Content,
819
-
Lines: lines,
820
-
SizeHint: sizeHint,
821
-
IsBinary: isBinary,
822
-
})
823
-
}
824
-
825
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
826
-
l := rp.logger.With("handler", "RepoBlobRaw")
827
-
828
-
f, err := rp.repoResolver.Resolve(r)
829
-
if err != nil {
830
-
l.Error("failed to get repo and knot", "err", err)
831
-
w.WriteHeader(http.StatusBadRequest)
832
-
return
833
-
}
834
-
835
-
ref := chi.URLParam(r, "ref")
836
-
ref, _ = url.PathUnescape(ref)
837
-
838
-
filePath := chi.URLParam(r, "*")
839
-
filePath, _ = url.PathUnescape(filePath)
840
-
841
-
scheme := "http"
842
-
if !rp.config.Core.Dev {
843
-
scheme = "https"
844
-
}
845
-
846
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
847
-
baseURL := &url.URL{
848
-
Scheme: scheme,
849
-
Host: f.Knot,
850
-
Path: "/xrpc/sh.tangled.repo.blob",
851
-
}
852
-
query := baseURL.Query()
853
-
query.Set("repo", repo)
854
-
query.Set("ref", ref)
855
-
query.Set("path", filePath)
856
-
query.Set("raw", "true")
857
-
baseURL.RawQuery = query.Encode()
858
-
blobURL := baseURL.String()
859
-
860
-
req, err := http.NewRequest("GET", blobURL, nil)
861
-
if err != nil {
862
-
l.Error("failed to create request", "err", err)
863
-
return
864
-
}
865
-
866
-
// forward the If-None-Match header
867
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
868
-
req.Header.Set("If-None-Match", clientETag)
869
-
}
870
-
871
-
client := &http.Client{}
872
-
resp, err := client.Do(req)
873
-
if err != nil {
874
-
l.Error("failed to reach knotserver", "err", err)
875
-
rp.pages.Error503(w)
876
-
return
877
-
}
878
-
defer resp.Body.Close()
879
-
880
-
// forward 304 not modified
881
-
if resp.StatusCode == http.StatusNotModified {
882
-
w.WriteHeader(http.StatusNotModified)
883
-
return
884
-
}
885
-
886
-
if resp.StatusCode != http.StatusOK {
887
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
888
-
w.WriteHeader(resp.StatusCode)
889
-
_, _ = io.Copy(w, resp.Body)
890
-
return
891
-
}
892
-
893
-
contentType := resp.Header.Get("Content-Type")
894
-
body, err := io.ReadAll(resp.Body)
895
-
if err != nil {
896
-
l.Error("error reading response body from knotserver", "err", err)
897
-
w.WriteHeader(http.StatusInternalServerError)
898
-
return
899
-
}
900
-
901
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
902
-
// serve all textual content as text/plain
903
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
904
-
w.Write(body)
905
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
906
-
// serve images and videos with their original content type
907
-
w.Header().Set("Content-Type", contentType)
908
-
w.Write(body)
909
-
} else {
910
-
w.WriteHeader(http.StatusUnsupportedMediaType)
911
-
w.Write([]byte("unsupported content type"))
912
-
return
913
-
}
914
-
}
915
-
916
-
// isTextualMimeType returns true if the MIME type represents textual content
917
-
// that should be served as text/plain
918
-
func isTextualMimeType(mimeType string) bool {
919
-
textualTypes := []string{
920
-
"application/json",
921
-
"application/xml",
922
-
"application/yaml",
923
-
"application/x-yaml",
924
-
"application/toml",
925
-
"application/javascript",
926
-
"application/ecmascript",
927
-
"message/",
928
-
}
929
-
930
-
return slices.Contains(textualTypes, mimeType)
931
-
}
932
-
933
// modify the spindle configured for this repo
934
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
935
user := rp.oauth.GetUser(r)
···
1785
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1786
}
1787
1788
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1789
-
l := rp.logger.With("handler", "SetDefaultBranch")
1790
-
1791
-
f, err := rp.repoResolver.Resolve(r)
1792
-
if err != nil {
1793
-
l.Error("failed to get repo and knot", "err", err)
1794
-
return
1795
-
}
1796
-
1797
-
noticeId := "operation-error"
1798
-
branch := r.FormValue("branch")
1799
-
if branch == "" {
1800
-
http.Error(w, "malformed form", http.StatusBadRequest)
1801
-
return
1802
-
}
1803
-
1804
-
client, err := rp.oauth.ServiceClient(
1805
-
r,
1806
-
oauth.WithService(f.Knot),
1807
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1808
-
oauth.WithDev(rp.config.Core.Dev),
1809
-
)
1810
-
if err != nil {
1811
-
l.Error("failed to connect to knot server", "err", err)
1812
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1813
-
return
1814
-
}
1815
-
1816
-
xe := tangled.RepoSetDefaultBranch(
1817
-
r.Context(),
1818
-
client,
1819
-
&tangled.RepoSetDefaultBranch_Input{
1820
-
Repo: f.RepoAt().String(),
1821
-
DefaultBranch: branch,
1822
-
},
1823
-
)
1824
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1825
-
l.Error("xrpc failed", "err", xe)
1826
-
rp.pages.Notice(w, noticeId, err.Error())
1827
-
return
1828
-
}
1829
-
1830
-
rp.pages.HxRefresh(w)
1831
-
}
1832
-
1833
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1834
-
user := rp.oauth.GetUser(r)
1835
-
l := rp.logger.With("handler", "Secrets")
1836
-
l = l.With("did", user.Did)
1837
-
1838
-
f, err := rp.repoResolver.Resolve(r)
1839
-
if err != nil {
1840
-
l.Error("failed to get repo and knot", "err", err)
1841
-
return
1842
-
}
1843
-
1844
-
if f.Spindle == "" {
1845
-
l.Error("empty spindle cannot add/rm secret", "err", err)
1846
-
return
1847
-
}
1848
-
1849
-
lxm := tangled.RepoAddSecretNSID
1850
-
if r.Method == http.MethodDelete {
1851
-
lxm = tangled.RepoRemoveSecretNSID
1852
-
}
1853
-
1854
-
spindleClient, err := rp.oauth.ServiceClient(
1855
-
r,
1856
-
oauth.WithService(f.Spindle),
1857
-
oauth.WithLxm(lxm),
1858
-
oauth.WithExp(60),
1859
-
oauth.WithDev(rp.config.Core.Dev),
1860
-
)
1861
-
if err != nil {
1862
-
l.Error("failed to create spindle client", "err", err)
1863
-
return
1864
-
}
1865
-
1866
-
key := r.FormValue("key")
1867
-
if key == "" {
1868
-
w.WriteHeader(http.StatusBadRequest)
1869
-
return
1870
-
}
1871
-
1872
-
switch r.Method {
1873
-
case http.MethodPut:
1874
-
errorId := "add-secret-error"
1875
-
1876
-
value := r.FormValue("value")
1877
-
if value == "" {
1878
-
w.WriteHeader(http.StatusBadRequest)
1879
-
return
1880
-
}
1881
-
1882
-
err = tangled.RepoAddSecret(
1883
-
r.Context(),
1884
-
spindleClient,
1885
-
&tangled.RepoAddSecret_Input{
1886
-
Repo: f.RepoAt().String(),
1887
-
Key: key,
1888
-
Value: value,
1889
-
},
1890
-
)
1891
-
if err != nil {
1892
-
l.Error("Failed to add secret.", "err", err)
1893
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
1894
-
return
1895
-
}
1896
-
1897
-
case http.MethodDelete:
1898
-
errorId := "operation-error"
1899
-
1900
-
err = tangled.RepoRemoveSecret(
1901
-
r.Context(),
1902
-
spindleClient,
1903
-
&tangled.RepoRemoveSecret_Input{
1904
-
Repo: f.RepoAt().String(),
1905
-
Key: key,
1906
-
},
1907
-
)
1908
-
if err != nil {
1909
-
l.Error("Failed to delete secret.", "err", err)
1910
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1911
-
return
1912
-
}
1913
-
}
1914
-
1915
-
rp.pages.HxRefresh(w)
1916
-
}
1917
-
1918
-
type tab = map[string]any
1919
-
1920
-
var (
1921
-
// would be great to have ordered maps right about now
1922
-
settingsTabs []tab = []tab{
1923
-
{"Name": "general", "Icon": "sliders-horizontal"},
1924
-
{"Name": "access", "Icon": "users"},
1925
-
{"Name": "pipelines", "Icon": "layers-2"},
1926
-
}
1927
-
)
1928
-
1929
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1930
-
tabVal := r.URL.Query().Get("tab")
1931
-
if tabVal == "" {
1932
-
tabVal = "general"
1933
-
}
1934
-
1935
-
switch tabVal {
1936
-
case "general":
1937
-
rp.generalSettings(w, r)
1938
-
1939
-
case "access":
1940
-
rp.accessSettings(w, r)
1941
-
1942
-
case "pipelines":
1943
-
rp.pipelineSettings(w, r)
1944
-
}
1945
-
}
1946
-
1947
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1948
-
l := rp.logger.With("handler", "generalSettings")
1949
-
1950
-
f, err := rp.repoResolver.Resolve(r)
1951
-
user := rp.oauth.GetUser(r)
1952
-
1953
-
scheme := "http"
1954
-
if !rp.config.Core.Dev {
1955
-
scheme = "https"
1956
-
}
1957
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1958
-
xrpcc := &indigoxrpc.Client{
1959
-
Host: host,
1960
-
}
1961
-
1962
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1963
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1964
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1965
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1966
-
rp.pages.Error503(w)
1967
-
return
1968
-
}
1969
-
1970
-
var result types.RepoBranchesResponse
1971
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1972
-
l.Error("failed to decode XRPC response", "err", err)
1973
-
rp.pages.Error503(w)
1974
-
return
1975
-
}
1976
-
1977
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1978
-
if err != nil {
1979
-
l.Error("failed to fetch labels", "err", err)
1980
-
rp.pages.Error503(w)
1981
-
return
1982
-
}
1983
-
1984
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1985
-
if err != nil {
1986
-
l.Error("failed to fetch labels", "err", err)
1987
-
rp.pages.Error503(w)
1988
-
return
1989
-
}
1990
-
// remove default labels from the labels list, if present
1991
-
defaultLabelMap := make(map[string]bool)
1992
-
for _, dl := range defaultLabels {
1993
-
defaultLabelMap[dl.AtUri().String()] = true
1994
-
}
1995
-
n := 0
1996
-
for _, l := range labels {
1997
-
if !defaultLabelMap[l.AtUri().String()] {
1998
-
labels[n] = l
1999
-
n++
2000
-
}
2001
-
}
2002
-
labels = labels[:n]
2003
-
2004
-
subscribedLabels := make(map[string]struct{})
2005
-
for _, l := range f.Repo.Labels {
2006
-
subscribedLabels[l] = struct{}{}
2007
-
}
2008
-
2009
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
2010
-
// if all default labels are subbed, show the "unsubscribe all" button
2011
-
shouldSubscribeAll := false
2012
-
for _, dl := range defaultLabels {
2013
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
2014
-
// one of the default labels is not subscribed to
2015
-
shouldSubscribeAll = true
2016
-
break
2017
-
}
2018
-
}
2019
-
2020
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
2021
-
LoggedInUser: user,
2022
-
RepoInfo: f.RepoInfo(user),
2023
-
Branches: result.Branches,
2024
-
Labels: labels,
2025
-
DefaultLabels: defaultLabels,
2026
-
SubscribedLabels: subscribedLabels,
2027
-
ShouldSubscribeAll: shouldSubscribeAll,
2028
-
Tabs: settingsTabs,
2029
-
Tab: "general",
2030
-
})
2031
-
}
2032
-
2033
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2034
-
l := rp.logger.With("handler", "accessSettings")
2035
-
2036
-
f, err := rp.repoResolver.Resolve(r)
2037
-
user := rp.oauth.GetUser(r)
2038
-
2039
-
repoCollaborators, err := f.Collaborators(r.Context())
2040
-
if err != nil {
2041
-
l.Error("failed to get collaborators", "err", err)
2042
-
}
2043
-
2044
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
2045
-
LoggedInUser: user,
2046
-
RepoInfo: f.RepoInfo(user),
2047
-
Tabs: settingsTabs,
2048
-
Tab: "access",
2049
-
Collaborators: repoCollaborators,
2050
-
})
2051
-
}
2052
-
2053
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2054
-
l := rp.logger.With("handler", "pipelineSettings")
2055
-
2056
-
f, err := rp.repoResolver.Resolve(r)
2057
-
user := rp.oauth.GetUser(r)
2058
-
2059
-
// all spindles that the repo owner is a member of
2060
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2061
-
if err != nil {
2062
-
l.Error("failed to fetch spindles", "err", err)
2063
-
return
2064
-
}
2065
-
2066
-
var secrets []*tangled.RepoListSecrets_Secret
2067
-
if f.Spindle != "" {
2068
-
if spindleClient, err := rp.oauth.ServiceClient(
2069
-
r,
2070
-
oauth.WithService(f.Spindle),
2071
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
2072
-
oauth.WithExp(60),
2073
-
oauth.WithDev(rp.config.Core.Dev),
2074
-
); err != nil {
2075
-
l.Error("failed to create spindle client", "err", err)
2076
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2077
-
l.Error("failed to fetch secrets", "err", err)
2078
-
} else {
2079
-
secrets = resp.Secrets
2080
-
}
2081
-
}
2082
-
2083
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2084
-
return strings.Compare(a.Key, b.Key)
2085
-
})
2086
-
2087
-
var dids []string
2088
-
for _, s := range secrets {
2089
-
dids = append(dids, s.CreatedBy)
2090
-
}
2091
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2092
-
2093
-
// convert to a more manageable form
2094
-
var niceSecret []map[string]any
2095
-
for id, s := range secrets {
2096
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2097
-
niceSecret = append(niceSecret, map[string]any{
2098
-
"Id": id,
2099
-
"Key": s.Key,
2100
-
"CreatedAt": when,
2101
-
"CreatedBy": resolvedIdents[id].Handle.String(),
2102
-
})
2103
-
}
2104
-
2105
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2106
-
LoggedInUser: user,
2107
-
RepoInfo: f.RepoInfo(user),
2108
-
Tabs: settingsTabs,
2109
-
Tab: "pipelines",
2110
-
Spindles: spindles,
2111
-
CurrentSpindle: f.Spindle,
2112
-
Secrets: niceSecret,
2113
-
})
2114
-
}
2115
-
2116
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2117
l := rp.logger.With("handler", "SyncRepoFork")
2118
···
2253
Source: sourceAt,
2254
Description: f.Repo.Description,
2255
Created: time.Now(),
2256
-
Labels: models.DefaultLabelDefs(),
2257
}
2258
record := repo.AsRecord()
2259
···
2369
aturi = ""
2370
2371
rp.notifier.NewRepo(r.Context(), repo)
2372
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
2373
}
2374
}
2375
···
2394
})
2395
return err
2396
}
2397
-
2398
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2399
-
l := rp.logger.With("handler", "RepoCompareNew")
2400
-
2401
-
user := rp.oauth.GetUser(r)
2402
-
f, err := rp.repoResolver.Resolve(r)
2403
-
if err != nil {
2404
-
l.Error("failed to get repo and knot", "err", err)
2405
-
return
2406
-
}
2407
-
2408
-
scheme := "http"
2409
-
if !rp.config.Core.Dev {
2410
-
scheme = "https"
2411
-
}
2412
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2413
-
xrpcc := &indigoxrpc.Client{
2414
-
Host: host,
2415
-
}
2416
-
2417
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2418
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2419
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2420
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2421
-
rp.pages.Error503(w)
2422
-
return
2423
-
}
2424
-
2425
-
var branchResult types.RepoBranchesResponse
2426
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2427
-
l.Error("failed to decode XRPC branches response", "err", err)
2428
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2429
-
return
2430
-
}
2431
-
branches := branchResult.Branches
2432
-
2433
-
sortBranches(branches)
2434
-
2435
-
var defaultBranch string
2436
-
for _, b := range branches {
2437
-
if b.IsDefault {
2438
-
defaultBranch = b.Name
2439
-
}
2440
-
}
2441
-
2442
-
base := defaultBranch
2443
-
head := defaultBranch
2444
-
2445
-
params := r.URL.Query()
2446
-
queryBase := params.Get("base")
2447
-
queryHead := params.Get("head")
2448
-
if queryBase != "" {
2449
-
base = queryBase
2450
-
}
2451
-
if queryHead != "" {
2452
-
head = queryHead
2453
-
}
2454
-
2455
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2456
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2457
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2458
-
rp.pages.Error503(w)
2459
-
return
2460
-
}
2461
-
2462
-
var tags types.RepoTagsResponse
2463
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2464
-
l.Error("failed to decode XRPC tags response", "err", err)
2465
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2466
-
return
2467
-
}
2468
-
2469
-
repoinfo := f.RepoInfo(user)
2470
-
2471
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2472
-
LoggedInUser: user,
2473
-
RepoInfo: repoinfo,
2474
-
Branches: branches,
2475
-
Tags: tags.Tags,
2476
-
Base: base,
2477
-
Head: head,
2478
-
})
2479
-
}
2480
-
2481
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2482
-
l := rp.logger.With("handler", "RepoCompare")
2483
-
2484
-
user := rp.oauth.GetUser(r)
2485
-
f, err := rp.repoResolver.Resolve(r)
2486
-
if err != nil {
2487
-
l.Error("failed to get repo and knot", "err", err)
2488
-
return
2489
-
}
2490
-
2491
-
var diffOpts types.DiffOpts
2492
-
if d := r.URL.Query().Get("diff"); d == "split" {
2493
-
diffOpts.Split = true
2494
-
}
2495
-
2496
-
// if user is navigating to one of
2497
-
// /compare/{base}/{head}
2498
-
// /compare/{base}...{head}
2499
-
base := chi.URLParam(r, "base")
2500
-
head := chi.URLParam(r, "head")
2501
-
if base == "" && head == "" {
2502
-
rest := chi.URLParam(r, "*") // master...feature/xyz
2503
-
parts := strings.SplitN(rest, "...", 2)
2504
-
if len(parts) == 2 {
2505
-
base = parts[0]
2506
-
head = parts[1]
2507
-
}
2508
-
}
2509
-
2510
-
base, _ = url.PathUnescape(base)
2511
-
head, _ = url.PathUnescape(head)
2512
-
2513
-
if base == "" || head == "" {
2514
-
l.Error("invalid comparison")
2515
-
rp.pages.Error404(w)
2516
-
return
2517
-
}
2518
-
2519
-
scheme := "http"
2520
-
if !rp.config.Core.Dev {
2521
-
scheme = "https"
2522
-
}
2523
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2524
-
xrpcc := &indigoxrpc.Client{
2525
-
Host: host,
2526
-
}
2527
-
2528
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2529
-
2530
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2531
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2532
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2533
-
rp.pages.Error503(w)
2534
-
return
2535
-
}
2536
-
2537
-
var branches types.RepoBranchesResponse
2538
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2539
-
l.Error("failed to decode XRPC branches response", "err", err)
2540
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2541
-
return
2542
-
}
2543
-
2544
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2545
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2546
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2547
-
rp.pages.Error503(w)
2548
-
return
2549
-
}
2550
-
2551
-
var tags types.RepoTagsResponse
2552
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2553
-
l.Error("failed to decode XRPC tags response", "err", err)
2554
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2555
-
return
2556
-
}
2557
-
2558
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2559
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2560
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2561
-
rp.pages.Error503(w)
2562
-
return
2563
-
}
2564
-
2565
-
var formatPatch types.RepoFormatPatchResponse
2566
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2567
-
l.Error("failed to decode XRPC compare response", "err", err)
2568
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2569
-
return
2570
-
}
2571
-
2572
-
var diff types.NiceDiff
2573
-
if formatPatch.CombinedPatchRaw != "" {
2574
-
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2575
-
} else {
2576
-
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2577
-
}
2578
-
2579
-
repoinfo := f.RepoInfo(user)
2580
-
2581
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2582
-
LoggedInUser: user,
2583
-
RepoInfo: repoinfo,
2584
-
Branches: branches.Branches,
2585
-
Tags: tags.Tags,
2586
-
Base: base,
2587
-
Head: head,
2588
-
Diff: &diff,
2589
-
DiffOpts: diffOpts,
2590
-
})
2591
-
2592
-
}
···
3
import (
4
"context"
5
"database/sql"
6
"errors"
7
"fmt"
8
"log/slog"
9
"net/http"
10
"net/url"
11
"slices"
12
"strings"
13
"time"
14
15
"tangled.org/core/api/tangled"
16
"tangled.org/core/appview/config"
17
"tangled.org/core/appview/db"
18
"tangled.org/core/appview/models"
19
"tangled.org/core/appview/notify"
20
"tangled.org/core/appview/oauth"
21
"tangled.org/core/appview/pages"
22
"tangled.org/core/appview/reporesolver"
23
"tangled.org/core/appview/validator"
24
xrpcclient "tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/eventconsumer"
26
"tangled.org/core/idresolver"
27
"tangled.org/core/rbac"
28
"tangled.org/core/tid"
29
"tangled.org/core/xrpc/serviceauth"
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"
35
securejoin "github.com/cyphar/filepath-securejoin"
36
"github.com/go-chi/chi/v5"
37
)
38
39
type Repo struct {
···
78
}
79
}
80
81
// modify the spindle configured for this repo
82
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
83
user := rp.oauth.GetUser(r)
···
933
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
934
}
935
936
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
937
l := rp.logger.With("handler", "SyncRepoFork")
938
···
1073
Source: sourceAt,
1074
Description: f.Repo.Description,
1075
Created: time.Now(),
1076
+
Labels: rp.config.Label.DefaultLabelDefs,
1077
}
1078
record := repo.AsRecord()
1079
···
1189
aturi = ""
1190
1191
rp.notifier.NewRepo(r.Context(), repo)
1192
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
1193
}
1194
}
1195
···
1214
})
1215
return err
1216
}
+2
-37
appview/repo/repo_util.go
+2
-37
appview/repo/repo_util.go
···
1
package repo
2
3
import (
4
-
"context"
5
"crypto/rand"
6
-
"fmt"
7
"math/big"
8
"slices"
9
"sort"
···
19
20
func sortFiles(files []types.NiceTree) {
21
sort.Slice(files, func(i, j int) bool {
22
-
iIsFile := files[i].IsFile
23
-
jIsFile := files[j].IsFile
24
if iIsFile != jIsFile {
25
return !iIsFile
26
}
···
90
}
91
92
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
}
127
128
func randomString(n int) string {
···
1
package repo
2
3
import (
4
"crypto/rand"
5
"math/big"
6
"slices"
7
"sort"
···
17
18
func sortFiles(files []types.NiceTree) {
19
sort.Slice(files, func(i, j int) bool {
20
+
iIsFile := files[i].IsFile()
21
+
jIsFile := files[j].IsFile()
22
if iIsFile != jIsFile {
23
return !iIsFile
24
}
···
88
}
89
90
return
91
}
92
93
func randomString(n int) string {
+15
-20
appview/repo/router.go
+15
-20
appview/repo/router.go
···
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
r := chi.NewRouter()
12
-
r.Get("/", rp.RepoIndex)
13
-
r.Get("/opengraph", rp.RepoOpenGraphSummary)
14
-
r.Get("/feed.atom", rp.RepoAtomFeed)
15
-
r.Get("/commits/{ref}", rp.RepoLog)
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
-
r.Get("/", rp.RepoIndex)
18
-
r.Get("/*", rp.RepoTree)
19
})
20
-
r.Get("/commit/{ref}", rp.RepoCommit)
21
-
r.Get("/branches", rp.RepoBranches)
22
r.Delete("/branches", rp.DeleteBranch)
23
r.Route("/tags", func(r chi.Router) {
24
-
r.Get("/", rp.RepoTags)
25
r.Route("/{tag}", func(r chi.Router) {
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
···
37
})
38
})
39
})
40
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
43
// intentionally doesn't use /* as this isn't
···
54
})
55
56
r.Route("/compare", func(r chi.Router) {
57
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
58
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
// /compare/{ref1}...{ref2}
61
// for example:
62
// /compare/master...some/feature
63
// /compare/master...example.com:another/feature <- this is a fork
64
-
r.Get("/{base}/{head}", rp.RepoCompare)
65
-
r.Get("/*", rp.RepoCompare)
66
})
67
68
// label panel in issues/pulls/discussions/tasks
···
74
// settings routes, needs auth
75
r.Group(func(r chi.Router) {
76
r.Use(middleware.AuthMiddleware(rp.oauth))
77
-
// repo description can only be edited by owner
78
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) {
79
-
r.Put("/", rp.RepoDescription)
80
-
r.Get("/", rp.RepoDescription)
81
-
r.Get("/edit", rp.RepoDescriptionEdit)
82
-
})
83
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
84
-
r.Get("/", rp.RepoSettings)
85
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
86
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
87
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
···
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
r := chi.NewRouter()
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)
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
19
})
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
r.Delete("/branches", rp.DeleteBranch)
23
r.Route("/tags", func(r chi.Router) {
24
+
r.Get("/", rp.Tags)
25
r.Route("/{tag}", func(r chi.Router) {
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
···
37
})
38
})
39
})
40
+
r.Get("/blob/{ref}/*", rp.Blob)
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
43
// intentionally doesn't use /* as this isn't
···
54
})
55
56
r.Route("/compare", func(r chi.Router) {
57
+
r.Get("/", rp.CompareNew) // start an new comparison
58
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
// /compare/{ref1}...{ref2}
61
// for example:
62
// /compare/master...some/feature
63
// /compare/master...example.com:another/feature <- this is a fork
64
+
r.Get("/{base}/{head}", rp.Compare)
65
+
r.Get("/*", rp.Compare)
66
})
67
68
// label panel in issues/pulls/discussions/tasks
···
74
// settings routes, needs auth
75
r.Group(func(r chi.Router) {
76
r.Use(middleware.AuthMiddleware(rp.oauth))
77
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
78
+
r.Get("/", rp.Settings)
79
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
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
+
}
+106
appview/repo/tree.go
+106
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
+
}
57
+
// Convert last commit info if present
58
+
if xrpcFile.Last_commit != nil {
59
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
60
+
file.LastCommit = &types.LastCommitInfo{
61
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
62
+
Message: xrpcFile.Last_commit.Message,
63
+
When: commitWhen,
64
+
}
65
+
}
66
+
files[i] = file
67
+
}
68
+
result := types.RepoTreeResponse{
69
+
Ref: xrpcResp.Ref,
70
+
Files: files,
71
+
}
72
+
if xrpcResp.Parent != nil {
73
+
result.Parent = *xrpcResp.Parent
74
+
}
75
+
if xrpcResp.Dotdot != nil {
76
+
result.DotDot = *xrpcResp.Dotdot
77
+
}
78
+
if xrpcResp.Readme != nil {
79
+
result.ReadmeFileName = xrpcResp.Readme.Filename
80
+
result.Readme = xrpcResp.Readme.Contents
81
+
}
82
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
83
+
// so we can safely redirect to the "parent" (which is the same file).
84
+
if len(result.Files) == 0 && result.Parent == treePath {
85
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
86
+
http.Redirect(w, r, redirectTo, http.StatusFound)
87
+
return
88
+
}
89
+
user := rp.oauth.GetUser(r)
90
+
var breadcrumbs [][]string
91
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
92
+
if treePath != "" {
93
+
for idx, elem := range strings.Split(treePath, "/") {
94
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
95
+
}
96
+
}
97
+
sortFiles(result.Files)
98
+
99
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
100
+
LoggedInUser: user,
101
+
BreadCrumbs: breadcrumbs,
102
+
TreePath: treePath,
103
+
RepoInfo: f.RepoInfo(user),
104
+
RepoTreeResponse: result,
105
+
})
106
+
}
+2
appview/reporesolver/resolver.go
+2
appview/reporesolver/resolver.go
+1
appview/settings/settings.go
+1
appview/settings/settings.go
+9
appview/spindles/spindles.go
+9
appview/spindles/spindles.go
···
6
"log/slog"
7
"net/http"
8
"slices"
9
"time"
10
11
"github.com/go-chi/chi/v5"
···
146
}
147
148
instance := r.FormValue("instance")
149
if instance == "" {
150
s.Pages.Notice(w, noticeId, "Incomplete form.")
151
return
···
484
}
485
486
member := r.FormValue("member")
487
if member == "" {
488
l.Error("empty member")
489
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
613
}
614
615
member := r.FormValue("member")
616
if member == "" {
617
l.Error("empty member")
618
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
6
"log/slog"
7
"net/http"
8
"slices"
9
+
"strings"
10
"time"
11
12
"github.com/go-chi/chi/v5"
···
147
}
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, "/")
156
if instance == "" {
157
s.Pages.Notice(w, noticeId, "Incomplete form.")
158
return
···
491
}
492
493
member := r.FormValue("member")
494
+
member = strings.TrimPrefix(member, "@")
495
if member == "" {
496
l.Error("empty member")
497
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
621
}
622
623
member := r.FormValue("member")
624
+
member = strings.TrimPrefix(member, "@")
625
if member == "" {
626
l.Error("empty member")
627
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1
appview/state/follow.go
+1
appview/state/follow.go
+11
-8
appview/state/gfi.go
+11
-8
appview/state/gfi.go
···
1
package state
2
3
import (
4
-
"fmt"
5
"log"
6
"net/http"
7
"sort"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.org/core/api/tangled"
11
"tangled.org/core/appview/db"
12
"tangled.org/core/appview/models"
13
"tangled.org/core/appview/pages"
···
18
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
user := s.oauth.GetUser(r)
20
21
-
page, ok := r.Context().Value("page").(pagination.Page)
22
-
if !ok {
23
-
page = pagination.FirstPage()
24
}
25
-
26
-
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
28
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
29
if err != nil {
···
38
RepoGroups: []*models.RepoGroup{},
39
LabelDefs: make(map[string]*models.LabelDefinition),
40
Page: page,
41
})
42
return
43
}
···
146
RepoGroups: paginatedGroups,
147
LabelDefs: labelDefsMap,
148
Page: page,
149
-
GfiLabel: labelDefsMap[goodFirstIssueLabel],
150
})
151
}
···
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"
···
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 {
···
40
RepoGroups: []*models.RepoGroup{},
41
LabelDefs: make(map[string]*models.LabelDefinition),
42
Page: page,
43
+
GfiLabel: gfiLabelDef,
44
})
45
return
46
}
···
149
RepoGroups: paginatedGroups,
150
LabelDefs: labelDefsMap,
151
Page: page,
152
+
GfiLabel: gfiLabelDef,
153
})
154
}
+3
appview/state/login.go
+3
appview/state/login.go
···
14
switch r.Method {
15
case http.MethodGet:
16
returnURL := r.URL.Query().Get("return_url")
17
s.pages.Login(w, pages.LoginParams{
18
ReturnUrl: returnURL,
19
})
20
case http.MethodPost:
21
handle := r.FormValue("handle")
···
44
45
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
46
if err != nil {
47
http.Error(w, err.Error(), http.StatusInternalServerError)
48
return
49
}
···
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")
···
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
}
+2
appview/state/profile.go
+2
appview/state/profile.go
···
538
profile.Description = r.FormValue("description")
539
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
540
profile.Location = r.FormValue("location")
541
542
var links [5]string
543
for i := range 5 {
···
652
Location: &profile.Location,
653
PinnedRepositories: pinnedRepoStrings,
654
Stats: vanityStats[:],
655
}},
656
SwapRecord: cid,
657
})
···
538
profile.Description = r.FormValue("description")
539
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
540
profile.Location = r.FormValue("location")
541
+
profile.Pronouns = r.FormValue("pronouns")
542
543
var links [5]string
544
for i := range 5 {
···
653
Location: &profile.Location,
654
PinnedRepositories: pinnedRepoStrings,
655
Stats: vanityStats[:],
656
+
Pronouns: &profile.Pronouns,
657
}},
658
SwapRecord: cid,
659
})
+43
-36
appview/state/router.go
+43
-36
appview/state/router.go
···
42
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
pat := chi.URLParam(r, "*")
45
-
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
46
-
userRouter.ServeHTTP(w, r)
47
-
} else {
48
-
// Check if the first path element is a valid handle without '@' or a flattened DID
49
-
pathParts := strings.SplitN(pat, "/", 2)
50
-
if len(pathParts) > 0 {
51
-
if userutil.IsHandleNoAt(pathParts[0]) {
52
-
// Redirect to the same path but with '@' prefixed to the handle
53
-
redirectPath := "@" + pat
54
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
55
-
return
56
-
} else if userutil.IsFlattenedDid(pathParts[0]) {
57
-
// Redirect to the unflattened DID version
58
-
unflattenedDid := userutil.UnflattenDid(pathParts[0])
59
-
var redirectPath string
60
-
if len(pathParts) > 1 {
61
-
redirectPath = unflattenedDid + "/" + pathParts[1]
62
-
} else {
63
-
redirectPath = unflattenedDid
64
-
}
65
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
66
-
return
67
-
}
68
}
69
-
standardRouter.ServeHTTP(w, r)
70
}
71
})
72
73
return router
···
80
r.Get("/", s.Profile)
81
r.Get("/feed.atom", s.AtomFeedPage)
82
83
-
// redirect /@handle/repo.git -> /@handle/repo
84
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
85
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
86
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
87
-
})
88
-
89
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
90
r.Use(mw.GoImport())
91
r.Mount("/", s.RepoRouter(mw))
92
r.Mount("/issues", s.IssuesRouter(mw))
93
r.Mount("/pulls", s.PullsRouter(mw))
94
-
r.Mount("/pipelines", s.PipelinesRouter(mw))
95
-
r.Mount("/labels", s.LabelsRouter(mw))
96
97
// These routes get proxied to the knot
98
r.Get("/info/refs", s.InfoRefs)
···
262
s.config,
263
s.notifier,
264
s.validator,
265
log.SubLogger(s.logger, "issues"),
266
)
267
return issues.Router(mw)
···
278
s.notifier,
279
s.enforcer,
280
s.validator,
281
log.SubLogger(s.logger, "pulls"),
282
)
283
return pulls.Router(mw)
···
300
return repo.Router(mw)
301
}
302
303
-
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
304
pipes := pipelines.New(
305
s.oauth,
306
s.repoResolver,
···
312
s.enforcer,
313
log.SubLogger(s.logger, "pipelines"),
314
)
315
-
return pipes.Router(mw)
316
}
317
318
-
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
319
ls := labels.New(
320
s.oauth,
321
s.pages,
···
324
s.enforcer,
325
log.SubLogger(s.logger, "labels"),
326
)
327
-
return ls.Router(mw)
328
}
329
330
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
···
42
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
pat := chi.URLParam(r, "*")
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
77
}
78
+
79
}
80
+
81
+
standardRouter.ServeHTTP(w, r)
82
})
83
84
return router
···
91
r.Get("/", s.Profile)
92
r.Get("/feed.atom", s.AtomFeedPage)
93
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
95
r.Use(mw.GoImport())
96
r.Mount("/", s.RepoRouter(mw))
97
r.Mount("/issues", s.IssuesRouter(mw))
98
r.Mount("/pulls", s.PullsRouter(mw))
99
+
r.Mount("/pipelines", s.PipelinesRouter())
100
+
r.Mount("/labels", s.LabelsRouter())
101
102
// These routes get proxied to the knot
103
r.Get("/info/refs", s.InfoRefs)
···
267
s.config,
268
s.notifier,
269
s.validator,
270
+
s.indexer.Issues,
271
log.SubLogger(s.logger, "issues"),
272
)
273
return issues.Router(mw)
···
284
s.notifier,
285
s.enforcer,
286
s.validator,
287
+
s.indexer.Pulls,
288
log.SubLogger(s.logger, "pulls"),
289
)
290
return pulls.Router(mw)
···
307
return repo.Router(mw)
308
}
309
310
+
func (s *State) PipelinesRouter() http.Handler {
311
pipes := pipelines.New(
312
s.oauth,
313
s.repoResolver,
···
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,
···
331
s.enforcer,
332
log.SubLogger(s.logger, "labels"),
333
)
334
+
return ls.Router()
335
}
336
337
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
+22
-12
appview/state/state.go
+22
-12
appview/state/state.go
···
14
"tangled.org/core/appview"
15
"tangled.org/core/appview/config"
16
"tangled.org/core/appview/db"
17
"tangled.org/core/appview/models"
18
"tangled.org/core/appview/notify"
19
dbnotify "tangled.org/core/appview/notify/db"
···
43
type State struct {
44
db *db.DB
45
notifier notify.Notifier
46
oauth *oauth.OAuth
47
enforcer *rbac.Enforcer
48
pages *pages.Pages
···
65
return nil, fmt.Errorf("failed to create db: %w", err)
66
}
67
68
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
69
if err != nil {
70
return nil, fmt.Errorf("failed to create enforcer: %w", err)
71
}
72
73
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
74
if err != nil {
75
logger.Error("failed to create redis resolver", "err", err)
76
-
res = idresolver.DefaultResolver()
77
}
78
79
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
121
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
122
}
123
124
-
if err := BackfillDefaultDefs(d, res); err != nil {
125
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
126
}
127
···
159
if !config.Core.Dev {
160
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
161
}
162
-
notifier := notify.NewMergedNotifier(notifiers...)
163
164
state := &State{
165
d,
166
notifier,
167
oauth,
168
enforcer,
169
pages,
···
284
return
285
}
286
287
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
288
if err != nil {
289
// non-fatal
290
}
···
376
377
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
378
if err != nil {
379
-
w.WriteHeader(http.StatusNotFound)
380
return
381
}
382
383
if len(pubKeys) == 0 {
384
-
w.WriteHeader(http.StatusNotFound)
385
return
386
}
387
···
506
Rkey: rkey,
507
Description: description,
508
Created: time.Now(),
509
-
Labels: models.DefaultLabelDefs(),
510
}
511
record := repo.AsRecord()
512
···
622
aturi = ""
623
624
s.notifier.NewRepo(r.Context(), repo)
625
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
626
}
627
}
628
···
648
return err
649
}
650
651
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
652
-
defaults := models.DefaultLabelDefs()
653
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
654
if err != nil {
655
return err
···
659
return nil
660
}
661
662
-
labelDefs, err := models.FetchDefaultDefs(r)
663
if err != nil {
664
return err
665
}
···
14
"tangled.org/core/appview"
15
"tangled.org/core/appview/config"
16
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/indexer"
18
"tangled.org/core/appview/models"
19
"tangled.org/core/appview/notify"
20
dbnotify "tangled.org/core/appview/notify/db"
···
44
type State struct {
45
db *db.DB
46
notifier notify.Notifier
47
+
indexer *indexer.Indexer
48
oauth *oauth.OAuth
49
enforcer *rbac.Enforcer
50
pages *pages.Pages
···
67
return nil, fmt.Errorf("failed to create db: %w", err)
68
}
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
+
76
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
77
if err != nil {
78
return nil, fmt.Errorf("failed to create enforcer: %w", err)
79
}
80
81
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
82
if err != nil {
83
logger.Error("failed to create redis resolver", "err", err)
84
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
85
}
86
87
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
129
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
130
}
131
132
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
133
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
134
}
135
···
167
if !config.Core.Dev {
168
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
169
}
170
+
notifiers = append(notifiers, indexer)
171
+
notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify"))
172
173
state := &State{
174
d,
175
notifier,
176
+
indexer,
177
oauth,
178
enforcer,
179
pages,
···
294
return
295
}
296
297
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
if err != nil {
299
// non-fatal
300
}
···
386
387
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
388
if err != nil {
389
+
s.logger.Error("failed to get public keys", "err", err)
390
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
391
return
392
}
393
394
if len(pubKeys) == 0 {
395
+
w.WriteHeader(http.StatusNoContent)
396
return
397
}
398
···
517
Rkey: rkey,
518
Description: description,
519
Created: time.Now(),
520
+
Labels: s.config.Label.DefaultLabelDefs,
521
}
522
record := repo.AsRecord()
523
···
633
aturi = ""
634
635
s.notifier.NewRepo(r.Context(), repo)
636
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
637
}
638
}
639
···
659
return err
660
}
661
662
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
663
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
664
if err != nil {
665
return err
···
669
return nil
670
}
671
672
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
673
if err != nil {
674
return err
675
}
+6
-6
appview/state/userutil/userutil.go
+6
-6
appview/state/userutil/userutil.go
···
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
)
12
13
-
func IsHandleNoAt(s string) bool {
14
// ref: https://atproto.com/specs/handle
15
return handleRegex.MatchString(s)
16
}
17
18
func UnflattenDid(s string) string {
···
45
return strings.Replace(s, ":", "-", 2)
46
}
47
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
}
54
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
···
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
)
12
13
+
func IsHandle(s string) bool {
14
// ref: https://atproto.com/specs/handle
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)
21
}
22
23
func UnflattenDid(s string) string {
···
50
return strings.Replace(s, ":", "-", 2)
51
}
52
return s
53
}
54
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+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
+
}
-43
cmd/genjwks/main.go
-43
cmd/genjwks/main.go
···
1
-
// adapted from https://tangled.org/anirudh.fi/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
-
}
···
+16
-6
docs/hacking.md
+16
-6
docs/hacking.md
···
37
38
```
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"}
42
43
# if not, you can set it up yourself:
44
-
go build -o genjwks.out ./cmd/genjwks
45
-
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
46
47
# run redis in at a new shell to store oauth sessions
48
redis-server
···
158
159
If for any reason you wish to disable either one of the
160
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`.
···
37
38
```
39
# oauth jwks should already be setup by the nix devshell:
40
+
echo $TANGLED_OAUTH_CLIENT_SECRET
41
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
+
43
+
echo $TANGLED_OAUTH_CLIENT_KID
44
+
1761667908
45
46
# if not, you can set it up yourself:
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..."
56
57
# run redis in at a new shell to store oauth sessions
58
redis-server
···
168
169
If for any reason you wish to disable either one of the
170
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
+
`services.tangled.spindle.enable` (or
172
+
`services.tangled.knot.enable`) to `false`.
+1
-1
docs/migrations.md
+1
-1
docs/migrations.md
+19
-1
docs/spindle/pipeline.md
+19
-1
docs/spindle/pipeline.md
···
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
- `manual`: The workflow can be triggered manually.
22
-
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
···
29
branch: ["main", "develop"]
30
- event: ["pull_request"]
31
branch: ["main"]
32
```
33
34
## Engine
···
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
- `manual`: The workflow can be triggered manually.
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.
24
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:
26
···
30
branch: ["main", "develop"]
31
- event: ["pull_request"]
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"]
50
```
51
52
## Engine
+17
flake.lock
+17
flake.lock
···
1
{
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
+
},
19
"flake-compat": {
20
"flake": false,
21
"locked": {
···
166
},
167
"root": {
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
170
"flake-compat": "flake-compat",
171
"gomod2nix": "gomod2nix",
172
"htmx-src": "htmx-src",
+18
-8
flake.nix
+18
-8
flake.nix
···
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
flake = false;
35
};
36
ibm-plex-mono-src = {
37
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
flake = false;
···
54
inter-fonts-src,
55
sqlite-lib-src,
56
ibm-plex-mono-src,
57
...
58
}: let
59
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
78
inherit (pkgs) gcc;
79
inherit sqlite-lib-src;
80
};
81
-
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
82
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
83
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;
85
};
86
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
90
});
91
in {
92
overlays.default = final: prev: {
93
-
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
94
};
95
96
packages = forAllSystems (system: let
···
99
staticPackages = mkPackageSet pkgs.pkgsStatic;
100
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
101
in {
102
-
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
103
104
pkgsStatic-appview = staticPackages.appview;
105
pkgsStatic-knot = staticPackages.knot;
···
167
mkdir -p appview/pages/static
168
# no preserve is needed because watch-tailwind will want to be able to overwrite
169
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
170
-
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
171
'';
172
env.CGO_ENABLED = 1;
173
};
···
206
watch-knot = {
207
type = "app";
208
program = ''${air-watcher "knot" "server"}/bin/run'';
209
};
210
watch-tailwind = {
211
type = "app";
···
278
}: {
279
imports = [./nix/modules/appview.nix];
280
281
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
282
};
283
nixosModules.knot = {
284
lib,
···
287
}: {
288
imports = [./nix/modules/knot.nix];
289
290
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
291
};
292
nixosModules.spindle = {
293
lib,
···
296
}: {
297
imports = [./nix/modules/spindle.nix];
298
299
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
300
};
301
};
302
}
···
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
flake = false;
35
};
36
+
actor-typeahead-src = {
37
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
+
flake = false;
39
+
};
40
ibm-plex-mono-src = {
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
42
flake = false;
···
58
inter-fonts-src,
59
sqlite-lib-src,
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
62
...
63
}: let
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
83
inherit (pkgs) gcc;
84
inherit sqlite-lib-src;
85
};
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
87
+
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
88
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
90
};
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
95
});
96
in {
97
overlays.default = final: prev: {
98
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
99
};
100
101
packages = forAllSystems (system: let
···
104
staticPackages = mkPackageSet pkgs.pkgsStatic;
105
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
106
in {
107
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
108
109
pkgsStatic-appview = staticPackages.appview;
110
pkgsStatic-knot = staticPackages.knot;
···
172
mkdir -p appview/pages/static
173
# no preserve is needed because watch-tailwind will want to be able to overwrite
174
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
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}')"
177
'';
178
env.CGO_ENABLED = 1;
179
};
···
212
watch-knot = {
213
type = "app";
214
program = ''${air-watcher "knot" "server"}/bin/run'';
215
+
};
216
+
watch-spindle = {
217
+
type = "app";
218
+
program = ''${air-watcher "spindle" ""}/bin/run'';
219
};
220
watch-tailwind = {
221
type = "app";
···
288
}: {
289
imports = [./nix/modules/appview.nix];
290
291
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
292
};
293
nixosModules.knot = {
294
lib,
···
297
}: {
298
imports = [./nix/modules/knot.nix];
299
300
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
301
};
302
nixosModules.spindle = {
303
lib,
···
306
}: {
307
imports = [./nix/modules/spindle.nix];
308
309
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
310
};
311
};
312
}
+32
-13
go.mod
+32
-13
go.mod
···
7
github.com/alecthomas/assert/v2 v2.11.0
8
github.com/alecthomas/chroma/v2 v2.15.0
9
github.com/avast/retry-go/v4 v4.6.1
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
15
github.com/cloudflare/cloudflare-go v0.115.0
16
github.com/cyphar/filepath-securejoin v0.4.1
17
github.com/dgraph-io/ristretto v0.2.0
···
29
github.com/hiddeco/sshsig v0.2.0
30
github.com/hpcloud/tail v1.0.0
31
github.com/ipfs/go-cid v0.5.0
32
-
github.com/lestrrat-go/jwx/v2 v2.1.6
33
github.com/mattn/go-sqlite3 v1.14.24
34
github.com/microcosm-cc/bluemonday v1.0.27
35
github.com/openbao/openbao/api/v2 v2.3.0
···
42
github.com/stretchr/testify v1.10.0
43
github.com/urfave/cli/v3 v3.3.3
44
github.com/whyrusleeping/cbor-gen v0.3.1
45
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
46
github.com/yuin/goldmark v1.7.13
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
48
golang.org/x/crypto v0.40.0
49
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
golang.org/x/image v0.31.0
···
58
dario.cat/mergo v1.0.1 // indirect
59
github.com/Microsoft/go-winio v0.6.2 // indirect
60
github.com/ProtonMail/go-crypto v1.3.0 // indirect
61
github.com/alecthomas/repr v0.4.0 // indirect
62
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
63
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
64
github.com/aymerick/douceur v0.2.0 // indirect
65
github.com/beorn7/perks v1.0.1 // indirect
66
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
67
github.com/casbin/govaluate v1.3.0 // indirect
68
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
69
github.com/cespare/xxhash/v2 v2.3.0 // indirect
70
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
71
github.com/charmbracelet/lipgloss v1.1.0 // indirect
72
-
github.com/charmbracelet/log v0.4.2 // indirect
73
github.com/charmbracelet/x/ansi v0.8.0 // indirect
74
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
75
github.com/charmbracelet/x/term v0.2.1 // indirect
···
78
github.com/containerd/errdefs/pkg v0.3.0 // indirect
79
github.com/containerd/log v0.1.0 // indirect
80
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
81
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
82
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
83
github.com/distribution/reference v0.6.0 // indirect
84
github.com/dlclark/regexp2 v1.11.5 // indirect
···
101
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
102
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
103
github.com/golang/mock v1.6.0 // indirect
104
github.com/google/go-querystring v1.1.0 // indirect
105
github.com/gorilla/css v1.0.1 // indirect
106
github.com/gorilla/securecookie v1.1.2 // indirect
···
126
github.com/ipfs/go-log v1.0.5 // indirect
127
github.com/ipfs/go-log/v2 v2.6.0 // indirect
128
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
129
github.com/kevinburke/ssh_config v1.2.0 // indirect
130
github.com/klauspost/compress v1.18.0 // indirect
131
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
132
-
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
133
-
github.com/lestrrat-go/httpcc v1.0.1 // indirect
134
-
github.com/lestrrat-go/httprc v1.0.6 // indirect
135
-
github.com/lestrrat-go/iter v1.0.2 // indirect
136
-
github.com/lestrrat-go/option v1.0.1 // indirect
137
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
138
github.com/mattn/go-isatty v0.0.20 // indirect
139
github.com/mattn/go-runewidth v0.0.16 // indirect
···
142
github.com/moby/docker-image-spec v1.3.1 // indirect
143
github.com/moby/sys/atomicwriter v0.1.0 // indirect
144
github.com/moby/term v0.5.2 // indirect
145
github.com/morikuni/aec v1.0.0 // indirect
146
github.com/mr-tron/base58 v1.2.0 // indirect
147
github.com/muesli/termenv v0.16.0 // indirect
148
github.com/multiformats/go-base32 v0.1.0 // indirect
149
github.com/multiformats/go-base36 v0.2.0 // indirect
···
165
github.com/prometheus/procfs v0.16.1 // indirect
166
github.com/rivo/uniseg v0.4.7 // indirect
167
github.com/ryanuber/go-glob v1.0.0 // indirect
168
-
github.com/segmentio/asm v1.2.0 // indirect
169
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
170
github.com/spaolacci/murmur3 v1.1.0 // indirect
171
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
172
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
173
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
174
-
github.com/wyatt915/treeblood v0.1.15 // indirect
175
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
176
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
177
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
178
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
179
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
180
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
181
go.opentelemetry.io/otel v1.37.0 // indirect
···
7
github.com/alecthomas/assert/v2 v2.11.0
8
github.com/alecthomas/chroma/v2 v2.15.0
9
github.com/avast/retry-go/v4 v4.6.1
10
+
github.com/blevesearch/bleve/v2 v2.5.3
11
github.com/bluekeyes/go-gitdiff v0.8.1
12
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
13
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
14
+
github.com/bmatcuk/doublestar/v4 v4.9.1
15
github.com/carlmjohnson/versioninfo v0.22.5
16
github.com/casbin/casbin/v2 v2.103.0
17
+
github.com/charmbracelet/log v0.4.2
18
github.com/cloudflare/cloudflare-go v0.115.0
19
github.com/cyphar/filepath-securejoin v0.4.1
20
github.com/dgraph-io/ristretto v0.2.0
···
32
github.com/hiddeco/sshsig v0.2.0
33
github.com/hpcloud/tail v1.0.0
34
github.com/ipfs/go-cid v0.5.0
35
github.com/mattn/go-sqlite3 v1.14.24
36
github.com/microcosm-cc/bluemonday v1.0.27
37
github.com/openbao/openbao/api/v2 v2.3.0
···
44
github.com/stretchr/testify v1.10.0
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
+
github.com/wyatt915/goldmark-treeblood v0.0.1
48
github.com/yuin/goldmark v1.7.13
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
golang.org/x/crypto v0.40.0
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
golang.org/x/image v0.31.0
···
61
dario.cat/mergo v1.0.1 // indirect
62
github.com/Microsoft/go-winio v0.6.2 // indirect
63
github.com/ProtonMail/go-crypto v1.3.0 // indirect
64
+
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
65
github.com/alecthomas/repr v0.4.0 // indirect
66
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
67
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
68
github.com/aymerick/douceur v0.2.0 // indirect
69
github.com/beorn7/perks v1.0.1 // indirect
70
+
github.com/bits-and-blooms/bitset v1.22.0 // indirect
71
+
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
72
+
github.com/blevesearch/geo v0.2.4 // indirect
73
+
github.com/blevesearch/go-faiss v1.0.25 // indirect
74
+
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
75
+
github.com/blevesearch/gtreap v0.1.1 // indirect
76
+
github.com/blevesearch/mmap-go v1.0.4 // indirect
77
+
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
78
+
github.com/blevesearch/segment v0.9.1 // indirect
79
+
github.com/blevesearch/snowballstem v0.9.0 // indirect
80
+
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
81
+
github.com/blevesearch/vellum v1.1.0 // indirect
82
+
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
83
+
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
84
+
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
85
+
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
86
+
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
87
+
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
88
github.com/casbin/govaluate v1.3.0 // indirect
89
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
90
github.com/cespare/xxhash/v2 v2.3.0 // indirect
91
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
92
github.com/charmbracelet/lipgloss v1.1.0 // 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
···
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
github.com/containerd/log v0.1.0 // indirect
100
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
101
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
102
github.com/distribution/reference v0.6.0 // indirect
103
github.com/dlclark/regexp2 v1.11.5 // indirect
···
120
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
121
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
122
github.com/golang/mock v1.6.0 // indirect
123
+
github.com/golang/protobuf v1.5.4 // indirect
124
+
github.com/golang/snappy v0.0.4 // indirect
125
github.com/google/go-querystring v1.1.0 // indirect
126
github.com/gorilla/css v1.0.1 // indirect
127
github.com/gorilla/securecookie v1.1.2 // indirect
···
147
github.com/ipfs/go-log v1.0.5 // indirect
148
github.com/ipfs/go-log/v2 v2.6.0 // indirect
149
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
150
+
github.com/json-iterator/go v1.1.12 // indirect
151
github.com/kevinburke/ssh_config v1.2.0 // indirect
152
github.com/klauspost/compress v1.18.0 // indirect
153
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
154
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
155
github.com/mattn/go-isatty v0.0.20 // indirect
156
github.com/mattn/go-runewidth v0.0.16 // indirect
···
159
github.com/moby/docker-image-spec v1.3.1 // indirect
160
github.com/moby/sys/atomicwriter v0.1.0 // indirect
161
github.com/moby/term v0.5.2 // indirect
162
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
163
+
github.com/modern-go/reflect2 v1.0.2 // indirect
164
github.com/morikuni/aec v1.0.0 // indirect
165
github.com/mr-tron/base58 v1.2.0 // indirect
166
+
github.com/mschoch/smat v0.2.0 // indirect
167
github.com/muesli/termenv v0.16.0 // indirect
168
github.com/multiformats/go-base32 v0.1.0 // indirect
169
github.com/multiformats/go-base36 v0.2.0 // indirect
···
185
github.com/prometheus/procfs v0.16.1 // indirect
186
github.com/rivo/uniseg v0.4.7 // indirect
187
github.com/ryanuber/go-glob v1.0.0 // indirect
188
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
189
github.com/spaolacci/murmur3 v1.1.0 // indirect
190
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
191
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
192
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
193
+
github.com/wyatt915/treeblood v0.1.16 // indirect
194
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
195
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
196
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
197
+
go.etcd.io/bbolt v1.4.0 // indirect
198
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
199
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
200
go.opentelemetry.io/otel v1.37.0 // indirect
+64
-21
go.sum
+64
-21
go.sum
···
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
11
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
25
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
26
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
27
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
28
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
29
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
30
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
31
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
32
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
33
-
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
34
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
35
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
36
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
37
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
83
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
84
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
85
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
86
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
87
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
88
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
89
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
90
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
170
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
171
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
172
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
173
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
174
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
175
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
···
181
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
182
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
183
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
184
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
185
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
186
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
261
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
262
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
263
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
264
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
265
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
266
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
280
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
281
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
282
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
283
-
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
284
-
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
285
-
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
286
-
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
287
-
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
288
-
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
289
-
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
290
-
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
291
-
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
292
-
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
293
-
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
294
-
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
295
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
296
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
297
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
316
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
317
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
318
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
319
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
320
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
321
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
322
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
323
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
324
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
325
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
···
409
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
410
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
411
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
412
-
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
413
-
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
414
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
415
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
416
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
455
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
456
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
457
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
458
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
459
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
460
-
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
461
-
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
462
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
463
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
464
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
477
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
478
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
479
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
480
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
481
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
482
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
···
680
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
681
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
682
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
683
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
684
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
685
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
···
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
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=
14
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
15
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
16
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
27
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
28
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
29
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
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=
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
73
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
74
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
75
+
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
76
+
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
77
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
78
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
79
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
125
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
127
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
128
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
129
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
130
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
210
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
211
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
212
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
213
+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
214
+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
215
+
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
216
+
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
217
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
218
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
219
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
···
225
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
226
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
227
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
228
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
229
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
230
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
231
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
306
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
307
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
308
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
309
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
310
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
311
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
312
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
313
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
327
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
328
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
329
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
330
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
331
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
332
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
351
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
352
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
353
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
354
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
355
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
356
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
357
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
358
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
359
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
360
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
361
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
362
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
363
+
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
364
+
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
365
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
366
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
367
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
···
451
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
452
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
453
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
454
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
455
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
456
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
495
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
496
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
497
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
498
+
github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs=
499
+
github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208=
500
+
github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y=
501
+
github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
502
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
503
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
504
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
517
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
518
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
519
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
520
+
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
521
+
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
522
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
523
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
524
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
···
722
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
723
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
724
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
725
+
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
726
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
727
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
728
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+36
-61
guard/guard.go
+36
-61
guard/guard.go
···
12
"os/exec"
13
"strings"
14
15
-
"github.com/bluesky-social/indigo/atproto/identity"
16
securejoin "github.com/cyphar/filepath-securejoin"
17
"github.com/urfave/cli/v3"
18
-
"tangled.org/core/idresolver"
19
"tangled.org/core/log"
20
)
21
···
93
"command", sshCommand,
94
"client", clientIP)
95
96
if sshCommand == "" {
97
l.Info("access denied: no interactive shells", "user", incomingUser)
98
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
107
}
108
109
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)
129
130
validCommands := map[string]bool{
131
"git-receive-pack": true,
···
138
return fmt.Errorf("access denied: invalid git command")
139
}
140
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
-
}
149
}
150
151
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
152
153
l.Info("processing command",
154
"user", incomingUser,
155
"command", gitCommand,
156
-
"repo", repoName,
157
"fullPath", fullPath,
158
"client", clientIP)
159
···
177
gitCmd.Stdin = os.Stdin
178
gitCmd.Env = append(os.Environ(),
179
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
181
)
182
183
if err := gitCmd.Run(); err != nil {
···
189
l.Info("command completed",
190
"user", incomingUser,
191
"command", gitCommand,
192
-
"repo", repoName,
193
"success", true)
194
195
return nil
196
}
197
198
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
-
resolver := idresolver.DefaultResolver()
200
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
201
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)
210
}
211
-
return ident
212
-
}
213
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()
220
221
-
req, err := http.Get(u.String())
222
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)
226
}
227
-
228
-
l.Info("Checking push permission",
229
-
"url", u.String(),
230
-
"status", req.Status)
231
232
-
return req.StatusCode == http.StatusNoContent
233
}
···
12
"os/exec"
13
"strings"
14
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/urfave/cli/v3"
17
"tangled.org/core/log"
18
)
19
···
91
"command", sshCommand,
92
"client", clientIP)
93
94
+
// TODO: greet user with their resolved handle instead of did
95
if sshCommand == "" {
96
l.Info("access denied: no interactive shells", "user", incomingUser)
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
106
}
107
108
gitCommand := cmdParts[0]
109
+
repoPath := cmdParts[1]
110
111
validCommands := map[string]bool{
112
"git-receive-pack": true,
···
119
return fmt.Errorf("access denied: invalid git command")
120
}
121
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)
128
}
129
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
131
132
l.Info("processing command",
133
"user", incomingUser,
134
"command", gitCommand,
135
+
"repo", repoPath,
136
"fullPath", fullPath,
137
"client", clientIP)
138
···
156
gitCmd.Stdin = os.Stdin
157
gitCmd.Env = append(os.Environ(),
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
159
)
160
161
if err := gitCmd.Run(); err != nil {
···
167
l.Info("command completed",
168
"user", incomingUser,
169
"command", gitCommand,
170
+
"repo", repoPath,
171
"success", true)
172
173
return nil
174
}
175
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())
186
if err != nil {
187
+
return "", err
188
}
189
+
defer resp.Body.Close()
190
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
192
193
+
body, err := io.ReadAll(resp.Body)
194
if err != nil {
195
+
return "", err
196
}
197
+
text := string(body)
198
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
+
}
208
}
+17
-8
idresolver/resolver.go
+17
-8
idresolver/resolver.go
···
17
directory identity.Directory
18
}
19
20
-
func BaseDirectory() identity.Directory {
21
base := identity.BaseDirectory{
22
-
PLCURL: identity.DefaultPLCURL,
23
HTTPClient: http.Client{
24
Timeout: time.Second * 10,
25
Transport: &http.Transport{
···
42
return &base
43
}
44
45
-
func RedisDirectory(url string) (identity.Directory, error) {
46
hitTTL := time.Hour * 24
47
errTTL := time.Second * 30
48
invalidHandleTTL := time.Minute * 5
49
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
50
}
51
52
-
func DefaultResolver() *Resolver {
53
return &Resolver{
54
-
directory: identity.DefaultDirectory(),
55
}
56
}
57
58
-
func RedisResolver(redisUrl string) (*Resolver, error) {
59
-
directory, err := RedisDirectory(redisUrl)
60
if err != nil {
61
return nil, err
62
}
···
17
directory identity.Directory
18
}
19
20
+
func BaseDirectory(plcUrl string) identity.Directory {
21
base := identity.BaseDirectory{
22
+
PLCURL: plcUrl,
23
HTTPClient: http.Client{
24
Timeout: time.Second * 10,
25
Transport: &http.Transport{
···
42
return &base
43
}
44
45
+
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
46
hitTTL := time.Hour * 24
47
errTTL := time.Second * 30
48
invalidHandleTTL := time.Minute * 5
49
+
return redisdir.NewRedisDirectory(
50
+
BaseDirectory(plcUrl),
51
+
url,
52
+
hitTTL,
53
+
errTTL,
54
+
invalidHandleTTL,
55
+
10000,
56
+
)
57
}
58
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)
62
return &Resolver{
63
+
directory: &cached,
64
}
65
}
66
67
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
+
directory, err := RedisDirectory(redisUrl, plcUrl)
69
if err != nil {
70
return nil, err
71
}
+38
input.css
+38
input.css
···
161
@apply no-underline;
162
}
163
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
166
+
}
167
+
168
.prose li {
169
@apply my-0 py-0;
170
}
···
245
details[data-callout] > summary::-webkit-details-marker {
246
display: none;
247
}
248
+
249
}
250
@layer utilities {
251
.error {
···
929
text-decoration: underline;
930
}
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
knotserver/config/config.go
+1
knotserver/config/config.go
···
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
Hostname string `env:"HOSTNAME, required"`
22
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
Owner string `env:"OWNER, required"`
24
LogDids bool `env:"LOG_DIDS, default=true"`
···
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
Hostname string `env:"HOSTNAME, required"`
22
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
24
Owner string `env:"OWNER, required"`
25
LogDids bool `env:"LOG_DIDS, default=true"`
+60
-2
knotserver/git/git.go
+60
-2
knotserver/git/git.go
···
3
import (
4
"archive/tar"
5
"bytes"
6
"fmt"
7
"io"
8
"io/fs"
···
12
"time"
13
14
"github.com/go-git/go-git/v5"
15
"github.com/go-git/go-git/v5/plumbing"
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
18
19
var (
20
-
ErrBinaryFile = fmt.Errorf("binary file")
21
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
22
)
23
24
type GitRepo struct {
···
188
defer reader.Close()
189
190
return io.ReadAll(reader)
191
}
192
193
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
···
3
import (
4
"archive/tar"
5
"bytes"
6
+
"errors"
7
"fmt"
8
"io"
9
"io/fs"
···
13
"time"
14
15
"github.com/go-git/go-git/v5"
16
+
"github.com/go-git/go-git/v5/config"
17
"github.com/go-git/go-git/v5/plumbing"
18
"github.com/go-git/go-git/v5/plumbing/object"
19
)
20
21
var (
22
+
ErrBinaryFile = errors.New("binary file")
23
+
ErrNotBinaryFile = errors.New("not binary file")
24
+
ErrMissingGitModules = errors.New("no .gitmodules file found")
25
+
ErrInvalidGitModules = errors.New("invalid .gitmodules file")
26
+
ErrNotSubmodule = errors.New("path is not a submodule")
27
)
28
29
type GitRepo struct {
···
193
defer reader.Close()
194
195
return io.ReadAll(reader)
196
+
}
197
+
198
+
// read and parse .gitmodules
199
+
func (g *GitRepo) Submodules() (*config.Modules, error) {
200
+
c, err := g.r.CommitObject(g.h)
201
+
if err != nil {
202
+
return nil, fmt.Errorf("commit object: %w", err)
203
+
}
204
+
205
+
tree, err := c.Tree()
206
+
if err != nil {
207
+
return nil, fmt.Errorf("tree: %w", err)
208
+
}
209
+
210
+
// read .gitmodules file
211
+
modulesEntry, err := tree.FindEntry(".gitmodules")
212
+
if err != nil {
213
+
return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
214
+
}
215
+
216
+
modulesFile, err := tree.TreeEntryFile(modulesEntry)
217
+
if err != nil {
218
+
return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
219
+
}
220
+
221
+
content, err := modulesFile.Contents()
222
+
if err != nil {
223
+
return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
224
+
}
225
+
226
+
// parse .gitmodules
227
+
modules := config.NewModules()
228
+
if err = modules.Unmarshal([]byte(content)); err != nil {
229
+
return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
230
+
}
231
+
232
+
return modules, nil
233
+
}
234
+
235
+
func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
236
+
modules, err := g.Submodules()
237
+
if err != nil {
238
+
return nil, err
239
+
}
240
+
241
+
for _, submodule := range modules.Submodules {
242
+
if submodule.Path == path {
243
+
return submodule, nil
244
+
}
245
+
}
246
+
247
+
// path is not a submodule
248
+
return nil, ErrNotSubmodule
249
}
250
251
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4
-13
knotserver/git/tree.go
+4
-13
knotserver/git/tree.go
···
7
"path"
8
"time"
9
10
"github.com/go-git/go-git/v5/plumbing/object"
11
"tangled.org/core/types"
12
)
···
53
}
54
55
for _, e := range subtree.Entries {
56
-
mode, _ := e.Mode.ToOSFileMode()
57
sz, _ := subtree.Size(e.Name)
58
-
59
fpath := path.Join(parent, e.Name)
60
61
var lastCommit *types.LastCommitInfo
···
69
70
nts = append(nts, types.NiceTree{
71
Name: e.Name,
72
-
Mode: mode.String(),
73
-
IsFile: e.Mode.IsFile(),
74
Size: sz,
75
LastCommit: lastCommit,
76
})
···
126
default:
127
}
128
129
-
mode, err := e.Mode.ToOSFileMode()
130
-
if err != nil {
131
-
// TODO: log this
132
-
continue
133
-
}
134
-
135
if e.Mode.IsFile() {
136
-
err = cb(e, currentTree, root)
137
-
if errors.Is(err, TerminateWalk) {
138
return err
139
}
140
}
141
142
// e is a directory
143
-
if mode.IsDir() {
144
subtree, err := currentTree.Tree(e.Name)
145
if err != nil {
146
return fmt.Errorf("sub tree %s: %w", e.Name, err)
···
7
"path"
8
"time"
9
10
+
"github.com/go-git/go-git/v5/plumbing/filemode"
11
"github.com/go-git/go-git/v5/plumbing/object"
12
"tangled.org/core/types"
13
)
···
54
}
55
56
for _, e := range subtree.Entries {
57
sz, _ := subtree.Size(e.Name)
58
fpath := path.Join(parent, e.Name)
59
60
var lastCommit *types.LastCommitInfo
···
68
69
nts = append(nts, types.NiceTree{
70
Name: e.Name,
71
+
Mode: e.Mode.String(),
72
Size: sz,
73
LastCommit: lastCommit,
74
})
···
124
default:
125
}
126
127
if e.Mode.IsFile() {
128
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
129
return err
130
}
131
}
132
133
// e is a directory
134
+
if e.Mode == filemode.Dir {
135
subtree, err := currentTree.Tree(e.Name)
136
if err != nil {
137
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+4
-8
knotserver/ingester.go
+4
-8
knotserver/ingester.go
···
16
"github.com/bluesky-social/jetstream/pkg/models"
17
securejoin "github.com/cyphar/filepath-securejoin"
18
"tangled.org/core/api/tangled"
19
-
"tangled.org/core/idresolver"
20
"tangled.org/core/knotserver/db"
21
"tangled.org/core/knotserver/git"
22
"tangled.org/core/log"
···
120
}
121
122
// resolve this aturi to extract the repo record
123
-
resolver := idresolver.DefaultResolver()
124
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
125
if err != nil || ident.Handle.IsInvalidHandle() {
126
return fmt.Errorf("failed to resolve handle: %w", err)
127
}
···
163
164
var pipeline workflow.RawPipeline
165
for _, e := range workflowDir {
166
-
if !e.IsFile {
167
continue
168
}
169
···
233
return err
234
}
235
236
-
resolver := idresolver.DefaultResolver()
237
-
238
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
239
if err != nil || subjectId.Handle.IsInvalidHandle() {
240
return err
241
}
242
243
// TODO: fix this for good, we need to fetch the record here unfortunately
244
// resolve this aturi to extract the repo record
245
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
246
if err != nil || owner.Handle.IsInvalidHandle() {
247
return fmt.Errorf("failed to resolve handle: %w", err)
248
}
···
16
"github.com/bluesky-social/jetstream/pkg/models"
17
securejoin "github.com/cyphar/filepath-securejoin"
18
"tangled.org/core/api/tangled"
19
"tangled.org/core/knotserver/db"
20
"tangled.org/core/knotserver/git"
21
"tangled.org/core/log"
···
119
}
120
121
// resolve this aturi to extract the repo record
122
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
123
if err != nil || ident.Handle.IsInvalidHandle() {
124
return fmt.Errorf("failed to resolve handle: %w", err)
125
}
···
161
162
var pipeline workflow.RawPipeline
163
for _, e := range workflowDir {
164
+
if !e.IsFile() {
165
continue
166
}
167
···
231
return err
232
}
233
234
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
235
if err != nil || subjectId.Handle.IsInvalidHandle() {
236
return err
237
}
238
239
// TODO: fix this for good, we need to fetch the record here unfortunately
240
// resolve this aturi to extract the repo record
241
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
242
if err != nil || owner.Handle.IsInvalidHandle() {
243
return fmt.Errorf("failed to resolve handle: %w", err)
244
}
+146
-49
knotserver/internal.go
+146
-49
knotserver/internal.go
···
27
)
28
29
type InternalHandle struct {
30
-
db *db.DB
31
-
c *config.Config
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
35
}
36
37
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
67
writeJSON(w, data)
68
}
69
70
type PushOptions struct {
71
skipCi bool
72
verboseCi bool
···
121
// non-fatal
122
}
123
124
-
if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
125
-
msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context())
126
-
if err != nil {
127
-
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
128
-
// non-fatal
129
-
} else {
130
-
for msgLine := range msg {
131
-
resp.Messages = append(resp.Messages, msg[msgLine])
132
-
}
133
-
}
134
}
135
136
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
143
writeJSON(w, resp)
144
}
145
146
-
func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
147
-
l := h.l.With("handler", "replyCompare")
148
-
userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner)
149
-
user := repoOwner
150
-
if err != nil {
151
-
l.Error("Failed to fetch user identity", "err", err)
152
-
// non-fatal
153
-
} else {
154
-
user = userIdent.Handle.String()
155
-
}
156
-
gr, err := git.PlainOpen(gitRelativeDir)
157
-
if err != nil {
158
-
l.Error("Failed to open git repository", "err", err)
159
-
return []string{}, err
160
-
}
161
-
defaultBranch, err := gr.FindMainBranch()
162
-
if err != nil {
163
-
l.Error("Failed to fetch default branch", "err", err)
164
-
return []string{}, err
165
-
}
166
-
if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
167
-
return []string{}, nil
168
-
}
169
-
ZWS := "\u200B"
170
-
var msg []string
171
-
msg = append(msg, ZWS)
172
-
msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
173
-
msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
174
-
msg = append(msg, ZWS)
175
-
return msg, nil
176
-
}
177
-
178
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
179
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
180
if err != nil {
···
220
return errors.Join(errs, h.db.InsertEvent(event, h.n))
221
}
222
223
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
224
if pushOptions.skipCi {
225
return nil
226
}
···
247
248
var pipeline workflow.RawPipeline
249
for _, e := range workflowDir {
250
-
if !e.IsFile {
251
continue
252
}
253
···
315
return h.db.InsertEvent(event, h.n)
316
}
317
318
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
319
r := chi.NewRouter()
320
l := log.FromContext(ctx)
321
l = log.SubLogger(l, "internal")
322
323
h := InternalHandle{
324
db,
···
326
e,
327
l,
328
n,
329
}
330
331
r.Get("/push-allowed", h.PushAllowed)
332
r.Get("/keys", h.InternalKeys)
333
r.Post("/hooks/post-receive", h.PostReceiveHook)
334
r.Mount("/debug", middleware.Profiler())
335
···
27
)
28
29
type InternalHandle struct {
30
+
db *db.DB
31
+
c *config.Config
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
res *idresolver.Resolver
36
}
37
38
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
68
writeJSON(w, data)
69
}
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
+
131
type PushOptions struct {
132
skipCi bool
133
verboseCi bool
···
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)
188
+
// non-fatal
189
}
190
191
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
198
writeJSON(w, resp)
199
}
200
201
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
202
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
203
if err != nil {
···
243
return errors.Join(errs, h.db.InsertEvent(event, h.n))
244
}
245
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 {
254
if pushOptions.skipCi {
255
return nil
256
}
···
277
278
var pipeline workflow.RawPipeline
279
for _, e := range workflowDir {
280
+
if !e.IsFile() {
281
continue
282
}
283
···
345
return h.db.InsertEvent(event, h.n)
346
}
347
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 {
413
r := chi.NewRouter()
414
l := log.FromContext(ctx)
415
l = log.SubLogger(l, "internal")
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
417
418
h := InternalHandle{
419
db,
···
421
e,
422
l,
423
n,
424
+
res,
425
}
426
427
r.Get("/push-allowed", h.PushAllowed)
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
431
r.Mount("/debug", middleware.Profiler())
432
+18
knotserver/middleware.go
+18
knotserver/middleware.go
···
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
+
}
+2
-1
knotserver/router.go
+2
-1
knotserver/router.go
···
36
l: log.FromContext(ctx),
37
jc: jc,
38
n: n,
39
-
resolver: idresolver.DefaultResolver(),
40
}
41
42
err := e.AddKnot(rbac.ThisServer)
···
71
func (h *Knot) Router() http.Handler {
72
r := chi.NewRouter()
73
74
r.Use(h.RequestLogger)
75
76
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
···
36
l: log.FromContext(ctx),
37
jc: jc,
38
n: n,
39
+
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
40
}
41
42
err := e.AddKnot(rbac.ThisServer)
···
71
func (h *Knot) Router() http.Handler {
72
r := chi.NewRouter()
73
74
+
r.Use(h.CORS)
75
r.Use(h.RequestLogger)
76
77
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+21
-2
knotserver/xrpc/repo_blob.go
+21
-2
knotserver/xrpc/repo_blob.go
···
42
return
43
}
44
45
contents, err := gr.RawContent(treePath)
46
if err != nil {
47
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
101
var encoding string
102
103
isBinary := !isTextual(mimeType)
104
105
if isBinary {
106
content = base64.StdEncoding.EncodeToString(contents)
···
113
response := tangled.RepoBlob_Output{
114
Ref: ref,
115
Path: treePath,
116
-
Content: content,
117
Encoding: &encoding,
118
-
Size: &[]int64{int64(len(contents))}[0],
119
IsBinary: &isBinary,
120
}
121
···
42
return
43
}
44
45
+
// first check if this path is a submodule
46
+
submodule, err := gr.Submodule(treePath)
47
+
if err != nil {
48
+
// this is okay, continue and try to treat it as a regular file
49
+
} else {
50
+
response := tangled.RepoBlob_Output{
51
+
Ref: ref,
52
+
Path: treePath,
53
+
Submodule: &tangled.RepoBlob_Submodule{
54
+
Name: submodule.Name,
55
+
Url: submodule.URL,
56
+
Branch: &submodule.Branch,
57
+
},
58
+
}
59
+
writeJson(w, response)
60
+
return
61
+
}
62
+
63
contents, err := gr.RawContent(treePath)
64
if err != nil {
65
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
119
var encoding string
120
121
isBinary := !isTextual(mimeType)
122
+
size := int64(len(contents))
123
124
if isBinary {
125
content = base64.StdEncoding.EncodeToString(contents)
···
132
response := tangled.RepoBlob_Output{
133
Ref: ref,
134
Path: treePath,
135
+
Content: &content,
136
Encoding: &encoding,
137
+
Size: &size,
138
IsBinary: &isBinary,
139
}
140
+3
-5
knotserver/xrpc/repo_tree.go
+3
-5
knotserver/xrpc/repo_tree.go
···
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
for i, file := range files {
69
entry := &tangled.RepoTree_TreeEntry{
70
-
Name: file.Name,
71
-
Mode: file.Mode,
72
-
Size: file.Size,
73
-
Is_file: file.IsFile,
74
-
Is_subtree: file.IsSubtree,
75
}
76
77
if file.LastCommit != nil {
+5
lexicons/actor/profile.json
+5
lexicons/actor/profile.json
+49
-5
lexicons/repo/blob.json
+49
-5
lexicons/repo/blob.json
···
6
"type": "query",
7
"parameters": {
8
"type": "params",
9
-
"required": ["repo", "ref", "path"],
10
"properties": {
11
"repo": {
12
"type": "string",
···
31
"encoding": "application/json",
32
"schema": {
33
"type": "object",
34
-
"required": ["ref", "path", "content"],
35
"properties": {
36
"ref": {
37
"type": "string",
···
48
"encoding": {
49
"type": "string",
50
"description": "Content encoding",
51
-
"enum": ["utf-8", "base64"]
52
},
53
"size": {
54
"type": "integer",
···
61
"mimeType": {
62
"type": "string",
63
"description": "MIME type of the file"
64
},
65
"lastCommit": {
66
"type": "ref",
···
90
},
91
"lastCommit": {
92
"type": "object",
93
-
"required": ["hash", "message", "when"],
94
"properties": {
95
"hash": {
96
"type": "string",
···
117
},
118
"signature": {
119
"type": "object",
120
-
"required": ["name", "email", "when"],
121
"properties": {
122
"name": {
123
"type": "string",
···
131
"type": "string",
132
"format": "datetime",
133
"description": "Author timestamp"
134
}
135
}
136
}
···
6
"type": "query",
7
"parameters": {
8
"type": "params",
9
+
"required": [
10
+
"repo",
11
+
"ref",
12
+
"path"
13
+
],
14
"properties": {
15
"repo": {
16
"type": "string",
···
35
"encoding": "application/json",
36
"schema": {
37
"type": "object",
38
+
"required": [
39
+
"ref",
40
+
"path"
41
+
],
42
"properties": {
43
"ref": {
44
"type": "string",
···
55
"encoding": {
56
"type": "string",
57
"description": "Content encoding",
58
+
"enum": [
59
+
"utf-8",
60
+
"base64"
61
+
]
62
},
63
"size": {
64
"type": "integer",
···
71
"mimeType": {
72
"type": "string",
73
"description": "MIME type of the file"
74
+
},
75
+
"submodule": {
76
+
"type": "ref",
77
+
"ref": "#submodule",
78
+
"description": "Submodule information if path is a submodule"
79
},
80
"lastCommit": {
81
"type": "ref",
···
105
},
106
"lastCommit": {
107
"type": "object",
108
+
"required": [
109
+
"hash",
110
+
"message",
111
+
"when"
112
+
],
113
"properties": {
114
"hash": {
115
"type": "string",
···
136
},
137
"signature": {
138
"type": "object",
139
+
"required": [
140
+
"name",
141
+
"email",
142
+
"when"
143
+
],
144
"properties": {
145
"name": {
146
"type": "string",
···
154
"type": "string",
155
"format": "datetime",
156
"description": "Author timestamp"
157
+
}
158
+
}
159
+
},
160
+
"submodule": {
161
+
"type": "object",
162
+
"required": [
163
+
"name",
164
+
"url"
165
+
],
166
+
"properties": {
167
+
"name": {
168
+
"type": "string",
169
+
"description": "Submodule name"
170
+
},
171
+
"url": {
172
+
"type": "string",
173
+
"description": "Submodule repository URL"
174
+
},
175
+
"branch": {
176
+
"type": "string",
177
+
"description": "Branch to track in the submodule"
178
}
179
}
180
}
+15
lexicons/repo/repo.json
+15
lexicons/repo/repo.json
···
32
"minGraphemes": 1,
33
"maxGraphemes": 140
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
+
},
50
"source": {
51
"type": "string",
52
"format": "uri",
+1
-9
lexicons/repo/tree.json
+1
-9
lexicons/repo/tree.json
···
91
},
92
"treeEntry": {
93
"type": "object",
94
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
95
"properties": {
96
"name": {
97
"type": "string",
···
104
"size": {
105
"type": "integer",
106
"description": "File size in bytes"
107
-
},
108
-
"is_file": {
109
-
"type": "boolean",
110
-
"description": "Whether this entry is a file"
111
-
},
112
-
"is_subtree": {
113
-
"type": "boolean",
114
-
"description": "Whether this entry is a directory/subtree"
115
},
116
"last_commit": {
117
"type": "ref",
+87
-6
nix/gomod2nix.toml
+87
-6
nix/gomod2nix.toml
···
13
[mod."github.com/ProtonMail/go-crypto"]
14
version = "v1.3.0"
15
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
[mod."github.com/alecthomas/assert/v2"]
17
version = "v2.11.0"
18
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
···
38
[mod."github.com/beorn7/perks"]
39
version = "v1.0.1"
40
hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
41
[mod."github.com/bluekeyes/go-gitdiff"]
42
version = "v0.8.2"
43
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
···
49
version = "v0.0.0-20241210005130-ea96859b93d1"
50
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
51
[mod."github.com/bmatcuk/doublestar/v4"]
52
-
version = "v4.7.1"
53
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
54
[mod."github.com/carlmjohnson/versioninfo"]
55
version = "v0.22.5"
56
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
199
[mod."github.com/golang/mock"]
200
version = "v1.6.0"
201
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
202
[mod."github.com/google/go-querystring"]
203
version = "v1.1.0"
204
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
···
295
[mod."github.com/ipfs/go-metrics-interface"]
296
version = "v0.3.0"
297
hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ="
298
[mod."github.com/kevinburke/ssh_config"]
299
version = "v1.2.0"
300
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
···
352
[mod."github.com/moby/term"]
353
version = "v0.5.2"
354
hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU="
355
[mod."github.com/morikuni/aec"]
356
version = "v1.0.0"
357
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
358
[mod."github.com/mr-tron/base58"]
359
version = "v1.2.0"
360
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
361
[mod."github.com/muesli/termenv"]
362
version = "v0.16.0"
363
hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI="
···
471
version = "v0.3.1"
472
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
473
[mod."github.com/wyatt915/goldmark-treeblood"]
474
-
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
475
-
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
476
[mod."github.com/wyatt915/treeblood"]
477
-
version = "v0.1.15"
478
-
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
479
[mod."github.com/xo/terminfo"]
480
version = "v0.0.0-20220910002029-abceb7e1c41e"
481
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
···
494
[mod."gitlab.com/yawning/tuplehash"]
495
version = "v0.0.0-20230713102510-df83abbf9a02"
496
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
497
[mod."go.opentelemetry.io/auto/sdk"]
498
version = "v1.1.0"
499
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
···
13
[mod."github.com/ProtonMail/go-crypto"]
14
version = "v1.3.0"
15
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/RoaringBitmap/roaring/v2"]
17
+
version = "v2.4.5"
18
+
hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA="
19
[mod."github.com/alecthomas/assert/v2"]
20
version = "v2.11.0"
21
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
···
41
[mod."github.com/beorn7/perks"]
42
version = "v1.0.1"
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="
101
[mod."github.com/bluekeyes/go-gitdiff"]
102
version = "v0.8.2"
103
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
···
109
version = "v0.0.0-20241210005130-ea96859b93d1"
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
111
[mod."github.com/bmatcuk/doublestar/v4"]
112
+
version = "v4.9.1"
113
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
114
[mod."github.com/carlmjohnson/versioninfo"]
115
version = "v0.22.5"
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
259
[mod."github.com/golang/mock"]
260
version = "v1.6.0"
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="
268
[mod."github.com/google/go-querystring"]
269
version = "v1.1.0"
270
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
···
361
[mod."github.com/ipfs/go-metrics-interface"]
362
version = "v0.3.0"
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="
367
[mod."github.com/kevinburke/ssh_config"]
368
version = "v1.2.0"
369
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
···
421
[mod."github.com/moby/term"]
422
version = "v0.5.2"
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="
430
[mod."github.com/morikuni/aec"]
431
version = "v1.0.0"
432
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
433
[mod."github.com/mr-tron/base58"]
434
version = "v1.2.0"
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="
···
549
version = "v0.3.1"
550
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
551
[mod."github.com/wyatt915/goldmark-treeblood"]
552
+
version = "v0.0.1"
553
+
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
554
[mod."github.com/wyatt915/treeblood"]
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="
···
572
[mod."gitlab.com/yawning/tuplehash"]
573
version = "v0.0.0-20230713102510-df83abbf9a02"
574
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
575
+
[mod."go.etcd.io/bbolt"]
576
+
version = "v1.4.0"
577
+
hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
578
[mod."go.opentelemetry.io/auto/sdk"]
579
version = "v1.1.0"
580
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
+285
-18
nix/modules/appview.nix
+285
-18
nix/modules/appview.nix
···
3
lib,
4
...
5
}: let
6
-
cfg = config.services.tangled-appview;
7
in
8
with lib; {
9
options = {
10
-
services.tangled-appview = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
14
description = "Enable tangled appview";
15
};
16
package = mkOption {
17
type = types.package;
18
description = "Package to use for the appview";
19
};
20
port = mkOption {
21
-
type = types.int;
22
default = 3000;
23
description = "Port to run the appview on";
24
};
25
-
cookie_secret = mkOption {
26
type = types.str;
27
-
default = "00000000000000000000000000000000";
28
-
description = "Cookie secret";
29
};
30
environmentFile = mkOption {
31
type = with types; nullOr path;
32
default = null;
33
-
example = "/etc/tangled-appview.env";
34
description = ''
35
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
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
-
41
'';
42
};
43
};
44
};
45
46
config = mkIf cfg.enable {
47
-
systemd.services.tangled-appview = {
48
description = "tangled appview service";
49
wantedBy = ["multi-user.target"];
50
51
serviceConfig = {
52
-
ListenStream = "0.0.0.0:${toString cfg.port}";
53
ExecStart = "${cfg.package}/bin/appview";
54
Restart = "always";
55
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
56
};
57
58
-
environment = {
59
-
TANGLED_DB_PATH = "appview.db";
60
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
-
};
62
};
63
};
64
}
···
3
lib,
4
...
5
}: let
6
+
cfg = config.services.tangled.appview;
7
in
8
with lib; {
9
options = {
10
+
services.tangled.appview = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
14
description = "Enable tangled appview";
15
};
16
+
17
package = mkOption {
18
type = types.package;
19
description = "Package to use for the appview";
20
};
21
+
22
+
# core configuration
23
port = mkOption {
24
+
type = types.port;
25
default = 3000;
26
description = "Port to run the appview on";
27
};
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 {
36
type = types.str;
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
+
};
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
+
228
environmentFile = mkOption {
229
type = with types; nullOr path;
230
default = null;
231
+
example = "/etc/appview.env";
232
description = ''
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
234
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.
245
'';
246
};
247
};
248
};
249
250
config = mkIf cfg.enable {
251
+
services.redis.servers.appview = {
252
+
enable = true;
253
+
port = 6379;
254
+
};
255
+
256
+
systemd.services.appview = {
257
description = "tangled appview service";
258
wantedBy = ["multi-user.target"];
259
+
after = ["redis-appview.service" "network-online.target"];
260
+
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
262
263
serviceConfig = {
264
+
Type = "simple";
265
ExecStart = "${cfg.package}/bin/appview";
266
Restart = "always";
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"];
280
};
281
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
+
};
329
};
330
};
331
}
+74
-4
nix/modules/knot.nix
+74
-4
nix/modules/knot.nix
···
4
lib,
5
...
6
}: let
7
-
cfg = config.services.tangled-knot;
8
in
9
with lib; {
10
options = {
11
-
services.tangled-knot = {
12
enable = mkOption {
13
type = types.bool;
14
default = false;
···
51
description = "Path where repositories are scanned from";
52
};
53
54
mainBranch = mkOption {
55
type = types.str;
56
default = "main";
57
description = "Default branch name for repositories";
58
};
59
};
60
···
111
description = "Hostname for the server (required)";
112
};
113
114
dev = mkOption {
115
type = types.bool;
116
default = false;
···
178
mkdir -p "${cfg.stateDir}/.config/git"
179
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
[user]
181
-
name = Git User
182
-
email = git@example.com
183
[receive]
184
advertisePushOptions = true
185
EOF
186
${setMotd}
187
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
193
WorkingDirectory = cfg.stateDir;
194
Environment = [
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
196
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
197
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
198
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
199
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
202
"KNOT_SERVER_OWNER=${cfg.server.owner}"
203
];
204
ExecStart = "${cfg.package}/bin/knot server";
205
Restart = "always";
···
4
lib,
5
...
6
}: let
7
+
cfg = config.services.tangled.knot;
8
in
9
with lib; {
10
options = {
11
+
services.tangled.knot = {
12
enable = mkOption {
13
type = types.bool;
14
default = false;
···
51
description = "Path where repositories are scanned from";
52
};
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
+
75
mainBranch = mkOption {
76
type = types.str;
77
default = "main";
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";
93
};
94
};
95
···
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";
165
+
};
166
+
167
dev = mkOption {
168
type = types.bool;
169
default = false;
···
231
mkdir -p "${cfg.stateDir}/.config/git"
232
cat > "${cfg.stateDir}/.config/git/config" << EOF
233
[user]
234
+
name = ${cfg.git.userName}
235
+
email = ${cfg.git.userEmail}
236
[receive]
237
advertisePushOptions = true
238
+
[uploadpack]
239
+
allowFilter = true
240
EOF
241
${setMotd}
242
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
248
WorkingDirectory = cfg.stateDir;
249
Environment = [
250
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
252
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
255
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
256
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
257
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
258
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
259
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
260
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
261
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
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
+
}"
273
];
274
ExecStart = "${cfg.package}/bin/knot server";
275
Restart = "always";
+10
-3
nix/modules/spindle.nix
+10
-3
nix/modules/spindle.nix
···
3
lib,
4
...
5
}: let
6
-
cfg = config.services.tangled-spindle;
7
in
8
with lib; {
9
options = {
10
-
services.tangled-spindle = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
···
35
type = types.str;
36
example = "my.spindle.com";
37
description = "Hostname for the server (required)";
38
};
39
40
jetstreamEndpoint = mkOption {
···
119
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
120
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
121
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
122
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
123
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
124
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
···
3
lib,
4
...
5
}: let
6
+
cfg = config.services.tangled.spindle;
7
in
8
with lib; {
9
options = {
10
+
services.tangled.spindle = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
···
35
type = types.str;
36
example = "my.spindle.com";
37
description = "Hostname for the server (required)";
38
+
};
39
+
40
+
plcUrl = mkOption {
41
+
type = types.str;
42
+
default = "https://plc.directory";
43
+
description = "atproto PLC directory";
44
};
45
46
jetstreamEndpoint = mkOption {
···
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
126
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
128
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
132
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
+2
nix/pkgs/appview-static-files.nix
···
5
lucide-src,
6
inter-fonts-src,
7
ibm-plex-mono-src,
8
sqlite-lib,
9
tailwindcss,
10
src,
···
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
27
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
# for whatever reason (produces broken css), so we are doing this instead
29
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
···
5
lucide-src,
6
inter-fonts-src,
7
ibm-plex-mono-src,
8
+
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
src,
···
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
# for whatever reason (produces broken css), so we are doing this instead
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
+21
-8
nix/vm.nix
+21
-8
nix/vm.nix
···
10
if var == ""
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
else var;
13
in
14
nixpkgs.lib.nixosSystem {
15
inherit system;
···
73
time.timeZone = "Europe/London";
74
services.getty.autologinUser = "root";
75
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
-
services.tangled-knot = {
77
enable = true;
78
motd = "Welcome to the development knot!\n";
79
server = {
80
owner = envVar "TANGLED_VM_KNOT_OWNER";
81
-
hostname = "localhost:6000";
82
listenAddr = "0.0.0.0:6000";
83
};
84
};
85
-
services.tangled-spindle = {
86
enable = true;
87
server = {
88
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
89
-
hostname = "localhost:6555";
90
listenAddr = "0.0.0.0:6555";
91
dev = true;
92
queueSize = 100;
···
99
users = {
100
# So we don't have to deal with permission clashing between
101
# blank disk VMs and existing state
102
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
104
105
# TODO: separate spindle user
106
};
···
120
serviceConfig.PermissionsStartOnly = true;
121
};
122
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);
125
};
126
})
127
];
···
10
if var == ""
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
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";
22
in
23
nixpkgs.lib.nixosSystem {
24
inherit system;
···
82
time.timeZone = "Europe/London";
83
services.getty.autologinUser = "root";
84
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
85
+
services.tangled.knot = {
86
enable = true;
87
motd = "Welcome to the development knot!\n";
88
server = {
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
+
plcUrl = plcUrl;
92
+
jetstreamEndpoint = jetstream;
93
listenAddr = "0.0.0.0:6000";
94
};
95
};
96
+
services.tangled.spindle = {
97
enable = true;
98
server = {
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
+
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
103
listenAddr = "0.0.0.0:6555";
104
dev = true;
105
queueSize = 100;
···
112
users = {
113
# So we don't have to deal with permission clashing between
114
# blank disk VMs and existing state
115
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
117
118
# TODO: separate spindle user
119
};
···
133
serviceConfig.PermissionsStartOnly = true;
134
};
135
in {
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);
138
};
139
})
140
];
-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
DBPath string `env:"DB_PATH, default=spindle.db"`
14
Hostname string `env:"HOSTNAME, required"`
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
Dev bool `env:"DEV, default=false"`
17
Owner string `env:"OWNER, required"`
18
Secrets Secrets `env:",prefix=SECRETS_"`
···
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
Hostname string `env:"HOSTNAME, required"`
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
Dev bool `env:"DEV, default=false"`
18
Owner string `env:"OWNER, required"`
19
Secrets Secrets `env:",prefix=SECRETS_"`
+13
-3
spindle/engine/engine.go
+13
-3
spindle/engine/engine.go
···
79
defer cancel()
80
81
for stepIdx, step := range w.Steps {
82
if wfLogger != nil {
83
-
ctl := wfLogger.ControlWriter(stepIdx, step)
84
-
ctl.Write([]byte(step.Name()))
85
}
86
87
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
88
if err != nil {
89
if errors.Is(err, ErrTimedOut) {
90
dbErr := db.StatusTimeout(wid, n)
···
115
if err := eg.Wait(); err != nil {
116
l.Error("failed to run one or more workflows", "err", err)
117
} else {
118
-
l.Error("successfully ran full pipeline")
119
}
120
}
···
79
defer cancel()
80
81
for stepIdx, step := range w.Steps {
82
+
// log start of step
83
if wfLogger != nil {
84
+
wfLogger.
85
+
ControlWriter(stepIdx, step, models.StepStatusStart).
86
+
Write([]byte{0})
87
}
88
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
+
98
if err != nil {
99
if errors.Is(err, ErrTimedOut) {
100
dbErr := db.StatusTimeout(wid, n)
···
125
if err := eg.Wait(); err != nil {
126
l.Error("failed to run one or more workflows", "err", err)
127
} else {
128
+
l.Info("successfully ran full pipeline")
129
}
130
}
+3
-3
spindle/engines/nixery/engine.go
+3
-3
spindle/engines/nixery/engine.go
···
222
},
223
ReadonlyRootfs: false,
224
CapDrop: []string{"ALL"},
225
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
226
SecurityOpt: []string{"no-new-privileges"},
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
}, nil, nil, "")
···
381
defer logs.Close()
382
383
_, err = stdcopy.StdCopy(
384
-
wfLogger.DataWriter("stdout"),
385
-
wfLogger.DataWriter("stderr"),
386
logs.Reader,
387
)
388
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
···
222
},
223
ReadonlyRootfs: false,
224
CapDrop: []string{"ALL"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
226
SecurityOpt: []string{"no-new-privileges"},
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
}, nil, nil, "")
···
381
defer logs.Close()
382
383
_, err = stdcopy.StdCopy(
384
+
wfLogger.DataWriter(stepIdx, "stdout"),
385
+
wfLogger.DataWriter(stepIdx, "stderr"),
386
logs.Reader,
387
)
388
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3
-7
spindle/ingester.go
+3
-7
spindle/ingester.go
···
9
10
"tangled.org/core/api/tangled"
11
"tangled.org/core/eventconsumer"
12
-
"tangled.org/core/idresolver"
13
"tangled.org/core/rbac"
14
"tangled.org/core/spindle/db"
15
···
142
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
143
var err error
144
did := e.Did
145
-
resolver := idresolver.DefaultResolver()
146
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
···
190
}
191
192
// add collaborators to rbac
193
-
owner, err := resolver.ResolveIdent(ctx, did)
194
if err != nil || owner.Handle.IsInvalidHandle() {
195
return err
196
}
···
225
return err
226
}
227
228
-
resolver := idresolver.DefaultResolver()
229
-
230
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
231
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
return err
233
}
···
240
241
// TODO: get rid of this entirely
242
// resolve this aturi to extract the repo record
243
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
244
if err != nil || owner.Handle.IsInvalidHandle() {
245
return fmt.Errorf("failed to resolve handle: %w", err)
246
}
···
9
10
"tangled.org/core/api/tangled"
11
"tangled.org/core/eventconsumer"
12
"tangled.org/core/rbac"
13
"tangled.org/core/spindle/db"
14
···
141
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
142
var err error
143
did := e.Did
144
145
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
146
···
188
}
189
190
// add collaborators to rbac
191
+
owner, err := s.res.ResolveIdent(ctx, did)
192
if err != nil || owner.Handle.IsInvalidHandle() {
193
return err
194
}
···
223
return err
224
}
225
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
227
if err != nil || subjectId.Handle.IsInvalidHandle() {
228
return err
229
}
···
236
237
// TODO: get rid of this entirely
238
// resolve this aturi to extract the repo record
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
240
if err != nil || owner.Handle.IsInvalidHandle() {
241
return fmt.Errorf("failed to resolve handle: %w", err)
242
}
+14
-11
spindle/models/logger.go
+14
-11
spindle/models/logger.go
···
37
return l.file.Close()
38
}
39
40
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
41
-
// TODO: emit stream
42
return &dataWriter{
43
logger: l,
44
stream: stream,
45
}
46
}
47
48
-
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
49
return &controlWriter{
50
-
logger: l,
51
-
idx: idx,
52
-
step: step,
53
}
54
}
55
56
type dataWriter struct {
57
logger *WorkflowLogger
58
stream string
59
}
60
61
func (w *dataWriter) Write(p []byte) (int, error) {
62
line := strings.TrimRight(string(p), "\r\n")
63
-
entry := NewDataLogLine(line, w.stream)
64
if err := w.logger.encoder.Encode(entry); err != nil {
65
return 0, err
66
}
···
68
}
69
70
type controlWriter struct {
71
-
logger *WorkflowLogger
72
-
idx int
73
-
step Step
74
}
75
76
func (w *controlWriter) Write(_ []byte) (int, error) {
77
-
entry := NewControlLogLine(w.idx, w.step)
78
if err := w.logger.encoder.Encode(entry); err != nil {
79
return 0, err
80
}
···
37
return l.file.Close()
38
}
39
40
+
func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer {
41
return &dataWriter{
42
logger: l,
43
+
idx: idx,
44
stream: stream,
45
}
46
}
47
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer {
49
return &controlWriter{
50
+
logger: l,
51
+
idx: idx,
52
+
step: step,
53
+
stepStatus: stepStatus,
54
}
55
}
56
57
type dataWriter struct {
58
logger *WorkflowLogger
59
+
idx int
60
stream string
61
}
62
63
func (w *dataWriter) Write(p []byte) (int, error) {
64
line := strings.TrimRight(string(p), "\r\n")
65
+
entry := NewDataLogLine(w.idx, line, w.stream)
66
if err := w.logger.encoder.Encode(entry); err != nil {
67
return 0, err
68
}
···
70
}
71
72
type controlWriter struct {
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step Step
76
+
stepStatus StepStatus
77
}
78
79
func (w *controlWriter) Write(_ []byte) (int, error) {
80
+
entry := NewControlLogLine(w.idx, w.step, w.stepStatus)
81
if err := w.logger.encoder.Encode(entry); err != nil {
82
return 0, err
83
}
+23
-8
spindle/models/models.go
+23
-8
spindle/models/models.go
···
4
"fmt"
5
"regexp"
6
"slices"
7
8
"tangled.org/core/api/tangled"
9
···
76
var (
77
// step log data
78
LogKindData LogKind = "data"
79
-
// indicates start/end of a step
80
LogKindControl LogKind = "control"
81
)
82
83
type LogLine struct {
84
-
Kind LogKind `json:"kind"`
85
-
Content string `json:"content"`
86
87
// fields if kind is "data"
88
Stream string `json:"stream,omitempty"`
89
90
// 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"`
94
}
95
96
-
func NewDataLogLine(content, stream string) LogLine {
97
return LogLine{
98
Kind: LogKindData,
99
Content: content,
100
Stream: stream,
101
}
102
}
103
104
-
func NewControlLogLine(idx int, step Step) LogLine {
105
return LogLine{
106
Kind: LogKindControl,
107
Content: step.Name(),
108
StepId: idx,
109
StepKind: step.Kind(),
110
StepCommand: step.Command(),
111
}
···
4
"fmt"
5
"regexp"
6
"slices"
7
+
"time"
8
9
"tangled.org/core/api/tangled"
10
···
77
var (
78
// step log data
79
LogKindData LogKind = "data"
80
+
// indicates status of a step
81
LogKindControl LogKind = "control"
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
+
92
type LogLine struct {
93
+
Kind LogKind `json:"kind"`
94
+
Content string `json:"content"`
95
+
Time time.Time `json:"time"`
96
+
StepId int `json:"step_id"`
97
98
// fields if kind is "data"
99
Stream string `json:"stream,omitempty"`
100
101
// fields if kind is "control"
102
+
StepStatus StepStatus `json:"step_status,omitempty"`
103
+
StepKind StepKind `json:"step_kind,omitempty"`
104
+
StepCommand string `json:"step_command,omitempty"`
105
}
106
107
+
func NewDataLogLine(idx int, content, stream string) LogLine {
108
return LogLine{
109
Kind: LogKindData,
110
+
Time: time.Now(),
111
Content: content,
112
+
StepId: idx,
113
Stream: stream,
114
}
115
}
116
117
+
func NewControlLogLine(idx int, step Step, status StepStatus) LogLine {
118
return LogLine{
119
Kind: LogKindControl,
120
+
Time: time.Now(),
121
Content: step.Name(),
122
StepId: idx,
123
+
StepStatus: status,
124
StepKind: step.Kind(),
125
StepCommand: step.Command(),
126
}
+86
-41
spindle/server.go
+86
-41
spindle/server.go
···
49
vault secrets.Manager
50
}
51
52
-
func Run(ctx context.Context) error {
53
logger := log.FromContext(ctx)
54
-
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
60
d, err := db.Make(cfg.Server.DBPath)
61
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
63
}
64
65
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
}
69
e.E.EnableAutoSave(true)
70
···
74
switch cfg.Server.Secrets.Provider {
75
case "openbao":
76
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
}
79
vault, err = secrets.NewOpenBaoManager(
80
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
)
84
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
}
87
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
case "sqlite", "":
89
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
}
93
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
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
101
}
102
103
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
110
}
111
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
114
}
115
jc.AddDid(cfg.Server.Owner)
116
117
// Check if the spindle knows about any Dids;
118
dids, err := d.GetAllDids()
119
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
121
}
122
for _, d := range dids {
123
jc.AddDid(d)
124
}
125
126
-
resolver := idresolver.DefaultResolver()
127
128
-
spindle := Spindle{
129
jc: jc,
130
e: e,
131
db: d,
132
l: logger,
133
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
135
jq: jq,
136
cfg: cfg,
137
res: resolver,
···
140
141
err = e.AddSpindle(rbacDomain)
142
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
144
}
145
err = spindle.configureOwner()
146
if err != nil {
147
-
return err
148
}
149
logger.Info("owner set", "did", cfg.Server.Owner)
150
-
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
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
}
164
165
err = jc.StartJetstream(ctx, spindle.ingest())
166
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
168
}
169
170
// for each incoming sh.tangled.pipeline, we execute
···
177
ccfg.CursorStore = cursorStore
178
knownKnots, err := d.Knots()
179
if err != nil {
180
-
return err
181
}
182
for _, knot := range knownKnots {
183
logger.Info("adding source start", "knot", knot)
···
185
}
186
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
188
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
191
}()
192
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
195
196
-
return nil
197
}
198
199
func (s *Spindle) Router() http.Handler {
···
49
vault secrets.Manager
50
}
51
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) {
54
logger := log.FromContext(ctx)
55
56
d, err := db.Make(cfg.Server.DBPath)
57
if err != nil {
58
+
return nil, fmt.Errorf("failed to setup db: %w", err)
59
}
60
61
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
62
if err != nil {
63
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
64
}
65
e.E.EnableAutoSave(true)
66
···
70
switch cfg.Server.Secrets.Provider {
71
case "openbao":
72
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
73
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
74
}
75
vault, err = secrets.NewOpenBaoManager(
76
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
78
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
79
)
80
if err != nil {
81
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
82
}
83
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
84
case "sqlite", "":
85
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
86
if err != nil {
87
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
88
}
89
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
90
default:
91
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
92
}
93
94
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
101
}
102
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
103
if err != nil {
104
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
105
}
106
jc.AddDid(cfg.Server.Owner)
107
108
// Check if the spindle knows about any Dids;
109
dids, err := d.GetAllDids()
110
if err != nil {
111
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
112
}
113
for _, d := range dids {
114
jc.AddDid(d)
115
}
116
117
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
118
119
+
spindle := &Spindle{
120
jc: jc,
121
e: e,
122
db: d,
123
l: logger,
124
n: &n,
125
+
engs: engines,
126
jq: jq,
127
cfg: cfg,
128
res: resolver,
···
131
132
err = e.AddSpindle(rbacDomain)
133
if err != nil {
134
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
135
}
136
err = spindle.configureOwner()
137
if err != nil {
138
+
return nil, err
139
}
140
logger.Info("owner set", "did", cfg.Server.Owner)
141
142
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
143
if err != nil {
144
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
145
}
146
147
err = jc.StartJetstream(ctx, spindle.ingest())
148
if err != nil {
149
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
150
}
151
152
// for each incoming sh.tangled.pipeline, we execute
···
159
ccfg.CursorStore = cursorStore
160
knownKnots, err := d.Knots()
161
if err != nil {
162
+
return nil, err
163
}
164
for _, knot := range knownKnots {
165
logger.Info("adding source start", "knot", knot)
···
167
}
168
spindle.ks = eventconsumer.NewConsumer(*ccfg)
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
+
214
go func() {
215
+
s.l.Info("starting knot event consumer")
216
+
s.ks.Start(ctx)
217
}()
218
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
+
}
240
241
+
return s.Start(ctx)
242
}
243
244
func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
+5
spindle/stream.go
···
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
214
return fmt.Errorf("failed to write to websocket: %w", err)
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
+
}
221
}
222
}
223
}
+1
-1
types/repo.go
+1
-1
types/repo.go
+28
-5
types/tree.go
+28
-5
types/tree.go
···
4
"time"
5
6
"github.com/go-git/go-git/v5/plumbing"
7
)
8
9
// A nicer git tree representation.
10
type NiceTree struct {
11
// Relative path
12
-
Name string `json:"name"`
13
-
Mode string `json:"mode"`
14
-
Size int64 `json:"size"`
15
-
IsFile bool `json:"is_file"`
16
-
IsSubtree bool `json:"is_subtree"`
17
18
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
19
}
20
21
type LastCommitInfo struct {
···
4
"time"
5
6
"github.com/go-git/go-git/v5/plumbing"
7
+
"github.com/go-git/go-git/v5/plumbing/filemode"
8
)
9
10
// A nicer git tree representation.
11
type NiceTree struct {
12
// Relative path
13
+
Name string `json:"name"`
14
+
Mode string `json:"mode"`
15
+
Size int64 `json:"size"`
16
17
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
18
+
}
19
+
20
+
func (t *NiceTree) FileMode() (filemode.FileMode, error) {
21
+
return filemode.New(t.Mode)
22
+
}
23
+
24
+
func (t *NiceTree) IsFile() bool {
25
+
m, err := t.FileMode()
26
+
27
+
if err != nil {
28
+
return false
29
+
}
30
+
31
+
return m.IsFile()
32
+
}
33
+
34
+
func (t *NiceTree) IsSubmodule() bool {
35
+
m, err := t.FileMode()
36
+
37
+
if err != nil {
38
+
return false
39
+
}
40
+
41
+
return m == filemode.Submodule
42
}
43
44
type LastCommitInfo struct {
+9
-1
workflow/compile.go
+9
-1
workflow/compile.go
···
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
cw := &tangled.Pipeline_Workflow{}
115
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 {
125
compiler.Diagnostics.AddWarning(
126
w.Name,
127
WorkflowSkipped,
+125
workflow/compile_test.go
+125
workflow/compile_test.go
···
95
assert.Len(t, c.Diagnostics.Errors, 1)
96
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
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
9
"tangled.org/core/api/tangled"
10
11
"github.com/go-git/go-git/v5/plumbing"
12
"gopkg.in/yaml.v3"
13
)
···
33
34
Constraint struct {
35
Event StringList `yaml:"event"`
36
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
37
}
38
39
CloneOpts struct {
···
59
return strings.ReplaceAll(string(t), "_", " ")
60
}
61
62
func FromFile(name string, contents []byte) (Workflow, error) {
63
var wf Workflow
64
···
74
}
75
76
// if any of the constraints on a workflow is true, return true
77
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
78
// manual triggers always run the workflow
79
if trigger.Manual != nil {
80
-
return true
81
}
82
83
// if not manual, run through the constraint list and see if any one matches
84
for _, c := range w.When {
85
-
if c.Match(trigger) {
86
-
return true
87
}
88
}
89
90
// no constraints, always run this workflow
91
if len(w.When) == 0 {
92
-
return true
93
}
94
95
-
return false
96
}
97
98
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
99
match := true
100
101
// manual triggers always pass this constraint
102
if trigger.Manual != nil {
103
-
return true
104
}
105
106
// apply event constraints
···
108
109
// apply branch constraints for PRs
110
if trigger.PullRequest != nil {
111
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
112
}
113
114
// apply ref constraints for pushes
115
if trigger.Push != nil {
116
-
match = match && c.MatchRef(trigger.Push.Ref)
117
}
118
119
-
return match
120
-
}
121
-
122
-
func (c *Constraint) MatchBranch(branch string) bool {
123
-
return slices.Contains(c.Branch, branch)
124
}
125
126
-
func (c *Constraint) MatchRef(ref string) bool {
127
refName := plumbing.ReferenceName(ref)
128
if refName.IsBranch() {
129
-
return slices.Contains(c.Branch, refName.Short())
130
}
131
-
return false
132
}
133
134
func (c *Constraint) MatchEvent(event string) bool {
···
8
9
"tangled.org/core/api/tangled"
10
11
+
"github.com/bmatcuk/doublestar/v4"
12
"github.com/go-git/go-git/v5/plumbing"
13
"gopkg.in/yaml.v3"
14
)
···
34
35
Constraint struct {
36
Event StringList `yaml:"event"`
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
39
}
40
41
CloneOpts struct {
···
61
return strings.ReplaceAll(string(t), "_", " ")
62
}
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
+
81
func FromFile(name string, contents []byte) (Workflow, error) {
82
var wf Workflow
83
···
93
}
94
95
// if any of the constraints on a workflow is true, return true
96
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
97
// manual triggers always run the workflow
98
if trigger.Manual != nil {
99
+
return true, nil
100
}
101
102
// if not manual, run through the constraint list and see if any one matches
103
for _, c := range w.When {
104
+
matched, err := c.Match(trigger)
105
+
if err != nil {
106
+
return false, err
107
+
}
108
+
if matched {
109
+
return true, nil
110
}
111
}
112
113
// no constraints, always run this workflow
114
if len(w.When) == 0 {
115
+
return true, nil
116
}
117
118
+
return false, nil
119
}
120
121
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
122
match := true
123
124
// manual triggers always pass this constraint
125
if trigger.Manual != nil {
126
+
return true, nil
127
}
128
129
// apply event constraints
···
131
132
// apply branch constraints for PRs
133
if trigger.PullRequest != nil {
134
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
+
if err != nil {
136
+
return false, err
137
+
}
138
+
match = match && matched
139
}
140
141
// apply ref constraints for pushes
142
if trigger.Push != nil {
143
+
matched, err := c.MatchRef(trigger.Push.Ref)
144
+
if err != nil {
145
+
return false, err
146
+
}
147
+
match = match && matched
148
}
149
150
+
return match, nil
151
}
152
153
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
154
refName := plumbing.ReferenceName(ref)
155
+
shortName := refName.Short()
156
+
157
if refName.IsBranch() {
158
+
return c.MatchBranch(shortName)
159
}
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)
174
}
175
176
func (c *Constraint) MatchEvent(event string) bool {
+284
-1
workflow/def_test.go
+284
-1
workflow/def_test.go
···
6
"github.com/stretchr/testify/assert"
7
)
8
9
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
10
yamlData := `
11
when:
12
- event: ["push", "pull_request"]
···
38
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
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
+
}