+13
.editorconfig
+13
.editorconfig
+1
.gitignore
+1
.gitignore
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/test.yml
+1
-1
.tangled/workflows/test.yml
+3
-1
api/tangled/actorprofile.go
+3
-1
api/tangled/actorprofile.go
···
27
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
33
}
+196
-2
api/tangled/cbor_gen.go
+196
-2
api/tangled/cbor_gen.go
···
26
26
}
27
27
28
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 7
29
+
fieldCount := 8
30
30
31
31
if t.Description == nil {
32
32
fieldCount--
···
41
41
}
42
42
43
43
if t.PinnedRepositories == nil {
44
+
fieldCount--
45
+
}
46
+
47
+
if t.Pronouns == nil {
44
48
fieldCount--
45
49
}
46
50
···
186
190
return err
187
191
}
188
192
if _, err := cw.WriteString(string(*t.Location)); err != nil {
193
+
return err
194
+
}
195
+
}
196
+
}
197
+
198
+
// t.Pronouns (string) (string)
199
+
if t.Pronouns != nil {
200
+
201
+
if len("pronouns") > 1000000 {
202
+
return xerrors.Errorf("Value in field \"pronouns\" was too long")
203
+
}
204
+
205
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil {
206
+
return err
207
+
}
208
+
if _, err := cw.WriteString(string("pronouns")); err != nil {
209
+
return err
210
+
}
211
+
212
+
if t.Pronouns == nil {
213
+
if _, err := cw.Write(cbg.CborNull); err != nil {
214
+
return err
215
+
}
216
+
} else {
217
+
if len(*t.Pronouns) > 1000000 {
218
+
return xerrors.Errorf("Value in field t.Pronouns was too long")
219
+
}
220
+
221
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil {
222
+
return err
223
+
}
224
+
if _, err := cw.WriteString(string(*t.Pronouns)); err != nil {
189
225
return err
190
226
}
191
227
}
···
430
466
}
431
467
432
468
t.Location = (*string)(&sval)
469
+
}
470
+
}
471
+
// t.Pronouns (string) (string)
472
+
case "pronouns":
473
+
474
+
{
475
+
b, err := cr.ReadByte()
476
+
if err != nil {
477
+
return err
478
+
}
479
+
if b != cbg.CborNull[0] {
480
+
if err := cr.UnreadByte(); err != nil {
481
+
return err
482
+
}
483
+
484
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
485
+
if err != nil {
486
+
return err
487
+
}
488
+
489
+
t.Pronouns = (*string)(&sval)
433
490
}
434
491
}
435
492
// t.Description (string) (string)
···
5806
5863
}
5807
5864
5808
5865
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5866
+
fieldCount := 10
5810
5867
5811
5868
if t.Description == nil {
5812
5869
fieldCount--
···
5821
5878
}
5822
5879
5823
5880
if t.Spindle == nil {
5881
+
fieldCount--
5882
+
}
5883
+
5884
+
if t.Topics == nil {
5885
+
fieldCount--
5886
+
}
5887
+
5888
+
if t.Website == nil {
5824
5889
fieldCount--
5825
5890
}
5826
5891
···
5961
6026
}
5962
6027
}
5963
6028
6029
+
// t.Topics ([]string) (slice)
6030
+
if t.Topics != nil {
6031
+
6032
+
if len("topics") > 1000000 {
6033
+
return xerrors.Errorf("Value in field \"topics\" was too long")
6034
+
}
6035
+
6036
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil {
6037
+
return err
6038
+
}
6039
+
if _, err := cw.WriteString(string("topics")); err != nil {
6040
+
return err
6041
+
}
6042
+
6043
+
if len(t.Topics) > 8192 {
6044
+
return xerrors.Errorf("Slice value in field t.Topics was too long")
6045
+
}
6046
+
6047
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil {
6048
+
return err
6049
+
}
6050
+
for _, v := range t.Topics {
6051
+
if len(v) > 1000000 {
6052
+
return xerrors.Errorf("Value in field v was too long")
6053
+
}
6054
+
6055
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
6056
+
return err
6057
+
}
6058
+
if _, err := cw.WriteString(string(v)); err != nil {
6059
+
return err
6060
+
}
6061
+
6062
+
}
6063
+
}
6064
+
5964
6065
// t.Spindle (string) (string)
5965
6066
if t.Spindle != nil {
5966
6067
···
5993
6094
}
5994
6095
}
5995
6096
6097
+
// t.Website (string) (string)
6098
+
if t.Website != nil {
6099
+
6100
+
if len("website") > 1000000 {
6101
+
return xerrors.Errorf("Value in field \"website\" was too long")
6102
+
}
6103
+
6104
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil {
6105
+
return err
6106
+
}
6107
+
if _, err := cw.WriteString(string("website")); err != nil {
6108
+
return err
6109
+
}
6110
+
6111
+
if t.Website == nil {
6112
+
if _, err := cw.Write(cbg.CborNull); err != nil {
6113
+
return err
6114
+
}
6115
+
} else {
6116
+
if len(*t.Website) > 1000000 {
6117
+
return xerrors.Errorf("Value in field t.Website was too long")
6118
+
}
6119
+
6120
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil {
6121
+
return err
6122
+
}
6123
+
if _, err := cw.WriteString(string(*t.Website)); err != nil {
6124
+
return err
6125
+
}
6126
+
}
6127
+
}
6128
+
5996
6129
// t.CreatedAt (string) (string)
5997
6130
if len("createdAt") > 1000000 {
5998
6131
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
6318
t.Source = (*string)(&sval)
6186
6319
}
6187
6320
}
6321
+
// t.Topics ([]string) (slice)
6322
+
case "topics":
6323
+
6324
+
maj, extra, err = cr.ReadHeader()
6325
+
if err != nil {
6326
+
return err
6327
+
}
6328
+
6329
+
if extra > 8192 {
6330
+
return fmt.Errorf("t.Topics: array too large (%d)", extra)
6331
+
}
6332
+
6333
+
if maj != cbg.MajArray {
6334
+
return fmt.Errorf("expected cbor array")
6335
+
}
6336
+
6337
+
if extra > 0 {
6338
+
t.Topics = make([]string, extra)
6339
+
}
6340
+
6341
+
for i := 0; i < int(extra); i++ {
6342
+
{
6343
+
var maj byte
6344
+
var extra uint64
6345
+
var err error
6346
+
_ = maj
6347
+
_ = extra
6348
+
_ = err
6349
+
6350
+
{
6351
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6352
+
if err != nil {
6353
+
return err
6354
+
}
6355
+
6356
+
t.Topics[i] = string(sval)
6357
+
}
6358
+
6359
+
}
6360
+
}
6188
6361
// t.Spindle (string) (string)
6189
6362
case "spindle":
6190
6363
···
6204
6377
}
6205
6378
6206
6379
t.Spindle = (*string)(&sval)
6380
+
}
6381
+
}
6382
+
// t.Website (string) (string)
6383
+
case "website":
6384
+
6385
+
{
6386
+
b, err := cr.ReadByte()
6387
+
if err != nil {
6388
+
return err
6389
+
}
6390
+
if b != cbg.CborNull[0] {
6391
+
if err := cr.UnreadByte(); err != nil {
6392
+
return err
6393
+
}
6394
+
6395
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6396
+
if err != nil {
6397
+
return err
6398
+
}
6399
+
6400
+
t.Website = (*string)(&sval)
6207
6401
}
6208
6402
}
6209
6403
// t.CreatedAt (string) (string)
+13
-1
api/tangled/repoblob.go
+13
-1
api/tangled/repoblob.go
···
30
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
31
type RepoBlob_Output struct {
32
32
// content: File content (base64 encoded for binary files)
33
-
Content string `json:"content" cborgen:"content"`
33
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
34
34
// encoding: Content encoding
35
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
36
// isBinary: Whether the file is binary
···
44
44
Ref string `json:"ref" cborgen:"ref"`
45
45
// size: File size in bytes
46
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"`
47
49
}
48
50
49
51
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
54
56
Name string `json:"name" cborgen:"name"`
55
57
// when: Author timestamp
56
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"`
57
69
}
58
70
59
71
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
-4
api/tangled/repotree.go
···
47
47
48
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
49
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
50
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
55
51
// mode: File mode
56
52
Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
+4
api/tangled/tangledrepo.go
···
30
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
31
// spindle: CI runner to send jobs to and receive results from
32
32
Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"`
33
+
// topics: Topics related to the repo
34
+
Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"`
35
+
// website: Any URI related to the repo
36
+
Website *string `json:"website,omitempty" cborgen:"website,omitempty"`
33
37
}
+15
-2
appview/config/config.go
+15
-2
appview/config/config.go
···
13
13
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
14
DbPath string `env:"DB_PATH, default=appview.db"`
15
15
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"`
17
+
AppviewName string `env:"APPVIEW_Name, default=Tangled"`
17
18
Dev bool `env:"DEV, default=false"`
18
19
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
20
···
25
26
}
26
27
27
28
type OAuthConfig struct {
28
-
Jwks string `env:"JWKS"`
29
+
ClientSecret string `env:"CLIENT_SECRET"`
30
+
ClientKid string `env:"CLIENT_KID"`
31
+
}
32
+
33
+
type PlcConfig struct {
34
+
PLCURL string `env:"URL, default=https://plc.directory"`
29
35
}
30
36
31
37
type JetstreamConfig struct {
···
78
84
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
79
85
}
80
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
+
81
92
func (cfg RedisConfig) ToURL() string {
82
93
u := &url.URL{
83
94
Scheme: "redis",
···
103
114
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
104
115
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
105
116
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
106
118
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
107
119
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
108
121
}
109
122
110
123
func LoadConfig(ctx context.Context) (*Config, error) {
-1
appview/db/artifact.go
-1
appview/db/artifact.go
+53
appview/db/collaborators.go
+53
appview/db/collaborators.go
···
3
3
import (
4
4
"fmt"
5
5
"strings"
6
+
"time"
6
7
7
8
"tangled.org/core/appview/models"
8
9
)
···
59
60
60
61
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
61
62
}
63
+
64
+
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
+
var collaborators []models.Collaborator
66
+
var conditions []string
67
+
var args []any
68
+
for _, filter := range filters {
69
+
conditions = append(conditions, filter.Condition())
70
+
args = append(args, filter.Arg()...)
71
+
}
72
+
whereClause := ""
73
+
if conditions != nil {
74
+
whereClause = " where " + strings.Join(conditions, " and ")
75
+
}
76
+
query := fmt.Sprintf(`select
77
+
id,
78
+
did,
79
+
rkey,
80
+
subject_did,
81
+
repo_at,
82
+
created
83
+
from collaborators %s`,
84
+
whereClause,
85
+
)
86
+
rows, err := e.Query(query, args...)
87
+
if err != nil {
88
+
return nil, err
89
+
}
90
+
defer rows.Close()
91
+
for rows.Next() {
92
+
var collaborator models.Collaborator
93
+
var createdAt string
94
+
if err := rows.Scan(
95
+
&collaborator.Id,
96
+
&collaborator.Did,
97
+
&collaborator.Rkey,
98
+
&collaborator.SubjectDid,
99
+
&collaborator.RepoAt,
100
+
&createdAt,
101
+
); err != nil {
102
+
return nil, err
103
+
}
104
+
collaborator.Created, err = time.Parse(time.RFC3339, createdAt)
105
+
if err != nil {
106
+
collaborator.Created = time.Now()
107
+
}
108
+
collaborators = append(collaborators, collaborator)
109
+
}
110
+
if err := rows.Err(); err != nil {
111
+
return nil, err
112
+
}
113
+
return collaborators, nil
114
+
}
+31
appview/db/db.go
+31
appview/db/db.go
···
1097
1097
})
1098
1098
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1099
1099
1100
+
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
+
// (but not on the pds record), because calculating the combined patch requires a git index
1102
+
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
+
_, err := tx.Exec(`
1104
+
alter table pull_submissions add column combined text;
1105
+
`)
1106
+
return err
1107
+
})
1108
+
1109
+
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1110
+
_, err := tx.Exec(`
1111
+
alter table profile add column pronouns text;
1112
+
`)
1113
+
return err
1114
+
})
1115
+
1116
+
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1117
+
_, err := tx.Exec(`
1118
+
alter table repos add column website text;
1119
+
alter table repos add column topics text;
1120
+
`)
1121
+
return err
1122
+
})
1123
+
1124
+
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1125
+
_, err := tx.Exec(`
1126
+
alter table notification_preferences add column user_mentioned integer not null default 1;
1127
+
`)
1128
+
return err
1129
+
})
1130
+
1100
1131
return &DB{
1101
1132
db,
1102
1133
logger,
+72
-16
appview/db/issues.go
+72
-16
appview/db/issues.go
···
101
101
pLower := FilterGte("row_num", page.Offset+1)
102
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
103
104
-
args = append(args, pLower.Arg()...)
105
-
args = append(args, pUpper.Arg()...)
106
-
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
104
+
pageClause := ""
105
+
if page.Limit > 0 {
106
+
args = append(args, pLower.Arg()...)
107
+
args = append(args, pUpper.Arg()...)
108
+
pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition()
109
+
}
107
110
108
111
query := fmt.Sprintf(
109
112
`
···
128
131
%s
129
132
`,
130
133
whereClause,
131
-
pagination,
134
+
pageClause,
132
135
)
133
136
134
137
rows, err := e.Query(query, args...)
···
243
246
return issues, nil
244
247
}
245
248
249
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
250
+
issues, err := GetIssuesPaginated(
251
+
e,
252
+
pagination.Page{},
253
+
FilterEq("repo_at", repoAt),
254
+
FilterEq("issue_id", issueId),
255
+
)
256
+
if err != nil {
257
+
return nil, err
258
+
}
259
+
if len(issues) != 1 {
260
+
return nil, sql.ErrNoRows
261
+
}
262
+
263
+
return &issues[0], nil
264
+
}
265
+
246
266
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
-
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
267
+
return GetIssuesPaginated(e, pagination.Page{}, filters...)
248
268
}
249
269
250
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
-
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
-
row := e.QueryRow(query, repoAt, issueId)
270
+
// GetIssueIDs gets list of all existing issue's IDs
271
+
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
272
+
var ids []int64
273
+
274
+
var filters []filter
275
+
openValue := 0
276
+
if opts.IsOpen {
277
+
openValue = 1
278
+
}
279
+
filters = append(filters, FilterEq("open", openValue))
280
+
if opts.RepoAt != "" {
281
+
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
282
+
}
283
+
284
+
var conditions []string
285
+
var args []any
286
+
287
+
for _, filter := range filters {
288
+
conditions = append(conditions, filter.Condition())
289
+
args = append(args, filter.Arg()...)
290
+
}
253
291
254
-
var issue models.Issue
255
-
var createdAt string
256
-
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
292
+
whereClause := ""
293
+
if conditions != nil {
294
+
whereClause = " where " + strings.Join(conditions, " and ")
295
+
}
296
+
query := fmt.Sprintf(
297
+
`
298
+
select
299
+
id
300
+
from
301
+
issues
302
+
%s
303
+
limit ? offset ?`,
304
+
whereClause,
305
+
)
306
+
args = append(args, opts.Page.Limit, opts.Page.Offset)
307
+
rows, err := e.Query(query, args...)
257
308
if err != nil {
258
309
return nil, err
259
310
}
311
+
defer rows.Close()
260
312
261
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
262
-
if err != nil {
263
-
return nil, err
313
+
for rows.Next() {
314
+
var id int64
315
+
err := rows.Scan(&id)
316
+
if err != nil {
317
+
return nil, err
318
+
}
319
+
320
+
ids = append(ids, id)
264
321
}
265
-
issue.Created = createdTime
266
322
267
-
return &issue, nil
323
+
return ids, nil
268
324
}
269
325
270
326
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+103
-54
appview/db/notifications.go
+103
-54
appview/db/notifications.go
···
8
8
"strings"
9
9
"time"
10
10
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
12
"tangled.org/core/appview/models"
12
13
"tangled.org/core/appview/pagination"
13
14
)
14
15
15
-
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
func CreateNotification(e Execer, notification *models.Notification) error {
16
17
query := `
17
18
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
18
19
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19
20
`
20
21
21
-
result, err := d.DB.ExecContext(ctx, query,
22
+
result, err := e.Exec(query,
22
23
notification.RecipientDid,
23
24
notification.ActorDid,
24
25
string(notification.Type),
···
58
59
for _, condition := range conditions[1:] {
59
60
whereClause += " AND " + condition
60
61
}
62
+
}
63
+
pageClause := ""
64
+
if page.Limit > 0 {
65
+
pageClause = " limit ? offset ? "
66
+
args = append(args, page.Limit, page.Offset)
61
67
}
62
68
63
69
query := fmt.Sprintf(`
···
65
71
from notifications
66
72
%s
67
73
order by created desc
68
-
limit ? offset ?
69
-
`, whereClause)
70
-
71
-
args = append(args, page.Limit, page.Offset)
74
+
%s
75
+
`, whereClause, pageClause)
72
76
73
77
rows, err := e.QueryContext(context.Background(), query, args...)
74
78
if err != nil {
···
130
134
select
131
135
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
132
136
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
133
-
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
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,
134
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,
135
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
136
140
from notifications n
···
159
163
var issue models.Issue
160
164
var pull models.Pull
161
165
var rId, iId, pId sql.NullInt64
162
-
var rDid, rName, rDescription sql.NullString
166
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
163
167
var iDid sql.NullString
164
168
var iIssueId sql.NullInt64
165
169
var iTitle sql.NullString
···
172
176
err := rows.Scan(
173
177
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
174
178
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
175
-
&rId, &rDid, &rName, &rDescription,
179
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
176
180
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
177
181
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
178
182
)
···
199
203
}
200
204
if rDescription.Valid {
201
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)
202
212
}
203
213
nwe.Repo = &repo
204
214
}
···
274
284
return count, nil
275
285
}
276
286
277
-
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
287
+
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
278
288
idFilter := FilterEq("id", notificationID)
279
289
recipientFilter := FilterEq("recipient_did", userDID)
280
290
···
286
296
287
297
args := append(idFilter.Arg(), recipientFilter.Arg()...)
288
298
289
-
result, err := d.DB.ExecContext(ctx, query, args...)
299
+
result, err := e.Exec(query, args...)
290
300
if err != nil {
291
301
return fmt.Errorf("failed to mark notification as read: %w", err)
292
302
}
···
303
313
return nil
304
314
}
305
315
306
-
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
316
+
func MarkAllNotificationsRead(e Execer, userDID string) error {
307
317
recipientFilter := FilterEq("recipient_did", userDID)
308
318
readFilter := FilterEq("read", 0)
309
319
···
315
325
316
326
args := append(recipientFilter.Arg(), readFilter.Arg()...)
317
327
318
-
_, err := d.DB.ExecContext(ctx, query, args...)
328
+
_, err := e.Exec(query, args...)
319
329
if err != nil {
320
330
return fmt.Errorf("failed to mark all notifications as read: %w", err)
321
331
}
···
323
333
return nil
324
334
}
325
335
326
-
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
336
+
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
327
337
idFilter := FilterEq("id", notificationID)
328
338
recipientFilter := FilterEq("recipient_did", userDID)
329
339
···
334
344
335
345
args := append(idFilter.Arg(), recipientFilter.Arg()...)
336
346
337
-
result, err := d.DB.ExecContext(ctx, query, args...)
347
+
result, err := e.Exec(query, args...)
338
348
if err != nil {
339
349
return fmt.Errorf("failed to delete notification: %w", err)
340
350
}
···
351
361
return nil
352
362
}
353
363
354
-
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
-
userFilter := FilterEq("user_did", userDID)
364
+
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
365
+
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
366
+
if err != nil {
367
+
return nil, err
368
+
}
369
+
370
+
p, ok := prefs[syntax.DID(userDid)]
371
+
if !ok {
372
+
return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil
373
+
}
374
+
375
+
return p, nil
376
+
}
377
+
378
+
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
+
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
380
+
381
+
var conditions []string
382
+
var args []any
383
+
for _, filter := range filters {
384
+
conditions = append(conditions, filter.Condition())
385
+
args = append(args, filter.Arg()...)
386
+
}
387
+
388
+
whereClause := ""
389
+
if conditions != nil {
390
+
whereClause = " where " + strings.Join(conditions, " and ")
391
+
}
356
392
357
393
query := fmt.Sprintf(`
358
-
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
-
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
-
FROM notification_preferences
361
-
WHERE %s
362
-
`, userFilter.Condition())
394
+
select
395
+
id,
396
+
user_did,
397
+
repo_starred,
398
+
issue_created,
399
+
issue_commented,
400
+
pull_created,
401
+
pull_commented,
402
+
followed,
403
+
user_mentioned,
404
+
pull_merged,
405
+
issue_closed,
406
+
email_notifications
407
+
from
408
+
notification_preferences
409
+
%s
410
+
`, whereClause)
363
411
364
-
var prefs models.NotificationPreferences
365
-
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
-
&prefs.ID,
367
-
&prefs.UserDid,
368
-
&prefs.RepoStarred,
369
-
&prefs.IssueCreated,
370
-
&prefs.IssueCommented,
371
-
&prefs.PullCreated,
372
-
&prefs.PullCommented,
373
-
&prefs.Followed,
374
-
&prefs.PullMerged,
375
-
&prefs.IssueClosed,
376
-
&prefs.EmailNotifications,
377
-
)
378
-
412
+
rows, err := e.Query(query, args...)
379
413
if err != nil {
380
-
if err == sql.ErrNoRows {
381
-
return &models.NotificationPreferences{
382
-
UserDid: userDID,
383
-
RepoStarred: true,
384
-
IssueCreated: true,
385
-
IssueCommented: true,
386
-
PullCreated: true,
387
-
PullCommented: true,
388
-
Followed: true,
389
-
PullMerged: true,
390
-
IssueClosed: true,
391
-
EmailNotifications: false,
392
-
}, nil
414
+
return nil, err
415
+
}
416
+
defer rows.Close()
417
+
418
+
for rows.Next() {
419
+
var prefs models.NotificationPreferences
420
+
if err := rows.Scan(
421
+
&prefs.ID,
422
+
&prefs.UserDid,
423
+
&prefs.RepoStarred,
424
+
&prefs.IssueCreated,
425
+
&prefs.IssueCommented,
426
+
&prefs.PullCreated,
427
+
&prefs.PullCommented,
428
+
&prefs.Followed,
429
+
&prefs.UserMentioned,
430
+
&prefs.PullMerged,
431
+
&prefs.IssueClosed,
432
+
&prefs.EmailNotifications,
433
+
); err != nil {
434
+
return nil, err
393
435
}
394
-
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
436
+
437
+
prefsMap[prefs.UserDid] = &prefs
438
+
}
439
+
440
+
if err := rows.Err(); err != nil {
441
+
return nil, err
395
442
}
396
443
397
-
return &prefs, nil
444
+
return prefsMap, nil
398
445
}
399
446
400
447
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
401
448
query := `
402
449
INSERT OR REPLACE INTO notification_preferences
403
450
(user_did, repo_starred, issue_created, issue_commented, pull_created,
404
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
405
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
+
email_notifications)
453
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406
454
`
407
455
408
456
result, err := d.DB.ExecContext(ctx, query,
···
413
461
prefs.PullCreated,
414
462
prefs.PullCommented,
415
463
prefs.Followed,
464
+
prefs.UserMentioned,
416
465
prefs.PullMerged,
417
466
prefs.IssueClosed,
418
467
prefs.EmailNotifications,
+26
-6
appview/db/profile.go
+26
-6
appview/db/profile.go
···
129
129
did,
130
130
description,
131
131
include_bluesky,
132
-
location
132
+
location,
133
+
pronouns
133
134
)
134
-
values (?, ?, ?, ?)`,
135
+
values (?, ?, ?, ?, ?)`,
135
136
profile.Did,
136
137
profile.Description,
137
138
includeBskyValue,
138
139
profile.Location,
140
+
profile.Pronouns,
139
141
)
140
142
141
143
if err != nil {
···
216
218
did,
217
219
description,
218
220
include_bluesky,
219
-
location
221
+
location,
222
+
pronouns
220
223
from
221
224
profile
222
225
%s`,
···
231
234
for rows.Next() {
232
235
var profile models.Profile
233
236
var includeBluesky int
237
+
var pronouns sql.Null[string]
234
238
235
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
239
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
236
240
if err != nil {
237
241
return nil, err
238
242
}
239
243
240
244
if includeBluesky != 0 {
241
245
profile.IncludeBluesky = true
246
+
}
247
+
248
+
if pronouns.Valid {
249
+
profile.Pronouns = pronouns.V
242
250
}
243
251
244
252
profileMap[profile.Did] = &profile
···
302
310
303
311
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
312
var profile models.Profile
313
+
var pronouns sql.Null[string]
314
+
305
315
profile.Did = did
306
316
307
317
includeBluesky := 0
318
+
308
319
err := e.QueryRow(
309
-
`select description, include_bluesky, location from profile where did = ?`,
320
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
310
321
did,
311
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
322
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
312
323
if err == sql.ErrNoRows {
313
324
profile := models.Profile{}
314
325
profile.Did = did
···
321
332
322
333
if includeBluesky != 0 {
323
334
profile.IncludeBluesky = true
335
+
}
336
+
337
+
if pronouns.Valid {
338
+
profile.Pronouns = pronouns.V
324
339
}
325
340
326
341
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
412
427
// ensure description is not too long
413
428
if len(profile.Location) > 40 {
414
429
return fmt.Errorf("Entered location is too long.")
430
+
}
431
+
432
+
// ensure pronouns are not too long
433
+
if len(profile.Pronouns) > 40 {
434
+
return fmt.Errorf("Entered pronouns are too long.")
415
435
}
416
436
417
437
// ensure links are in order
+86
-24
appview/db/pulls.go
+86
-24
appview/db/pulls.go
···
90
90
pull.ID = int(id)
91
91
92
92
_, err = tx.Exec(`
93
-
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
-
values (?, ?, ?, ?)
95
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
93
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
+
values (?, ?, ?, ?, ?)
95
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
96
return err
97
97
}
98
98
···
101
101
if err != nil {
102
102
return "", err
103
103
}
104
-
return pull.PullAt(), err
104
+
return pull.AtUri(), err
105
105
}
106
106
107
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
214
214
pull.ParentChangeId = parentChangeId.String
215
215
}
216
216
217
-
pulls[pull.PullAt()] = &pull
217
+
pulls[pull.AtUri()] = &pull
218
218
}
219
219
220
220
var pullAts []syntax.ATURI
221
221
for _, p := range pulls {
222
-
pullAts = append(pullAts, p.PullAt())
222
+
pullAts = append(pullAts, p.AtUri())
223
223
}
224
224
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
225
if err != nil {
···
281
281
return GetPullsWithLimit(e, 0, filters...)
282
282
}
283
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
+
284
345
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
346
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
286
347
if err != nil {
287
348
return nil, err
288
349
}
289
-
if pulls == nil {
350
+
if len(pulls) == 0 {
290
351
return nil, sql.ErrNoRows
291
352
}
292
353
···
313
374
pull_at,
314
375
round_number,
315
376
patch,
377
+
combined,
316
378
created,
317
379
source_rev
318
380
from
···
332
394
333
395
for rows.Next() {
334
396
var submission models.PullSubmission
335
-
var createdAt string
336
-
var sourceRev sql.NullString
397
+
var submissionCreatedStr string
398
+
var submissionSourceRev, submissionCombined sql.NullString
337
399
err := rows.Scan(
338
400
&submission.ID,
339
401
&submission.PullAt,
340
402
&submission.RoundNumber,
341
403
&submission.Patch,
342
-
&createdAt,
343
-
&sourceRev,
404
+
&submissionCombined,
405
+
&submissionCreatedStr,
406
+
&submissionSourceRev,
344
407
)
345
408
if err != nil {
346
409
return nil, err
347
410
}
348
411
349
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
-
if err != nil {
351
-
return nil, err
412
+
if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil {
413
+
submission.Created = t
414
+
}
415
+
416
+
if submissionSourceRev.Valid {
417
+
submission.SourceRev = submissionSourceRev.String
352
418
}
353
-
submission.Created = createdTime
354
419
355
-
if sourceRev.Valid {
356
-
submission.SourceRev = sourceRev.String
420
+
if submissionCombined.Valid {
421
+
submission.Combined = submissionCombined.String
357
422
}
358
423
359
424
submissionMap[submission.ID] = &submission
···
590
655
return err
591
656
}
592
657
593
-
func ResubmitPull(e Execer, pull *models.Pull) error {
594
-
newPatch := pull.LatestPatch()
595
-
newSourceRev := pull.LatestSha()
596
-
newRoundNumber := len(pull.Submissions)
658
+
func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error {
597
659
_, err := e.Exec(`
598
-
insert into pull_submissions (pull_at, round_number, patch, source_rev)
599
-
values (?, ?, ?, ?)
600
-
`, pull.PullAt(), newRoundNumber, newPatch, newSourceRev)
660
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
661
+
values (?, ?, ?, ?, ?)
662
+
`, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
601
663
602
664
return err
603
665
}
+50
-12
appview/db/repos.go
+50
-12
appview/db/repos.go
···
70
70
rkey,
71
71
created,
72
72
description,
73
+
website,
74
+
topics,
73
75
source,
74
76
spindle
75
77
from
···
89
91
for rows.Next() {
90
92
var repo models.Repo
91
93
var createdAt string
92
-
var description, source, spindle sql.NullString
94
+
var description, website, topicStr, source, spindle sql.NullString
93
95
94
96
err := rows.Scan(
95
97
&repo.Id,
···
99
101
&repo.Rkey,
100
102
&createdAt,
101
103
&description,
104
+
&website,
105
+
&topicStr,
102
106
&source,
103
107
&spindle,
104
108
)
···
111
115
}
112
116
if description.Valid {
113
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)
114
124
}
115
125
if source.Valid {
116
126
repo.Source = source.String
···
356
366
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
367
var repo models.Repo
358
368
var nullableDescription sql.NullString
369
+
var nullableWebsite sql.NullString
370
+
var nullableTopicStr sql.NullString
359
371
360
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
372
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
361
373
362
374
var createdAt string
363
-
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
375
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
364
376
return nil, err
365
377
}
366
378
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
368
380
369
381
if nullableDescription.Valid {
370
382
repo.Description = nullableDescription.String
371
-
} else {
372
-
repo.Description = ""
383
+
}
384
+
if nullableWebsite.Valid {
385
+
repo.Website = nullableWebsite.String
386
+
}
387
+
if nullableTopicStr.Valid {
388
+
repo.Topics = strings.Fields(nullableTopicStr.String)
373
389
}
374
390
375
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
376
403
}
377
404
378
405
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
406
_, err := tx.Exec(
380
407
`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,
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,
384
411
)
385
412
if err != nil {
386
413
return fmt.Errorf("failed to insert repo: %w", err)
···
416
443
var repos []models.Repo
417
444
418
445
rows, err := e.Query(
419
-
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
446
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
420
447
from repos r
421
448
left join collaborators c on r.at_uri = c.repo_at
422
449
where (r.did = ? or c.subject_did = ?)
···
434
461
var repo models.Repo
435
462
var createdAt string
436
463
var nullableDescription sql.NullString
464
+
var nullableWebsite sql.NullString
437
465
var nullableSource sql.NullString
438
466
439
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
467
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
440
468
if err != nil {
441
469
return nil, err
442
470
}
···
470
498
var repo models.Repo
471
499
var createdAt string
472
500
var nullableDescription sql.NullString
501
+
var nullableWebsite sql.NullString
502
+
var nullableTopicStr sql.NullString
473
503
var nullableSource sql.NullString
474
504
475
505
row := e.QueryRow(
476
-
`select id, did, name, knot, rkey, description, created, source
506
+
`select id, did, name, knot, rkey, description, website, topics, created, source
477
507
from repos
478
508
where did = ? and name = ? and source is not null and source != ''`,
479
509
did, name,
480
510
)
481
511
482
-
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
512
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
483
513
if err != nil {
484
514
return nil, err
485
515
}
486
516
487
517
if nullableDescription.Valid {
488
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)
489
527
}
490
528
491
529
if nullableSource.Valid {
+4
-4
appview/dns/cloudflare.go
+4
-4
appview/dns/cloudflare.go
···
30
30
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
31
}
32
32
33
-
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
-
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) {
34
+
result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
35
Type: record.Type,
36
36
Name: record.Name,
37
37
Content: record.Content,
···
39
39
Proxied: &record.Proxied,
40
40
})
41
41
if err != nil {
42
-
return fmt.Errorf("failed to create DNS record: %w", err)
42
+
return "", fmt.Errorf("failed to create DNS record: %w", err)
43
43
}
44
-
return nil
44
+
return result.ID, nil
45
45
}
46
46
47
47
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+20
appview/indexer/base36/base36.go
+20
appview/indexer/base36/base36.go
···
1
+
// mostly copied from gitea/modules/indexer/internal/base32
2
+
3
+
package base36
4
+
5
+
import (
6
+
"fmt"
7
+
"strconv"
8
+
)
9
+
10
+
func Encode(i int64) string {
11
+
return strconv.FormatInt(i, 36)
12
+
}
13
+
14
+
func Decode(s string) (int64, error) {
15
+
i, err := strconv.ParseInt(s, 36, 64)
16
+
if err != nil {
17
+
return 0, fmt.Errorf("invalid base36 integer %q: %w", s, err)
18
+
}
19
+
return i, nil
20
+
}
+58
appview/indexer/bleve/batch.go
+58
appview/indexer/bleve/batch.go
···
1
+
// Copyright 2021 The Gitea Authors. All rights reserved.
2
+
// SPDX-License-Identifier: MIT
3
+
4
+
package bleveutil
5
+
6
+
import (
7
+
"github.com/blevesearch/bleve/v2"
8
+
)
9
+
10
+
// FlushingBatch is a batch of operations that automatically flushes to the
11
+
// underlying index once it reaches a certain size.
12
+
type FlushingBatch struct {
13
+
maxBatchSize int
14
+
batch *bleve.Batch
15
+
index bleve.Index
16
+
}
17
+
18
+
// NewFlushingBatch creates a new flushing batch for the specified index. Once
19
+
// the number of operations in the batch reaches the specified limit, the batch
20
+
// automatically flushes its operations to the index.
21
+
func NewFlushingBatch(index bleve.Index, maxBatchSize int) *FlushingBatch {
22
+
return &FlushingBatch{
23
+
maxBatchSize: maxBatchSize,
24
+
batch: index.NewBatch(),
25
+
index: index,
26
+
}
27
+
}
28
+
29
+
// Index add a new index to batch
30
+
func (b *FlushingBatch) Index(id string, data any) error {
31
+
if err := b.batch.Index(id, data); err != nil {
32
+
return err
33
+
}
34
+
return b.flushIfFull()
35
+
}
36
+
37
+
// Delete add a delete index to batch
38
+
func (b *FlushingBatch) Delete(id string) error {
39
+
b.batch.Delete(id)
40
+
return b.flushIfFull()
41
+
}
42
+
43
+
func (b *FlushingBatch) flushIfFull() error {
44
+
if b.batch.Size() < b.maxBatchSize {
45
+
return nil
46
+
}
47
+
return b.Flush()
48
+
}
49
+
50
+
// Flush submit the batch and create a new one
51
+
func (b *FlushingBatch) Flush() error {
52
+
err := b.index.Batch(b.batch)
53
+
if err != nil {
54
+
return err
55
+
}
56
+
b.batch = b.index.NewBatch()
57
+
return nil
58
+
}
+26
appview/indexer/bleve/query.go
+26
appview/indexer/bleve/query.go
···
1
+
package bleveutil
2
+
3
+
import (
4
+
"github.com/blevesearch/bleve/v2"
5
+
"github.com/blevesearch/bleve/v2/search/query"
6
+
)
7
+
8
+
func MatchAndQuery(field, keyword, analyzer string, fuzziness int) query.Query {
9
+
q := bleve.NewMatchQuery(keyword)
10
+
q.FieldVal = field
11
+
q.Analyzer = analyzer
12
+
q.Fuzziness = fuzziness
13
+
return q
14
+
}
15
+
16
+
func BoolFieldQuery(field string, val bool) query.Query {
17
+
q := bleve.NewBoolFieldQuery(val)
18
+
q.FieldVal = field
19
+
return q
20
+
}
21
+
22
+
func KeywordFieldQuery(field, keyword string) query.Query {
23
+
q := bleve.NewTermQuery(keyword)
24
+
q.FieldVal = field
25
+
return q
26
+
}
+36
appview/indexer/indexer.go
+36
appview/indexer/indexer.go
···
1
+
package indexer
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
7
+
"tangled.org/core/appview/db"
8
+
issues_indexer "tangled.org/core/appview/indexer/issues"
9
+
pulls_indexer "tangled.org/core/appview/indexer/pulls"
10
+
"tangled.org/core/appview/notify"
11
+
tlog "tangled.org/core/log"
12
+
)
13
+
14
+
type Indexer struct {
15
+
Issues *issues_indexer.Indexer
16
+
Pulls *pulls_indexer.Indexer
17
+
logger *slog.Logger
18
+
notify.BaseNotifier
19
+
}
20
+
21
+
func New(logger *slog.Logger) *Indexer {
22
+
return &Indexer{
23
+
issues_indexer.NewIndexer("indexes/issues.bleve"),
24
+
pulls_indexer.NewIndexer("indexes/pulls.bleve"),
25
+
logger,
26
+
notify.BaseNotifier{},
27
+
}
28
+
}
29
+
30
+
// Init initializes all indexers
31
+
func (ix *Indexer) Init(ctx context.Context, db *db.DB) error {
32
+
ctx = tlog.IntoContext(ctx, ix.logger)
33
+
ix.Issues.Init(ctx, db)
34
+
ix.Pulls.Init(ctx, db)
35
+
return nil
36
+
}
+255
appview/indexer/issues/indexer.go
+255
appview/indexer/issues/indexer.go
···
1
+
// heavily inspired by gitea's model (basically copy-pasted)
2
+
package issues_indexer
3
+
4
+
import (
5
+
"context"
6
+
"errors"
7
+
"log"
8
+
"os"
9
+
10
+
"github.com/blevesearch/bleve/v2"
11
+
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
12
+
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
13
+
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
14
+
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
15
+
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
16
+
"github.com/blevesearch/bleve/v2/index/upsidedown"
17
+
"github.com/blevesearch/bleve/v2/mapping"
18
+
"github.com/blevesearch/bleve/v2/search/query"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/indexer/base36"
21
+
"tangled.org/core/appview/indexer/bleve"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/pagination"
24
+
tlog "tangled.org/core/log"
25
+
)
26
+
27
+
const (
28
+
issueIndexerAnalyzer = "issueIndexer"
29
+
issueIndexerDocType = "issueIndexerDocType"
30
+
31
+
unicodeNormalizeName = "uicodeNormalize"
32
+
)
33
+
34
+
type Indexer struct {
35
+
indexer bleve.Index
36
+
path string
37
+
}
38
+
39
+
func NewIndexer(indexDir string) *Indexer {
40
+
return &Indexer{
41
+
path: indexDir,
42
+
}
43
+
}
44
+
45
+
// Init initializes the indexer
46
+
func (ix *Indexer) Init(ctx context.Context, e db.Execer) {
47
+
l := tlog.FromContext(ctx)
48
+
existed, err := ix.intialize(ctx)
49
+
if err != nil {
50
+
log.Fatalln("failed to initialize issue indexer", err)
51
+
}
52
+
if !existed {
53
+
l.Debug("Populating the issue indexer")
54
+
err := PopulateIndexer(ctx, ix, e)
55
+
if err != nil {
56
+
log.Fatalln("failed to populate issue indexer", err)
57
+
}
58
+
}
59
+
l.Info("Initialized the issue indexer")
60
+
}
61
+
62
+
func generateIssueIndexMapping() (mapping.IndexMapping, error) {
63
+
mapping := bleve.NewIndexMapping()
64
+
docMapping := bleve.NewDocumentMapping()
65
+
66
+
textFieldMapping := bleve.NewTextFieldMapping()
67
+
textFieldMapping.Store = false
68
+
textFieldMapping.IncludeInAll = false
69
+
70
+
boolFieldMapping := bleve.NewBooleanFieldMapping()
71
+
boolFieldMapping.Store = false
72
+
boolFieldMapping.IncludeInAll = false
73
+
74
+
keywordFieldMapping := bleve.NewKeywordFieldMapping()
75
+
keywordFieldMapping.Store = false
76
+
keywordFieldMapping.IncludeInAll = false
77
+
78
+
// numericFieldMapping := bleve.NewNumericFieldMapping()
79
+
80
+
docMapping.AddFieldMappingsAt("title", textFieldMapping)
81
+
docMapping.AddFieldMappingsAt("body", textFieldMapping)
82
+
83
+
docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping)
84
+
docMapping.AddFieldMappingsAt("is_open", boolFieldMapping)
85
+
86
+
err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
87
+
"type": unicodenorm.Name,
88
+
"form": unicodenorm.NFC,
89
+
})
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
94
+
err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{
95
+
"type": custom.Name,
96
+
"char_filters": []string{},
97
+
"tokenizer": unicode.Name,
98
+
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
99
+
})
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
mapping.DefaultAnalyzer = issueIndexerAnalyzer
105
+
mapping.AddDocumentMapping(issueIndexerDocType, docMapping)
106
+
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
107
+
mapping.DefaultMapping = bleve.NewDocumentDisabledMapping()
108
+
109
+
return mapping, nil
110
+
}
111
+
112
+
func (ix *Indexer) intialize(ctx context.Context) (bool, error) {
113
+
if ix.indexer != nil {
114
+
return false, errors.New("indexer is already initialized")
115
+
}
116
+
117
+
indexer, err := openIndexer(ctx, ix.path)
118
+
if err != nil {
119
+
return false, err
120
+
}
121
+
if indexer != nil {
122
+
ix.indexer = indexer
123
+
return true, nil
124
+
}
125
+
126
+
mapping, err := generateIssueIndexMapping()
127
+
if err != nil {
128
+
return false, err
129
+
}
130
+
indexer, err = bleve.New(ix.path, mapping)
131
+
if err != nil {
132
+
return false, err
133
+
}
134
+
135
+
ix.indexer = indexer
136
+
137
+
return false, nil
138
+
}
139
+
140
+
func openIndexer(ctx context.Context, path string) (bleve.Index, error) {
141
+
l := tlog.FromContext(ctx)
142
+
indexer, err := bleve.Open(path)
143
+
if err != nil {
144
+
if errors.Is(err, upsidedown.IncompatibleVersion) {
145
+
l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding")
146
+
return nil, os.RemoveAll(path)
147
+
}
148
+
return nil, nil
149
+
}
150
+
return indexer, nil
151
+
}
152
+
153
+
func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error {
154
+
l := tlog.FromContext(ctx)
155
+
count := 0
156
+
err := pagination.IterateAll(
157
+
func(page pagination.Page) ([]models.Issue, error) {
158
+
return db.GetIssuesPaginated(e, page)
159
+
},
160
+
func(issues []models.Issue) error {
161
+
count += len(issues)
162
+
return ix.Index(ctx, issues...)
163
+
},
164
+
)
165
+
l.Info("issues indexed", "count", count)
166
+
return err
167
+
}
168
+
169
+
// issueData data stored and will be indexed
170
+
type issueData struct {
171
+
ID int64 `json:"id"`
172
+
RepoAt string `json:"repo_at"`
173
+
IssueID int `json:"issue_id"`
174
+
Title string `json:"title"`
175
+
Body string `json:"body"`
176
+
177
+
IsOpen bool `json:"is_open"`
178
+
Comments []IssueCommentData `json:"comments"`
179
+
}
180
+
181
+
func makeIssueData(issue *models.Issue) *issueData {
182
+
return &issueData{
183
+
ID: issue.Id,
184
+
RepoAt: issue.RepoAt.String(),
185
+
IssueID: issue.IssueId,
186
+
Title: issue.Title,
187
+
Body: issue.Body,
188
+
IsOpen: issue.Open,
189
+
}
190
+
}
191
+
192
+
// Type returns the document type, for bleve's mapping.Classifier interface.
193
+
func (i *issueData) Type() string {
194
+
return issueIndexerDocType
195
+
}
196
+
197
+
type IssueCommentData struct {
198
+
Body string `json:"body"`
199
+
}
200
+
201
+
type SearchResult struct {
202
+
Hits []int64
203
+
Total uint64
204
+
}
205
+
206
+
const maxBatchSize = 20
207
+
208
+
func (ix *Indexer) Index(ctx context.Context, issues ...models.Issue) error {
209
+
batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize)
210
+
for _, issue := range issues {
211
+
issueData := makeIssueData(&issue)
212
+
if err := batch.Index(base36.Encode(issue.Id), issueData); err != nil {
213
+
return err
214
+
}
215
+
}
216
+
return batch.Flush()
217
+
}
218
+
219
+
func (ix *Indexer) Delete(ctx context.Context, issueId int64) error {
220
+
return ix.indexer.Delete(base36.Encode(issueId))
221
+
}
222
+
223
+
// Search searches for issues
224
+
func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) {
225
+
var queries []query.Query
226
+
227
+
if opts.Keyword != "" {
228
+
queries = append(queries, bleve.NewDisjunctionQuery(
229
+
bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0),
230
+
bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0),
231
+
))
232
+
}
233
+
queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt))
234
+
queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen))
235
+
// TODO: append more queries
236
+
237
+
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
238
+
searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false)
239
+
res, err := ix.indexer.SearchInContext(ctx, searchReq)
240
+
if err != nil {
241
+
return nil, nil
242
+
}
243
+
ret := &SearchResult{
244
+
Total: res.Total,
245
+
Hits: make([]int64, len(res.Hits)),
246
+
}
247
+
for i, hit := range res.Hits {
248
+
id, err := base36.Decode(hit.ID)
249
+
if err != nil {
250
+
return nil, err
251
+
}
252
+
ret.Hits[i] = id
253
+
}
254
+
return ret, nil
255
+
}
+57
appview/indexer/notifier.go
+57
appview/indexer/notifier.go
···
1
+
package indexer
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
8
+
"tangled.org/core/appview/notify"
9
+
"tangled.org/core/log"
10
+
)
11
+
12
+
var _ notify.Notifier = &Indexer{}
13
+
14
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
15
+
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
16
+
l.Debug("indexing new issue")
17
+
err := ix.Issues.Index(ctx, *issue)
18
+
if err != nil {
19
+
l.Error("failed to index an issue", "err", err)
20
+
}
21
+
}
22
+
23
+
func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
24
+
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
25
+
l.Debug("updating an issue")
26
+
err := ix.Issues.Index(ctx, *issue)
27
+
if err != nil {
28
+
l.Error("failed to index an issue", "err", err)
29
+
}
30
+
}
31
+
32
+
func (ix *Indexer) DeleteIssue(ctx context.Context, issue *models.Issue) {
33
+
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
34
+
l.Debug("deleting an issue")
35
+
err := ix.Issues.Delete(ctx, issue.Id)
36
+
if err != nil {
37
+
l.Error("failed to delete an issue", "err", err)
38
+
}
39
+
}
40
+
41
+
func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) {
42
+
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
43
+
l.Debug("indexing new pr")
44
+
err := ix.Pulls.Index(ctx, pull)
45
+
if err != nil {
46
+
l.Error("failed to index a pr", "err", err)
47
+
}
48
+
}
49
+
50
+
func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
51
+
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
52
+
l.Debug("updating a pr")
53
+
err := ix.Pulls.Index(ctx, pull)
54
+
if err != nil {
55
+
l.Error("failed to index a pr", "err", err)
56
+
}
57
+
}
+255
appview/indexer/pulls/indexer.go
+255
appview/indexer/pulls/indexer.go
···
1
+
// heavily inspired by gitea's model (basically copy-pasted)
2
+
package pulls_indexer
3
+
4
+
import (
5
+
"context"
6
+
"errors"
7
+
"log"
8
+
"os"
9
+
10
+
"github.com/blevesearch/bleve/v2"
11
+
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
12
+
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
13
+
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
14
+
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
15
+
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
16
+
"github.com/blevesearch/bleve/v2/index/upsidedown"
17
+
"github.com/blevesearch/bleve/v2/mapping"
18
+
"github.com/blevesearch/bleve/v2/search/query"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/indexer/base36"
21
+
"tangled.org/core/appview/indexer/bleve"
22
+
"tangled.org/core/appview/models"
23
+
tlog "tangled.org/core/log"
24
+
)
25
+
26
+
const (
27
+
pullIndexerAnalyzer = "pullIndexer"
28
+
pullIndexerDocType = "pullIndexerDocType"
29
+
30
+
unicodeNormalizeName = "uicodeNormalize"
31
+
)
32
+
33
+
type Indexer struct {
34
+
indexer bleve.Index
35
+
path string
36
+
}
37
+
38
+
func NewIndexer(indexDir string) *Indexer {
39
+
return &Indexer{
40
+
path: indexDir,
41
+
}
42
+
}
43
+
44
+
// Init initializes the indexer
45
+
func (ix *Indexer) Init(ctx context.Context, e db.Execer) {
46
+
l := tlog.FromContext(ctx)
47
+
existed, err := ix.intialize(ctx)
48
+
if err != nil {
49
+
log.Fatalln("failed to initialize pull indexer", err)
50
+
}
51
+
if !existed {
52
+
l.Debug("Populating the pull indexer")
53
+
err := PopulateIndexer(ctx, ix, e)
54
+
if err != nil {
55
+
log.Fatalln("failed to populate pull indexer", err)
56
+
}
57
+
}
58
+
l.Info("Initialized the pull indexer")
59
+
}
60
+
61
+
func generatePullIndexMapping() (mapping.IndexMapping, error) {
62
+
mapping := bleve.NewIndexMapping()
63
+
docMapping := bleve.NewDocumentMapping()
64
+
65
+
textFieldMapping := bleve.NewTextFieldMapping()
66
+
textFieldMapping.Store = false
67
+
textFieldMapping.IncludeInAll = false
68
+
69
+
keywordFieldMapping := bleve.NewKeywordFieldMapping()
70
+
keywordFieldMapping.Store = false
71
+
keywordFieldMapping.IncludeInAll = false
72
+
73
+
// numericFieldMapping := bleve.NewNumericFieldMapping()
74
+
75
+
docMapping.AddFieldMappingsAt("title", textFieldMapping)
76
+
docMapping.AddFieldMappingsAt("body", textFieldMapping)
77
+
78
+
docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping)
79
+
docMapping.AddFieldMappingsAt("state", keywordFieldMapping)
80
+
81
+
err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
82
+
"type": unicodenorm.Name,
83
+
"form": unicodenorm.NFC,
84
+
})
85
+
if err != nil {
86
+
return nil, err
87
+
}
88
+
89
+
err = mapping.AddCustomAnalyzer(pullIndexerAnalyzer, map[string]any{
90
+
"type": custom.Name,
91
+
"char_filters": []string{},
92
+
"tokenizer": unicode.Name,
93
+
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
94
+
})
95
+
if err != nil {
96
+
return nil, err
97
+
}
98
+
99
+
mapping.DefaultAnalyzer = pullIndexerAnalyzer
100
+
mapping.AddDocumentMapping(pullIndexerDocType, docMapping)
101
+
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
102
+
mapping.DefaultMapping = bleve.NewDocumentDisabledMapping()
103
+
104
+
return mapping, nil
105
+
}
106
+
107
+
func (ix *Indexer) intialize(ctx context.Context) (bool, error) {
108
+
if ix.indexer != nil {
109
+
return false, errors.New("indexer is already initialized")
110
+
}
111
+
112
+
indexer, err := openIndexer(ctx, ix.path)
113
+
if err != nil {
114
+
return false, err
115
+
}
116
+
if indexer != nil {
117
+
ix.indexer = indexer
118
+
return true, nil
119
+
}
120
+
121
+
mapping, err := generatePullIndexMapping()
122
+
if err != nil {
123
+
return false, err
124
+
}
125
+
indexer, err = bleve.New(ix.path, mapping)
126
+
if err != nil {
127
+
return false, err
128
+
}
129
+
130
+
ix.indexer = indexer
131
+
132
+
return false, nil
133
+
}
134
+
135
+
func openIndexer(ctx context.Context, path string) (bleve.Index, error) {
136
+
l := tlog.FromContext(ctx)
137
+
indexer, err := bleve.Open(path)
138
+
if err != nil {
139
+
if errors.Is(err, upsidedown.IncompatibleVersion) {
140
+
l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding")
141
+
return nil, os.RemoveAll(path)
142
+
}
143
+
return nil, nil
144
+
}
145
+
return indexer, nil
146
+
}
147
+
148
+
func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error {
149
+
l := tlog.FromContext(ctx)
150
+
151
+
pulls, err := db.GetPulls(e)
152
+
if err != nil {
153
+
return err
154
+
}
155
+
count := len(pulls)
156
+
err = ix.Index(ctx, pulls...)
157
+
if err != nil {
158
+
return err
159
+
}
160
+
l.Info("pulls indexed", "count", count)
161
+
return err
162
+
}
163
+
164
+
// pullData data stored and will be indexed
165
+
type pullData struct {
166
+
ID int64 `json:"id"`
167
+
RepoAt string `json:"repo_at"`
168
+
PullID int `json:"pull_id"`
169
+
Title string `json:"title"`
170
+
Body string `json:"body"`
171
+
State string `json:"state"`
172
+
173
+
Comments []pullCommentData `json:"comments"`
174
+
}
175
+
176
+
func makePullData(pull *models.Pull) *pullData {
177
+
return &pullData{
178
+
ID: int64(pull.ID),
179
+
RepoAt: pull.RepoAt.String(),
180
+
PullID: pull.PullId,
181
+
Title: pull.Title,
182
+
Body: pull.Body,
183
+
State: pull.State.String(),
184
+
}
185
+
}
186
+
187
+
// Type returns the document type, for bleve's mapping.Classifier interface.
188
+
func (i *pullData) Type() string {
189
+
return pullIndexerDocType
190
+
}
191
+
192
+
type pullCommentData struct {
193
+
Body string `json:"body"`
194
+
}
195
+
196
+
type searchResult struct {
197
+
Hits []int64
198
+
Total uint64
199
+
}
200
+
201
+
const maxBatchSize = 20
202
+
203
+
func (ix *Indexer) Index(ctx context.Context, pulls ...*models.Pull) error {
204
+
batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize)
205
+
for _, pull := range pulls {
206
+
pullData := makePullData(pull)
207
+
if err := batch.Index(base36.Encode(pullData.ID), pullData); err != nil {
208
+
return err
209
+
}
210
+
}
211
+
return batch.Flush()
212
+
}
213
+
214
+
func (ix *Indexer) Delete(ctx context.Context, pullID int64) error {
215
+
return ix.indexer.Delete(base36.Encode(pullID))
216
+
}
217
+
218
+
// Search searches for pulls
219
+
func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) {
220
+
var queries []query.Query
221
+
222
+
// TODO(boltless): remove this after implementing pulls page pagination
223
+
limit := opts.Page.Limit
224
+
if limit == 0 {
225
+
limit = 500
226
+
}
227
+
228
+
if opts.Keyword != "" {
229
+
queries = append(queries, bleve.NewDisjunctionQuery(
230
+
bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0),
231
+
bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0),
232
+
))
233
+
}
234
+
queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt))
235
+
queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String()))
236
+
237
+
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
238
+
searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false)
239
+
res, err := ix.indexer.SearchInContext(ctx, searchReq)
240
+
if err != nil {
241
+
return nil, nil
242
+
}
243
+
ret := &searchResult{
244
+
Total: res.Total,
245
+
Hits: make([]int64, len(res.Hits)),
246
+
}
247
+
for i, hit := range res.Hits {
248
+
id, err := base36.Decode(hit.ID)
249
+
if err != nil {
250
+
return nil, err
251
+
}
252
+
ret.Hits[i] = id
253
+
}
254
+
return ret, nil
255
+
}
+6
appview/ingester.go
+6
appview/ingester.go
···
291
291
292
292
includeBluesky := record.Bluesky
293
293
294
+
pronouns := ""
295
+
if record.Pronouns != nil {
296
+
pronouns = *record.Pronouns
297
+
}
298
+
294
299
location := ""
295
300
if record.Location != nil {
296
301
location = *record.Location
···
325
330
Links: links,
326
331
Stats: stats,
327
332
PinnedRepos: pinned,
333
+
Pronouns: pronouns,
328
334
}
329
335
330
336
ddb, ok := i.Db.Execer.(*db.DB)
+69
-15
appview/issues/issues.go
+69
-15
appview/issues/issues.go
···
19
19
"tangled.org/core/api/tangled"
20
20
"tangled.org/core/appview/config"
21
21
"tangled.org/core/appview/db"
22
+
issues_indexer "tangled.org/core/appview/indexer/issues"
22
23
"tangled.org/core/appview/models"
23
24
"tangled.org/core/appview/notify"
24
25
"tangled.org/core/appview/oauth"
25
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/markup"
26
28
"tangled.org/core/appview/pagination"
27
29
"tangled.org/core/appview/reporesolver"
28
30
"tangled.org/core/appview/validator"
···
40
42
notifier notify.Notifier
41
43
logger *slog.Logger
42
44
validator *validator.Validator
45
+
indexer *issues_indexer.Indexer
43
46
}
44
47
45
48
func New(
···
51
54
config *config.Config,
52
55
notifier notify.Notifier,
53
56
validator *validator.Validator,
57
+
indexer *issues_indexer.Indexer,
54
58
logger *slog.Logger,
55
59
) *Issues {
56
60
return &Issues{
···
63
67
notifier: notifier,
64
68
logger: logger,
65
69
validator: validator,
70
+
indexer: indexer,
66
71
}
67
72
}
68
73
···
259
264
return
260
265
}
261
266
267
+
rp.notifier.DeleteIssue(r.Context(), issue)
268
+
262
269
// return to all issues page
263
270
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
264
271
}
···
299
306
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
300
307
return
301
308
}
309
+
// change the issue state (this will pass down to the notifiers)
310
+
issue.Open = false
302
311
303
312
// notify about the issue closure
304
-
rp.notifier.NewIssueClosed(r.Context(), issue)
313
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
305
314
306
315
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
307
316
return
···
347
356
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
348
357
return
349
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
+
350
365
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
351
366
return
352
367
} else {
···
439
454
440
455
// notify about the new comment
441
456
comment.Id = commentId
442
-
rp.notifier.NewIssueComment(r.Context(), &comment)
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)
443
468
444
469
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
445
470
}
···
770
795
isOpen = true
771
796
}
772
797
773
-
page, ok := r.Context().Value("page").(pagination.Page)
774
-
if !ok {
775
-
l.Error("failed to get page")
776
-
page = pagination.FirstPage()
777
-
}
798
+
page := pagination.FromContext(r.Context())
778
799
779
800
user := rp.oauth.GetUser(r)
780
801
f, err := rp.repoResolver.Resolve(r)
···
783
804
return
784
805
}
785
806
786
-
openVal := 0
787
-
if isOpen {
788
-
openVal = 1
807
+
keyword := params.Get("q")
808
+
809
+
var ids []int64
810
+
searchOpts := models.IssueSearchOptions{
811
+
Keyword: keyword,
812
+
RepoAt: f.RepoAt().String(),
813
+
IsOpen: isOpen,
814
+
Page: page,
789
815
}
790
-
issues, err := db.GetIssuesPaginated(
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(
791
834
rp.db,
792
-
page,
793
-
db.FilterEq("repo_at", f.RepoAt()),
794
-
db.FilterEq("open", openVal),
835
+
db.FilterIn("id", ids),
795
836
)
796
837
if err != nil {
797
838
l.Error("failed to get issues", "err", err)
···
821
862
Issues: issues,
822
863
LabelDefs: defs,
823
864
FilteringByOpen: isOpen,
865
+
FilterQuery: keyword,
824
866
Page: page,
825
867
})
826
868
}
···
847
889
Rkey: tid.TID(),
848
890
Title: r.FormValue("title"),
849
891
Body: r.FormValue("body"),
892
+
Open: true,
850
893
Did: user.Did,
851
894
Created: time.Now(),
895
+
Repo: &f.Repo,
852
896
}
853
897
854
898
if err := rp.validator.ValidateIssue(issue); err != nil {
···
915
959
916
960
// everything is successful, do not rollback the atproto record
917
961
atUri = ""
918
-
rp.notifier.NewIssue(r.Context(), issue)
962
+
963
+
rawMentions := markup.FindUserMentions(issue.Body)
964
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
965
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
966
+
var mentions []syntax.DID
967
+
for _, ident := range idents {
968
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
969
+
mentions = append(mentions, ident.DID)
970
+
}
971
+
}
972
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
919
973
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
920
974
return
921
975
}
+267
appview/issues/opengraph.go
+267
appview/issues/opengraph.go
···
1
+
package issues
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/ogcard"
15
+
)
16
+
17
+
func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
18
+
width, height := ogcard.DefaultSize()
19
+
mainCard, err := ogcard.NewCard(width, height)
20
+
if err != nil {
21
+
return nil, err
22
+
}
23
+
24
+
// Split: content area (75%) and status/stats area (25%)
25
+
contentCard, statsArea := mainCard.Split(false, 75)
26
+
27
+
// Add padding to content
28
+
contentCard.SetMargin(50)
29
+
30
+
// Split content horizontally: main content (80%) and avatar area (20%)
31
+
mainContent, avatarArea := contentCard.Split(true, 80)
32
+
33
+
// Add margin to main content like repo card
34
+
mainContent.SetMargin(10)
35
+
36
+
// Use full main content area for repo name and title
37
+
bounds := mainContent.Img.Bounds()
38
+
startX := bounds.Min.X + mainContent.Margin
39
+
startY := bounds.Min.Y + mainContent.Margin
40
+
41
+
// Draw full repository name at top (owner/repo format)
42
+
var repoOwner string
43
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
44
+
if err != nil {
45
+
repoOwner = repo.Did
46
+
} else {
47
+
repoOwner = "@" + owner.Handle.String()
48
+
}
49
+
50
+
fullRepoName := repoOwner + " / " + repo.Name
51
+
if len(fullRepoName) > 60 {
52
+
fullRepoName = fullRepoName[:60] + "…"
53
+
}
54
+
55
+
grayColor := color.RGBA{88, 96, 105, 255}
56
+
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
57
+
if err != nil {
58
+
return nil, err
59
+
}
60
+
61
+
// Draw issue title below repo name with wrapping
62
+
titleY := startY + 60
63
+
titleX := startX
64
+
65
+
// Truncate title if too long
66
+
issueTitle := issue.Title
67
+
maxTitleLength := 80
68
+
if len(issueTitle) > maxTitleLength {
69
+
issueTitle = issueTitle[:maxTitleLength] + "…"
70
+
}
71
+
72
+
// Create a temporary card for the title area to enable wrapping
73
+
titleBounds := mainContent.Img.Bounds()
74
+
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
75
+
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
76
+
77
+
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
78
+
titleCard := &ogcard.Card{
79
+
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
80
+
Font: mainContent.Font,
81
+
Margin: 0,
82
+
}
83
+
84
+
// Draw wrapped title
85
+
lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
86
+
if err != nil {
87
+
return nil, err
88
+
}
89
+
90
+
// Calculate where title ends (number of lines * line height)
91
+
lineHeight := 60 // Approximate line height for 54pt font
92
+
titleEndY := titleY + (len(lines) * lineHeight) + 10
93
+
94
+
// Draw issue ID in gray below the title
95
+
issueIdText := fmt.Sprintf("#%d", issue.IssueId)
96
+
err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
97
+
if err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
// Get issue author handle (needed for avatar and metadata)
102
+
var authorHandle string
103
+
author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
104
+
if err != nil {
105
+
authorHandle = issue.Did
106
+
} else {
107
+
authorHandle = "@" + author.Handle.String()
108
+
}
109
+
110
+
// Draw avatar circle on the right side
111
+
avatarBounds := avatarArea.Img.Bounds()
112
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
113
+
if avatarSize > 220 {
114
+
avatarSize = 220
115
+
}
116
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
117
+
avatarY := avatarBounds.Min.Y + 20
118
+
119
+
// Get avatar URL for issue author
120
+
avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
121
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
122
+
if err != nil {
123
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
124
+
}
125
+
126
+
// Split stats area: left side for status/comments (80%), right side for dolly (20%)
127
+
statusCommentsArea, dollyArea := statsArea.Split(true, 80)
128
+
129
+
// Draw status and comment count in status/comments area
130
+
statsBounds := statusCommentsArea.Img.Bounds()
131
+
statsX := statsBounds.Min.X + 60 // left padding
132
+
statsY := statsBounds.Min.Y
133
+
134
+
iconColor := color.RGBA{88, 96, 105, 255}
135
+
iconSize := 36
136
+
textSize := 36.0
137
+
labelSize := 28.0
138
+
iconBaselineOffset := int(textSize) / 2
139
+
140
+
// Draw status (open/closed) with colored icon and text
141
+
var statusIcon string
142
+
var statusText string
143
+
var statusBgColor color.RGBA
144
+
145
+
if issue.Open {
146
+
statusIcon = "circle-dot"
147
+
statusText = "open"
148
+
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
+
} else {
150
+
statusIcon = "ban"
151
+
statusText = "closed"
152
+
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
+
}
154
+
155
+
badgeIconSize := 36
156
+
157
+
// Draw icon with status color (no background)
158
+
err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
+
if err != nil {
160
+
log.Printf("failed to draw status icon: %v", err)
161
+
}
162
+
163
+
// Draw text with status color (no background)
164
+
textX := statsX + badgeIconSize + 12
165
+
badgeTextSize := 32.0
166
+
err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
167
+
if err != nil {
168
+
log.Printf("failed to draw status text: %v", err)
169
+
}
170
+
171
+
statusTextWidth := len(statusText) * 20
172
+
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
+
174
+
// Draw comment count
175
+
err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
+
if err != nil {
177
+
log.Printf("failed to draw comment icon: %v", err)
178
+
}
179
+
180
+
currentX += iconSize + 15
181
+
commentText := fmt.Sprintf("%d comments", commentCount)
182
+
if commentCount == 1 {
183
+
commentText = "1 comment"
184
+
}
185
+
err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
186
+
if err != nil {
187
+
log.Printf("failed to draw comment text: %v", err)
188
+
}
189
+
190
+
// Draw dolly logo on the right side
191
+
dollyBounds := dollyArea.Img.Bounds()
192
+
dollySize := 90
193
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
+
if err != nil {
198
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
+
}
200
+
201
+
// Draw "opened by @author" and date at the bottom with more spacing
202
+
labelY := statsY + iconSize + 30
203
+
204
+
// Format the opened date
205
+
openedDate := issue.Created.Format("Jan 2, 2006")
206
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
207
+
208
+
err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
209
+
if err != nil {
210
+
log.Printf("failed to draw metadata: %v", err)
211
+
}
212
+
213
+
return mainCard, nil
214
+
}
215
+
216
+
func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
217
+
f, err := rp.repoResolver.Resolve(r)
218
+
if err != nil {
219
+
log.Println("failed to get repo and knot", err)
220
+
return
221
+
}
222
+
223
+
issue, ok := r.Context().Value("issue").(*models.Issue)
224
+
if !ok {
225
+
log.Println("issue not found in context")
226
+
http.Error(w, "issue not found", http.StatusNotFound)
227
+
return
228
+
}
229
+
230
+
// Get comment count
231
+
commentCount := len(issue.Comments)
232
+
233
+
// Get owner handle for avatar
234
+
var ownerHandle string
235
+
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
236
+
if err != nil {
237
+
ownerHandle = f.Repo.Did
238
+
} else {
239
+
ownerHandle = "@" + owner.Handle.String()
240
+
}
241
+
242
+
card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
243
+
if err != nil {
244
+
log.Println("failed to draw issue summary card", err)
245
+
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
246
+
return
247
+
}
248
+
249
+
var imageBuffer bytes.Buffer
250
+
err = png.Encode(&imageBuffer, card.Img)
251
+
if err != nil {
252
+
log.Println("failed to encode issue summary card", err)
253
+
http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
254
+
return
255
+
}
256
+
257
+
imageBytes := imageBuffer.Bytes()
258
+
259
+
w.Header().Set("Content-Type", "image/png")
260
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
261
+
w.WriteHeader(http.StatusOK)
262
+
_, err = w.Write(imageBytes)
263
+
if err != nil {
264
+
log.Println("failed to write issue summary card", err)
265
+
return
266
+
}
267
+
}
+1
appview/issues/router.go
+1
appview/issues/router.go
+9
appview/knots/knots.go
+9
appview/knots/knots.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
145
146
}
146
147
147
148
domain := r.FormValue("domain")
149
+
// Strip protocol, trailing slashes, and whitespace
150
+
// Rkey cannot contain slashes
151
+
domain = strings.TrimSpace(domain)
152
+
domain = strings.TrimPrefix(domain, "https://")
153
+
domain = strings.TrimPrefix(domain, "http://")
154
+
domain = strings.TrimSuffix(domain, "/")
148
155
if domain == "" {
149
156
k.Pages.Notice(w, noticeId, "Incomplete form.")
150
157
return
···
526
533
}
527
534
528
535
member := r.FormValue("member")
536
+
member = strings.TrimPrefix(member, "@")
529
537
if member == "" {
530
538
l.Error("empty member")
531
539
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
626
634
}
627
635
628
636
member := r.FormValue("member")
637
+
member = strings.TrimPrefix(member, "@")
629
638
if member == "" {
630
639
l.Error("empty member")
631
640
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+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
105
}
106
106
}
107
107
108
-
ctx := context.WithValue(r.Context(), "page", page)
108
+
ctx := pagination.IntoContext(r.Context(), page)
109
109
next.ServeHTTP(w, r.WithContext(ctx))
110
110
})
111
111
}
···
180
180
return func(next http.Handler) http.Handler {
181
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
182
didOrHandle := chi.URLParam(req, "user")
183
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
184
+
183
185
if slices.Contains(excluded, didOrHandle) {
184
186
next.ServeHTTP(w, req)
185
187
return
186
188
}
187
-
188
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
189
190
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
191
if err != nil {
···
206
206
return func(next http.Handler) http.Handler {
207
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
209
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
212
if !ok {
211
213
log.Println("malformed middleware")
···
244
246
prId := chi.URLParam(r, "pull")
245
247
prIdInt, err := strconv.Atoi(prId)
246
248
if err != nil {
247
-
http.Error(w, "bad pr id", http.StatusBadRequest)
248
249
log.Println("failed to parse pr id", err)
250
+
mw.pages.Error404(w)
249
251
return
250
252
}
251
253
252
254
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
253
255
if err != nil {
254
256
log.Println("failed to get pull and comments", err)
257
+
mw.pages.Error404(w)
255
258
return
256
259
}
257
260
···
292
295
issueId, err := strconv.Atoi(issueIdStr)
293
296
if err != nil {
294
297
log.Println("failed to fully resolve issue ID", err)
295
-
mw.pages.ErrorKnot404(w)
298
+
mw.pages.Error404(w)
296
299
return
297
300
}
298
301
299
-
issues, err := db.GetIssues(
300
-
mw.db,
301
-
db.FilterEq("repo_at", f.RepoAt()),
302
-
db.FilterEq("issue_id", issueId),
303
-
)
302
+
issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId)
304
303
if err != nil {
305
304
log.Println("failed to get issues", "err", err)
305
+
mw.pages.Error404(w)
306
306
return
307
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
308
314
-
ctx := context.WithValue(r.Context(), "issue", &issue)
309
+
ctx := context.WithValue(r.Context(), "issue", issue)
315
310
next.ServeHTTP(w, r.WithContext(ctx))
316
311
})
317
312
}
+24
appview/models/issue.go
+24
appview/models/issue.go
···
54
54
Replies []*IssueComment
55
55
}
56
56
57
+
func (it *CommentListItem) Participants() []syntax.DID {
58
+
participantSet := make(map[syntax.DID]struct{})
59
+
participants := []syntax.DID{}
60
+
61
+
addParticipant := func(did syntax.DID) {
62
+
if _, exists := participantSet[did]; !exists {
63
+
participantSet[did] = struct{}{}
64
+
participants = append(participants, did)
65
+
}
66
+
}
67
+
68
+
addParticipant(syntax.DID(it.Self.Did))
69
+
70
+
for _, c := range it.Replies {
71
+
addParticipant(syntax.DID(c.Did))
72
+
}
73
+
74
+
return participants
75
+
}
76
+
57
77
func (i *Issue) CommentList() []CommentListItem {
58
78
// Create a map to quickly find comments by their aturi
59
79
toplevel := make(map[string]*CommentListItem)
···
167
187
168
188
func (i *IssueComment) IsTopLevel() bool {
169
189
return i.ReplyTo == nil
190
+
}
191
+
192
+
func (i *IssueComment) IsReply() bool {
193
+
return i.ReplyTo != nil
170
194
}
171
195
172
196
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+25
-43
appview/models/label.go
+25
-43
appview/models/label.go
···
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"tangled.org/core/api/tangled"
17
-
"tangled.org/core/consts"
18
17
"tangled.org/core/idresolver"
19
18
)
20
19
···
461
460
return result
462
461
}
463
462
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
-
)
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
+
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
471
466
472
-
func DefaultLabelDefs() []string {
473
-
return []string{
474
-
LabelWontfix,
475
-
LabelDuplicate,
476
-
LabelAssignee,
477
-
LabelGoodFirstIssue,
478
-
LabelDocumentation,
479
-
}
480
-
}
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
+
}
481
475
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
-
}
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
+
}
494
480
495
-
var labelDefs []LabelDefinition
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
483
+
}
496
484
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
485
record, err := atproto.RepoGetRecord(
504
-
context.Background(),
505
-
client,
486
+
ctx,
487
+
&xrpcc,
506
488
"",
507
-
parsedUri.Collection().String(),
508
-
parsedUri.Authority().String(),
509
-
parsedUri.RecordKey().String(),
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
510
492
)
511
493
if err != nil {
512
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
526
508
}
527
509
528
510
labelDef, err := LabelDefinitionFromRecord(
529
-
parsedUri.Authority().String(),
530
-
parsedUri.RecordKey().String(),
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
531
513
labelRecord,
532
514
)
533
515
if err != nil {
+60
-1
appview/models/notifications.go
+60
-1
appview/models/notifications.go
···
2
2
3
3
import (
4
4
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
5
7
)
6
8
7
9
type NotificationType string
···
15
17
NotificationTypeFollowed NotificationType = "followed"
16
18
NotificationTypePullMerged NotificationType = "pull_merged"
17
19
NotificationTypeIssueClosed NotificationType = "issue_closed"
20
+
NotificationTypeIssueReopen NotificationType = "issue_reopen"
18
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
+
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
19
24
)
20
25
21
26
type Notification struct {
···
45
50
return "message-square"
46
51
case NotificationTypeIssueClosed:
47
52
return "ban"
53
+
case NotificationTypeIssueReopen:
54
+
return "circle-dot"
48
55
case NotificationTypePullCreated:
49
56
return "git-pull-request-create"
50
57
case NotificationTypePullCommented:
···
53
60
return "git-merge"
54
61
case NotificationTypePullClosed:
55
62
return "git-pull-request-closed"
63
+
case NotificationTypePullReopen:
64
+
return "git-pull-request-create"
56
65
case NotificationTypeFollowed:
57
66
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
58
69
default:
59
70
return ""
60
71
}
···
69
80
70
81
type NotificationPreferences struct {
71
82
ID int64
72
-
UserDid string
83
+
UserDid syntax.DID
73
84
RepoStarred bool
74
85
IssueCreated bool
75
86
IssueCommented bool
76
87
PullCreated bool
77
88
PullCommented bool
78
89
Followed bool
90
+
UserMentioned bool
79
91
PullMerged bool
80
92
IssueClosed bool
81
93
EmailNotifications bool
82
94
}
95
+
96
+
func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool {
97
+
switch t {
98
+
case NotificationTypeRepoStarred:
99
+
return prefs.RepoStarred
100
+
case NotificationTypeIssueCreated:
101
+
return prefs.IssueCreated
102
+
case NotificationTypeIssueCommented:
103
+
return prefs.IssueCommented
104
+
case NotificationTypeIssueClosed:
105
+
return prefs.IssueClosed
106
+
case NotificationTypeIssueReopen:
107
+
return prefs.IssueCreated // smae pref for now
108
+
case NotificationTypePullCreated:
109
+
return prefs.PullCreated
110
+
case NotificationTypePullCommented:
111
+
return prefs.PullCommented
112
+
case NotificationTypePullMerged:
113
+
return prefs.PullMerged
114
+
case NotificationTypePullClosed:
115
+
return prefs.PullMerged // same pref for now
116
+
case NotificationTypePullReopen:
117
+
return prefs.PullCreated // same pref for now
118
+
case NotificationTypeFollowed:
119
+
return prefs.Followed
120
+
case NotificationTypeUserMentioned:
121
+
return prefs.UserMentioned
122
+
default:
123
+
return false
124
+
}
125
+
}
126
+
127
+
func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences {
128
+
return &NotificationPreferences{
129
+
UserDid: user,
130
+
RepoStarred: true,
131
+
IssueCreated: true,
132
+
IssueCommented: true,
133
+
PullCreated: true,
134
+
PullCommented: true,
135
+
Followed: true,
136
+
UserMentioned: true,
137
+
PullMerged: true,
138
+
IssueClosed: true,
139
+
EmailNotifications: false,
140
+
}
141
+
}
+1
appview/models/profile.go
+1
appview/models/profile.go
+21
-10
appview/models/pull.go
+21
-10
appview/models/pull.go
···
88
88
source.Branch = p.PullSource.Branch
89
89
source.Sha = p.LatestSha()
90
90
if p.PullSource.RepoAt != nil {
91
-
s := p.PullSource.Repo.RepoAt().String()
91
+
s := p.PullSource.RepoAt.String()
92
92
source.Repo = &s
93
93
}
94
94
}
···
125
125
// content
126
126
RoundNumber int
127
127
Patch string
128
+
Combined string
128
129
Comments []PullComment
129
130
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
130
131
···
150
151
Created time.Time
151
152
}
152
153
154
+
func (p *Pull) LastRoundNumber() int {
155
+
return len(p.Submissions) - 1
156
+
}
157
+
158
+
func (p *Pull) LatestSubmission() *PullSubmission {
159
+
return p.Submissions[p.LastRoundNumber()]
160
+
}
161
+
153
162
func (p *Pull) LatestPatch() string {
154
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
155
-
return latestSubmission.Patch
163
+
return p.LatestSubmission().Patch
156
164
}
157
165
158
166
func (p *Pull) LatestSha() string {
159
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
160
-
return latestSubmission.SourceRev
167
+
return p.LatestSubmission().SourceRev
161
168
}
162
169
163
-
func (p *Pull) PullAt() syntax.ATURI {
170
+
func (p *Pull) AtUri() syntax.ATURI {
164
171
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
165
-
}
166
-
167
-
func (p *Pull) LastRoundNumber() int {
168
-
return len(p.Submissions) - 1
169
172
}
170
173
171
174
func (p *Pull) IsPatchBased() bool {
···
252
255
}
253
256
254
257
return participants
258
+
}
259
+
260
+
func (s PullSubmission) CombinedPatch() string {
261
+
if s.Combined == "" {
262
+
return s.Patch
263
+
}
264
+
265
+
return s.Combined
255
266
}
256
267
257
268
type Stack []*Pull
+61
-1
appview/models/repo.go
+61
-1
appview/models/repo.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"strings"
5
6
"time"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
17
18
Rkey string
18
19
Created time.Time
19
20
Description string
21
+
Website string
22
+
Topics []string
20
23
Spindle string
21
24
Labels []string
22
25
···
28
31
}
29
32
30
33
func (r *Repo) AsRecord() tangled.Repo {
31
-
var source, spindle, description *string
34
+
var source, spindle, description, website *string
32
35
33
36
if r.Source != "" {
34
37
source = &r.Source
···
42
45
description = &r.Description
43
46
}
44
47
48
+
if r.Website != "" {
49
+
website = &r.Website
50
+
}
51
+
45
52
return tangled.Repo{
46
53
Knot: r.Knot,
47
54
Name: r.Name,
48
55
Description: description,
56
+
Website: website,
57
+
Topics: r.Topics,
49
58
CreatedAt: r.Created.Format(time.RFC3339),
50
59
Source: source,
51
60
Spindle: spindle,
···
60
69
func (r Repo) DidSlashRepo() string {
61
70
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
71
return p
72
+
}
73
+
74
+
func (r Repo) TopicStr() string {
75
+
return strings.Join(r.Topics, " ")
63
76
}
64
77
65
78
type RepoStats struct {
···
91
104
Repo *Repo
92
105
Issues []Issue
93
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
+
// }
+5
-9
appview/notifications/notifications.go
+5
-9
appview/notifications/notifications.go
···
49
49
l := n.logger.With("handler", "notificationsPage")
50
50
user := n.oauth.GetUser(r)
51
51
52
-
page, ok := r.Context().Value("page").(pagination.Page)
53
-
if !ok {
54
-
l.Error("failed to get page")
55
-
page = pagination.FirstPage()
56
-
}
52
+
page := pagination.FromContext(r.Context())
57
53
58
54
total, err := db.CountNotifications(
59
55
n.db,
···
76
72
return
77
73
}
78
74
79
-
err = n.db.MarkAllNotificationsRead(r.Context(), user.Did)
75
+
err = db.MarkAllNotificationsRead(n.db, user.Did)
80
76
if err != nil {
81
77
l.Error("failed to mark notifications as read", "err", err)
82
78
}
···
128
124
return
129
125
}
130
126
131
-
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
127
+
err = db.MarkNotificationRead(n.db, notificationID, userDid)
132
128
if err != nil {
133
129
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
134
130
return
···
140
136
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
141
137
userDid := n.oauth.GetDid(r)
142
138
143
-
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
139
+
err := db.MarkAllNotificationsRead(n.db, userDid)
144
140
if err != nil {
145
141
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
146
142
return
···
159
155
return
160
156
}
161
157
162
-
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
158
+
err = db.DeleteNotification(n.db, notificationID, userDid)
163
159
if err != nil {
164
160
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
165
161
return
+320
-260
appview/notify/db/db.go
+320
-260
appview/notify/db/db.go
···
3
3
import (
4
4
"context"
5
5
"log"
6
+
"maps"
7
+
"slices"
6
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
10
"tangled.org/core/appview/db"
8
11
"tangled.org/core/appview/models"
9
12
"tangled.org/core/appview/notify"
10
13
"tangled.org/core/idresolver"
14
+
)
15
+
16
+
const (
17
+
maxMentions = 5
11
18
)
12
19
13
20
type databaseNotifier struct {
···
36
43
return
37
44
}
38
45
39
-
// don't notify yourself
40
-
if repo.Did == star.StarredByDid {
41
-
return
42
-
}
46
+
actorDid := syntax.DID(star.StarredByDid)
47
+
recipients := []syntax.DID{syntax.DID(repo.Did)}
48
+
eventType := models.NotificationTypeRepoStarred
49
+
entityType := "repo"
50
+
entityId := star.RepoAt.String()
51
+
repoId := &repo.Id
52
+
var issueId *int64
53
+
var pullId *int64
43
54
44
-
// check if user wants these notifications
45
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
46
-
if err != nil {
47
-
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
48
-
return
49
-
}
50
-
if !prefs.RepoStarred {
51
-
return
52
-
}
53
-
54
-
notification := &models.Notification{
55
-
RecipientDid: repo.Did,
56
-
ActorDid: star.StarredByDid,
57
-
Type: models.NotificationTypeRepoStarred,
58
-
EntityType: "repo",
59
-
EntityId: string(star.RepoAt),
60
-
RepoId: &repo.Id,
61
-
}
62
-
err = n.db.CreateNotification(ctx, notification)
63
-
if err != nil {
64
-
log.Printf("NewStar: failed to create notification: %v", err)
65
-
return
66
-
}
55
+
n.notifyEvent(
56
+
actorDid,
57
+
recipients,
58
+
eventType,
59
+
entityType,
60
+
entityId,
61
+
repoId,
62
+
issueId,
63
+
pullId,
64
+
)
67
65
}
68
66
69
67
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
70
68
// no-op
71
69
}
72
70
73
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
-
if err != nil {
76
-
log.Printf("NewIssue: failed to get repos: %v", err)
77
-
return
78
-
}
71
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
79
72
80
-
if repo.Did == issue.Did {
81
-
return
82
-
}
83
-
84
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
73
+
// build the recipients list
74
+
// - owner of the repo
75
+
// - collaborators in the repo
76
+
var recipients []syntax.DID
77
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
78
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
85
79
if err != nil {
86
-
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
80
+
log.Printf("failed to fetch collaborators: %v", err)
87
81
return
88
82
}
89
-
if !prefs.IssueCreated {
90
-
return
83
+
for _, c := range collaborators {
84
+
recipients = append(recipients, c.SubjectDid)
91
85
}
92
86
93
-
notification := &models.Notification{
94
-
RecipientDid: repo.Did,
95
-
ActorDid: issue.Did,
96
-
Type: models.NotificationTypeIssueCreated,
97
-
EntityType: "issue",
98
-
EntityId: string(issue.AtUri()),
99
-
RepoId: &repo.Id,
100
-
IssueId: &issue.Id,
101
-
}
87
+
actorDid := syntax.DID(issue.Did)
88
+
entityType := "issue"
89
+
entityId := issue.AtUri().String()
90
+
repoId := &issue.Repo.Id
91
+
issueId := &issue.Id
92
+
var pullId *int64
102
93
103
-
err = n.db.CreateNotification(ctx, notification)
104
-
if err != nil {
105
-
log.Printf("NewIssue: failed to create notification: %v", err)
106
-
return
107
-
}
94
+
n.notifyEvent(
95
+
actorDid,
96
+
recipients,
97
+
models.NotificationTypeIssueCreated,
98
+
entityType,
99
+
entityId,
100
+
repoId,
101
+
issueId,
102
+
pullId,
103
+
)
104
+
n.notifyEvent(
105
+
actorDid,
106
+
mentions,
107
+
models.NotificationTypeUserMentioned,
108
+
entityType,
109
+
entityId,
110
+
repoId,
111
+
issueId,
112
+
pullId,
113
+
)
108
114
}
109
115
110
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
116
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
111
117
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
112
118
if err != nil {
113
119
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
119
125
}
120
126
issue := issues[0]
121
127
122
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
-
if err != nil {
124
-
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
-
return
126
-
}
128
+
var recipients []syntax.DID
129
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
127
130
128
-
recipients := make(map[string]bool)
131
+
if comment.IsReply() {
132
+
// if this comment is a reply, then notify everybody in that thread
133
+
parentAtUri := *comment.ReplyTo
134
+
allThreads := issue.CommentList()
129
135
130
-
// notify issue author (if not the commenter)
131
-
if issue.Did != comment.Did {
132
-
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
133
-
if err == nil && prefs.IssueCommented {
134
-
recipients[issue.Did] = true
135
-
} else if err != nil {
136
-
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
136
+
// find the parent thread, and add all DIDs from here to the recipient list
137
+
for _, t := range allThreads {
138
+
if t.Self.AtUri().String() == parentAtUri {
139
+
recipients = append(recipients, t.Participants()...)
140
+
}
137
141
}
142
+
} else {
143
+
// not a reply, notify just the issue author
144
+
recipients = append(recipients, syntax.DID(issue.Did))
138
145
}
139
146
140
-
// notify repo owner (if not the commenter and not already added)
141
-
if repo.Did != comment.Did && repo.Did != issue.Did {
142
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
143
-
if err == nil && prefs.IssueCommented {
144
-
recipients[repo.Did] = true
145
-
} else if err != nil {
146
-
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
147
-
}
148
-
}
147
+
actorDid := syntax.DID(comment.Did)
148
+
entityType := "issue"
149
+
entityId := issue.AtUri().String()
150
+
repoId := &issue.Repo.Id
151
+
issueId := &issue.Id
152
+
var pullId *int64
149
153
150
-
// create notifications for all recipients
151
-
for recipientDid := range recipients {
152
-
notification := &models.Notification{
153
-
RecipientDid: recipientDid,
154
-
ActorDid: comment.Did,
155
-
Type: models.NotificationTypeIssueCommented,
156
-
EntityType: "issue",
157
-
EntityId: string(issue.AtUri()),
158
-
RepoId: &repo.Id,
159
-
IssueId: &issue.Id,
160
-
}
154
+
n.notifyEvent(
155
+
actorDid,
156
+
recipients,
157
+
models.NotificationTypeIssueCommented,
158
+
entityType,
159
+
entityId,
160
+
repoId,
161
+
issueId,
162
+
pullId,
163
+
)
164
+
n.notifyEvent(
165
+
actorDid,
166
+
mentions,
167
+
models.NotificationTypeUserMentioned,
168
+
entityType,
169
+
entityId,
170
+
repoId,
171
+
issueId,
172
+
pullId,
173
+
)
174
+
}
161
175
162
-
err = n.db.CreateNotification(ctx, notification)
163
-
if err != nil {
164
-
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
165
-
}
166
-
}
176
+
func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
177
+
// no-op for now
167
178
}
168
179
169
180
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
170
-
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
171
-
if err != nil {
172
-
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
173
-
return
174
-
}
175
-
if !prefs.Followed {
176
-
return
177
-
}
181
+
actorDid := syntax.DID(follow.UserDid)
182
+
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
183
+
eventType := models.NotificationTypeFollowed
184
+
entityType := "follow"
185
+
entityId := follow.UserDid
186
+
var repoId, issueId, pullId *int64
178
187
179
-
notification := &models.Notification{
180
-
RecipientDid: follow.SubjectDid,
181
-
ActorDid: follow.UserDid,
182
-
Type: models.NotificationTypeFollowed,
183
-
EntityType: "follow",
184
-
EntityId: follow.UserDid,
185
-
}
186
-
187
-
err = n.db.CreateNotification(ctx, notification)
188
-
if err != nil {
189
-
log.Printf("NewFollow: failed to create notification: %v", err)
190
-
return
191
-
}
188
+
n.notifyEvent(
189
+
actorDid,
190
+
recipients,
191
+
eventType,
192
+
entityType,
193
+
entityId,
194
+
repoId,
195
+
issueId,
196
+
pullId,
197
+
)
192
198
}
193
199
194
200
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
···
202
208
return
203
209
}
204
210
205
-
if repo.Did == pull.OwnerDid {
206
-
return
207
-
}
208
-
209
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
211
+
// build the recipients list
212
+
// - owner of the repo
213
+
// - collaborators in the repo
214
+
var recipients []syntax.DID
215
+
recipients = append(recipients, syntax.DID(repo.Did))
216
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
210
217
if err != nil {
211
-
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
218
+
log.Printf("failed to fetch collaborators: %v", err)
212
219
return
213
220
}
214
-
if !prefs.PullCreated {
215
-
return
221
+
for _, c := range collaborators {
222
+
recipients = append(recipients, c.SubjectDid)
216
223
}
217
224
218
-
notification := &models.Notification{
219
-
RecipientDid: repo.Did,
220
-
ActorDid: pull.OwnerDid,
221
-
Type: models.NotificationTypePullCreated,
222
-
EntityType: "pull",
223
-
EntityId: string(pull.RepoAt),
224
-
RepoId: &repo.Id,
225
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
226
-
}
225
+
actorDid := syntax.DID(pull.OwnerDid)
226
+
eventType := models.NotificationTypePullCreated
227
+
entityType := "pull"
228
+
entityId := pull.AtUri().String()
229
+
repoId := &repo.Id
230
+
var issueId *int64
231
+
p := int64(pull.ID)
232
+
pullId := &p
227
233
228
-
err = n.db.CreateNotification(ctx, notification)
229
-
if err != nil {
230
-
log.Printf("NewPull: failed to create notification: %v", err)
231
-
return
232
-
}
234
+
n.notifyEvent(
235
+
actorDid,
236
+
recipients,
237
+
eventType,
238
+
entityType,
239
+
entityId,
240
+
repoId,
241
+
issueId,
242
+
pullId,
243
+
)
233
244
}
234
245
235
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
236
-
pulls, err := db.GetPulls(n.db,
237
-
db.FilterEq("repo_at", comment.RepoAt),
238
-
db.FilterEq("pull_id", comment.PullId))
246
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
247
+
pull, err := db.GetPull(n.db,
248
+
syntax.ATURI(comment.RepoAt),
249
+
comment.PullId,
250
+
)
239
251
if err != nil {
240
252
log.Printf("NewPullComment: failed to get pulls: %v", err)
241
253
return
242
254
}
243
-
if len(pulls) == 0 {
244
-
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
245
-
return
246
-
}
247
-
pull := pulls[0]
248
255
249
256
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
250
257
if err != nil {
···
252
259
return
253
260
}
254
261
255
-
recipients := make(map[string]bool)
256
-
257
-
// notify pull request author (if not the commenter)
258
-
if pull.OwnerDid != comment.OwnerDid {
259
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
260
-
if err == nil && prefs.PullCommented {
261
-
recipients[pull.OwnerDid] = true
262
-
} else if err != nil {
263
-
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
264
-
}
262
+
// build up the recipients list:
263
+
// - repo owner
264
+
// - all pull participants
265
+
var recipients []syntax.DID
266
+
recipients = append(recipients, syntax.DID(repo.Did))
267
+
for _, p := range pull.Participants() {
268
+
recipients = append(recipients, syntax.DID(p))
265
269
}
266
270
267
-
// notify repo owner (if not the commenter and not already added)
268
-
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
269
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
270
-
if err == nil && prefs.PullCommented {
271
-
recipients[repo.Did] = true
272
-
} else if err != nil {
273
-
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
274
-
}
275
-
}
271
+
actorDid := syntax.DID(comment.OwnerDid)
272
+
eventType := models.NotificationTypePullCommented
273
+
entityType := "pull"
274
+
entityId := pull.AtUri().String()
275
+
repoId := &repo.Id
276
+
var issueId *int64
277
+
p := int64(pull.ID)
278
+
pullId := &p
276
279
277
-
for recipientDid := range recipients {
278
-
notification := &models.Notification{
279
-
RecipientDid: recipientDid,
280
-
ActorDid: comment.OwnerDid,
281
-
Type: models.NotificationTypePullCommented,
282
-
EntityType: "pull",
283
-
EntityId: comment.RepoAt,
284
-
RepoId: &repo.Id,
285
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
286
-
}
287
-
288
-
err = n.db.CreateNotification(ctx, notification)
289
-
if err != nil {
290
-
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
291
-
}
292
-
}
280
+
n.notifyEvent(
281
+
actorDid,
282
+
recipients,
283
+
eventType,
284
+
entityType,
285
+
entityId,
286
+
repoId,
287
+
issueId,
288
+
pullId,
289
+
)
290
+
n.notifyEvent(
291
+
actorDid,
292
+
mentions,
293
+
models.NotificationTypeUserMentioned,
294
+
entityType,
295
+
entityId,
296
+
repoId,
297
+
issueId,
298
+
pullId,
299
+
)
293
300
}
294
301
295
302
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
308
315
// no-op
309
316
}
310
317
311
-
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
-
// Get repo details
313
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
318
+
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
319
+
// build up the recipients list:
320
+
// - repo owner
321
+
// - repo collaborators
322
+
// - all issue participants
323
+
var recipients []syntax.DID
324
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
325
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
314
326
if err != nil {
315
-
log.Printf("NewIssueClosed: failed to get repos: %v", err)
327
+
log.Printf("failed to fetch collaborators: %v", err)
316
328
return
317
329
}
318
-
319
-
// Don't notify yourself
320
-
if repo.Did == issue.Did {
321
-
return
322
-
}
323
-
324
-
// Check if user wants these notifications
325
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
326
-
if err != nil {
327
-
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
328
-
return
330
+
for _, c := range collaborators {
331
+
recipients = append(recipients, c.SubjectDid)
329
332
}
330
-
if !prefs.IssueClosed {
331
-
return
333
+
for _, p := range issue.Participants() {
334
+
recipients = append(recipients, syntax.DID(p))
332
335
}
333
336
334
-
notification := &models.Notification{
335
-
RecipientDid: repo.Did,
336
-
ActorDid: issue.Did,
337
-
Type: models.NotificationTypeIssueClosed,
338
-
EntityType: "issue",
339
-
EntityId: string(issue.AtUri()),
340
-
RepoId: &repo.Id,
341
-
IssueId: &issue.Id,
342
-
}
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
343
344
-
err = n.db.CreateNotification(ctx, notification)
345
-
if err != nil {
346
-
log.Printf("NewIssueClosed: failed to create notification: %v", err)
347
-
return
344
+
if issue.Open {
345
+
eventType = models.NotificationTypeIssueReopen
346
+
} else {
347
+
eventType = models.NotificationTypeIssueClosed
348
348
}
349
+
350
+
n.notifyEvent(
351
+
actor,
352
+
recipients,
353
+
eventType,
354
+
entityType,
355
+
entityId,
356
+
repoId,
357
+
issueId,
358
+
pullId,
359
+
)
349
360
}
350
361
351
-
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
362
+
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
352
363
// Get repo details
353
364
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
354
365
if err != nil {
355
-
log.Printf("NewPullMerged: failed to get repos: %v", err)
366
+
log.Printf("NewPullState: failed to get repos: %v", err)
356
367
return
357
368
}
358
369
359
-
// Don't notify yourself
360
-
if repo.Did == pull.OwnerDid {
361
-
return
362
-
}
363
-
364
-
// Check if user wants these notifications
365
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
370
+
// build up the recipients list:
371
+
// - repo owner
372
+
// - all pull participants
373
+
var recipients []syntax.DID
374
+
recipients = append(recipients, syntax.DID(repo.Did))
375
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
366
376
if err != nil {
367
-
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
377
+
log.Printf("failed to fetch collaborators: %v", err)
368
378
return
369
379
}
370
-
if !prefs.PullMerged {
371
-
return
380
+
for _, c := range collaborators {
381
+
recipients = append(recipients, c.SubjectDid)
372
382
}
373
-
374
-
notification := &models.Notification{
375
-
RecipientDid: pull.OwnerDid,
376
-
ActorDid: repo.Did,
377
-
Type: models.NotificationTypePullMerged,
378
-
EntityType: "pull",
379
-
EntityId: string(pull.RepoAt),
380
-
RepoId: &repo.Id,
381
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
383
+
for _, p := range pull.Participants() {
384
+
recipients = append(recipients, syntax.DID(p))
382
385
}
383
386
384
-
err = n.db.CreateNotification(ctx, notification)
385
-
if err != nil {
386
-
log.Printf("NewPullMerged: failed to create notification: %v", err)
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)
387
401
return
388
402
}
403
+
p := int64(pull.ID)
404
+
pullId := &p
405
+
406
+
n.notifyEvent(
407
+
actor,
408
+
recipients,
409
+
eventType,
410
+
entityType,
411
+
entityId,
412
+
repoId,
413
+
issueId,
414
+
pullId,
415
+
)
389
416
}
390
417
391
-
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
-
// Get repo details
393
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
-
if err != nil {
395
-
log.Printf("NewPullClosed: failed to get repos: %v", err)
396
-
return
418
+
func (n *databaseNotifier) notifyEvent(
419
+
actorDid syntax.DID,
420
+
recipients []syntax.DID,
421
+
eventType models.NotificationType,
422
+
entityType string,
423
+
entityId string,
424
+
repoId *int64,
425
+
issueId *int64,
426
+
pullId *int64,
427
+
) {
428
+
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
429
+
recipients = recipients[:maxMentions]
397
430
}
398
-
399
-
// Don't notify yourself
400
-
if repo.Did == pull.OwnerDid {
401
-
return
431
+
recipientSet := make(map[syntax.DID]struct{})
432
+
for _, did := range recipients {
433
+
// everybody except actor themselves
434
+
if did != actorDid {
435
+
recipientSet[did] = struct{}{}
436
+
}
402
437
}
403
438
404
-
// Check if user wants these notifications - reuse pull_merged preference for now
405
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
439
+
prefMap, err := db.GetNotificationPreferences(
440
+
n.db,
441
+
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
442
+
)
406
443
if err != nil {
407
-
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
444
+
// failed to get prefs for users
408
445
return
409
446
}
410
-
if !prefs.PullMerged {
447
+
448
+
// create a transaction for bulk notification storage
449
+
tx, err := n.db.Begin()
450
+
if err != nil {
451
+
// failed to start tx
411
452
return
412
453
}
454
+
defer tx.Rollback()
413
455
414
-
notification := &models.Notification{
415
-
RecipientDid: pull.OwnerDid,
416
-
ActorDid: repo.Did,
417
-
Type: models.NotificationTypePullClosed,
418
-
EntityType: "pull",
419
-
EntityId: string(pull.RepoAt),
420
-
RepoId: &repo.Id,
421
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
456
+
// filter based on preferences
457
+
for recipientDid := range recipientSet {
458
+
prefs, ok := prefMap[recipientDid]
459
+
if !ok {
460
+
prefs = models.DefaultNotificationPreferences(recipientDid)
461
+
}
462
+
463
+
// skip users who don’t want this type
464
+
if !prefs.ShouldNotify(eventType) {
465
+
continue
466
+
}
467
+
468
+
// create notification
469
+
notif := &models.Notification{
470
+
RecipientDid: recipientDid.String(),
471
+
ActorDid: actorDid.String(),
472
+
Type: eventType,
473
+
EntityType: entityType,
474
+
EntityId: entityId,
475
+
RepoId: repoId,
476
+
IssueId: issueId,
477
+
PullId: pullId,
478
+
}
479
+
480
+
if err := db.CreateNotification(tx, notif); err != nil {
481
+
log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err)
482
+
}
422
483
}
423
484
424
-
err = n.db.CreateNotification(ctx, notification)
425
-
if err != nil {
426
-
log.Printf("NewPullClosed: failed to create notification: %v", err)
485
+
if err := tx.Commit(); err != nil {
486
+
// failed to commit
427
487
return
428
488
}
429
489
}
+57
-59
appview/notify/merged_notifier.go
+57
-59
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
6
+
"reflect"
7
+
"sync"
5
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/log"
7
12
)
8
13
9
14
type mergedNotifier struct {
10
15
notifiers []Notifier
16
+
logger *slog.Logger
11
17
}
12
18
13
-
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
-
return &mergedNotifier{notifiers}
19
+
func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier {
20
+
return &mergedNotifier{notifiers, logger}
15
21
}
16
22
17
23
var _ Notifier = &mergedNotifier{}
18
24
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
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)
22
41
}
42
+
wg.Wait()
43
+
}
44
+
45
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
46
+
m.fanout("NewRepo", ctx, repo)
23
47
}
24
48
25
49
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
50
+
m.fanout("NewStar", ctx, star)
29
51
}
52
+
30
53
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
54
+
m.fanout("DeleteStar", ctx, star)
34
55
}
35
56
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
57
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
+
m.fanout("NewIssue", ctx, issue, mentions)
40
59
}
41
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
-
for _, notifier := range m.notifiers {
43
-
notifier.NewIssueComment(ctx, comment)
44
-
}
60
+
61
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
+
m.fanout("NewIssueComment", ctx, comment, mentions)
45
63
}
46
64
47
-
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.NewIssueClosed(ctx, issue)
50
-
}
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)
51
71
}
52
72
53
73
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewFollow(ctx, follow)
56
-
}
74
+
m.fanout("NewFollow", ctx, follow)
57
75
}
76
+
58
77
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.DeleteFollow(ctx, follow)
61
-
}
78
+
m.fanout("DeleteFollow", ctx, follow)
62
79
}
63
80
64
81
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
-
}
82
+
m.fanout("NewPull", ctx, pull)
73
83
}
74
84
75
-
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
-
for _, notifier := range m.notifiers {
77
-
notifier.NewPullMerged(ctx, pull)
78
-
}
85
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
+
m.fanout("NewPullComment", ctx, comment, mentions)
79
87
}
80
88
81
-
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
-
for _, notifier := range m.notifiers {
83
-
notifier.NewPullClosed(ctx, pull)
84
-
}
89
+
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
90
+
m.fanout("NewPullState", ctx, actor, pull)
85
91
}
86
92
87
93
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
-
for _, notifier := range m.notifiers {
89
-
notifier.UpdateProfile(ctx, profile)
90
-
}
94
+
m.fanout("UpdateProfile", ctx, profile)
91
95
}
92
96
93
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
-
for _, notifier := range m.notifiers {
95
-
notifier.NewString(ctx, string)
96
-
}
97
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
98
+
m.fanout("NewString", ctx, s)
97
99
}
98
100
99
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
-
for _, notifier := range m.notifiers {
101
-
notifier.EditString(ctx, string)
102
-
}
101
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
102
+
m.fanout("EditString", ctx, s)
103
103
}
104
104
105
105
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
for _, notifier := range m.notifiers {
107
-
notifier.DeleteString(ctx, did, rkey)
108
-
}
106
+
m.fanout("DeleteString", ctx, did, rkey)
109
107
}
+16
-13
appview/notify/notifier.go
+16
-13
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
7
"tangled.org/core/appview/models"
7
8
)
8
9
···
12
13
NewStar(ctx context.Context, star *models.Star)
13
14
DeleteStar(ctx context.Context, star *models.Star)
14
15
15
-
NewIssue(ctx context.Context, issue *models.Issue)
16
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
-
NewIssueClosed(ctx context.Context, issue *models.Issue)
16
+
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
+
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
+
DeleteIssue(ctx context.Context, issue *models.Issue)
18
20
19
21
NewFollow(ctx context.Context, follow *models.Follow)
20
22
DeleteFollow(ctx context.Context, follow *models.Follow)
21
23
22
24
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)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
+
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
26
27
27
28
UpdateProfile(ctx context.Context, profile *models.Profile)
28
29
···
41
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
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) {}
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) {}
47
50
48
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
50
53
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) {}
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) {}
55
58
56
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
60
+33
-9
appview/notify/posthog/notifier.go
+33
-9
appview/notify/posthog/notifier.go
···
4
4
"context"
5
5
"log"
6
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
8
"github.com/posthog/posthog-go"
8
9
"tangled.org/core/appview/models"
9
10
"tangled.org/core/appview/notify"
···
56
57
}
57
58
}
58
59
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
60
61
err := n.client.Enqueue(posthog.Capture{
61
62
DistinctId: issue.Did,
62
63
Event: "new_issue",
63
64
Properties: posthog.Properties{
64
65
"repo_at": issue.RepoAt.String(),
65
66
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
66
68
},
67
69
})
68
70
if err != nil {
···
84
86
}
85
87
}
86
88
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
88
90
err := n.client.Enqueue(posthog.Capture{
89
91
DistinctId: comment.OwnerDid,
90
92
Event: "new_pull_comment",
91
93
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
94
97
},
95
98
})
96
99
if err != nil {
···
177
180
}
178
181
}
179
182
180
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
181
184
err := n.client.Enqueue(posthog.Capture{
182
185
DistinctId: comment.Did,
183
186
Event: "new_issue_comment",
184
187
Properties: posthog.Properties{
185
188
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
186
190
},
187
191
})
188
192
if err != nil {
···
190
194
}
191
195
}
192
196
193
-
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
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
+
}
194
204
err := n.client.Enqueue(posthog.Capture{
195
205
DistinctId: issue.Did,
196
-
Event: "issue_closed",
206
+
Event: event,
197
207
Properties: posthog.Properties{
198
208
"repo_at": issue.RepoAt.String(),
209
+
"actor": actor,
199
210
"issue_id": issue.IssueId,
200
211
},
201
212
})
···
204
215
}
205
216
}
206
217
207
-
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
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
+
}
208
231
err := n.client.Enqueue(posthog.Capture{
209
232
DistinctId: pull.OwnerDid,
210
-
Event: "pull_merged",
233
+
Event: event,
211
234
Properties: posthog.Properties{
212
235
"repo_at": pull.RepoAt,
213
236
"pull_id": pull.PullId,
237
+
"actor": actor,
214
238
},
215
239
})
216
240
if err != nil {
+18
-15
appview/oauth/handler.go
+18
-15
appview/oauth/handler.go
···
4
4
"bytes"
5
5
"context"
6
6
"encoding/json"
7
+
"errors"
7
8
"fmt"
8
9
"net/http"
9
10
"slices"
10
11
"time"
11
12
13
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
14
"github.com/go-chi/chi/v5"
13
-
"github.com/lestrrat-go/jwx/v2/jwk"
14
15
"github.com/posthog/posthog-go"
15
16
"tangled.org/core/api/tangled"
16
17
"tangled.org/core/appview/db"
···
30
31
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
31
32
doc := o.ClientApp.Config.ClientMetadata()
32
33
doc.JWKSURI = &o.JwksUri
34
+
doc.ClientName = &o.ClientName
35
+
doc.ClientURI = &o.ClientUri
33
36
34
37
w.Header().Set("Content-Type", "application/json")
35
38
if err := json.NewEncoder(w).Encode(doc); err != nil {
···
39
42
}
40
43
41
44
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)
45
+
w.Header().Set("Content-Type", "application/json")
46
+
body := o.ClientApp.Config.PublicJWKS()
47
+
if err := json.NewEncoder(w).Encode(body); err != nil {
46
48
http.Error(w, err.Error(), http.StatusInternalServerError)
47
49
return
48
50
}
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
51
}
58
52
59
53
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
60
54
ctx := r.Context()
55
+
l := o.Logger.With("query", r.URL.Query())
61
56
62
57
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
63
58
if err != nil {
64
-
http.Error(w, err.Error(), http.StatusInternalServerError)
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)
65
67
return
66
68
}
67
69
68
70
if err := o.SaveSession(w, r, sessData); err != nil {
69
-
http.Error(w, err.Error(), http.StatusInternalServerError)
71
+
l.Error("failed to save session", "data", sessData, "err", err)
72
+
http.Redirect(w, r, "/login?error=session", http.StatusFound)
70
73
return
71
74
}
72
75
+35
-22
appview/oauth/oauth.go
+35
-22
appview/oauth/oauth.go
···
10
10
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
11
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
12
atpclient "github.com/bluesky-social/indigo/atproto/client"
13
+
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
14
15
xrpc "github.com/bluesky-social/indigo/xrpc"
15
16
"github.com/gorilla/sessions"
16
-
"github.com/lestrrat-go/jwx/v2/jwk"
17
17
"github.com/posthog/posthog-go"
18
18
"tangled.org/core/appview/config"
19
19
"tangled.org/core/appview/db"
···
26
26
SessStore *sessions.CookieStore
27
27
Config *config.Config
28
28
JwksUri string
29
+
ClientName string
30
+
ClientUri string
29
31
Posthog posthog.Client
30
32
Db *db.DB
31
33
Enforcer *rbac.Enforcer
···
34
36
}
35
37
36
38
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
37
-
38
39
var oauthConfig oauth.ClientConfig
39
40
var clientUri string
40
-
41
41
if config.Core.Dev {
42
42
clientUri = "http://127.0.0.1:3000"
43
43
callbackUri := clientUri + "/oauth/callback"
···
47
47
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
48
48
callbackUri := clientUri + "/oauth/callback"
49
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
50
59
}
51
60
52
61
jwksUri := clientUri + "/oauth/jwks.json"
53
62
54
-
authStore, err := NewRedisStore(config.Redis.ToURL())
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
+
})
55
69
if err != nil {
56
70
return nil, err
57
71
}
58
72
59
73
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
60
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())
61
85
return &OAuth{
62
-
ClientApp: oauth.NewClientApp(&oauthConfig, authStore),
86
+
ClientApp: clientApp,
63
87
Config: config,
64
88
SessStore: sessStore,
65
89
JwksUri: jwksUri,
90
+
ClientName: clientName,
91
+
ClientUri: clientUri,
66
92
Posthog: ph,
67
93
Db: db,
68
94
Enforcer: enforcer,
···
137
163
return errors.Join(err1, err2)
138
164
}
139
165
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
166
type User struct {
153
167
Did string
154
168
Pds string
155
169
}
156
170
157
171
func (o *OAuth) GetUser(r *http.Request) *User {
158
-
sess, err := o.SessStore.Get(r, SessionName)
159
-
160
-
if err != nil || sess.IsNew {
172
+
sess, err := o.ResumeSession(r)
173
+
if err != nil {
161
174
return nil
162
175
}
163
176
164
177
return &User{
165
-
Did: sess.Values[SessionDid].(string),
166
-
Pds: sess.Values[SessionPds].(string),
178
+
Did: sess.Data.AccountDID.String(),
179
+
Pds: sess.Data.HostURL,
167
180
}
168
181
}
169
182
+110
-11
appview/oauth/store.go
+110
-11
appview/oauth/store.go
···
11
11
"github.com/redis/go-redis/v9"
12
12
)
13
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
+
14
24
// redis-backed implementation of ClientAuthStore.
15
25
type RedisStore struct {
16
-
client *redis.Client
17
-
SessionTTL time.Duration
18
-
AuthRequestTTL time.Duration
26
+
client *redis.Client
27
+
cfg *RedisStoreConfig
19
28
}
20
29
21
30
var _ oauth.ClientAuthStore = &RedisStore{}
22
31
23
-
func NewRedisStore(redisURL string) (*RedisStore, error) {
24
-
opts, err := redis.ParseURL(redisURL)
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)
25
55
if err != nil {
26
56
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
27
57
}
···
37
67
}
38
68
39
69
return &RedisStore{
40
-
client: client,
41
-
SessionTTL: 30 * 24 * time.Hour, // 30 days
42
-
AuthRequestTTL: 10 * time.Minute, // 10 minutes
70
+
client: client,
71
+
cfg: cfg,
43
72
}, nil
44
73
}
45
74
···
51
80
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
52
81
}
53
82
83
+
func sessionMetadataKey(did syntax.DID, sessionID string) string {
84
+
return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID)
85
+
}
86
+
54
87
func authRequestKey(state string) string {
55
88
return fmt.Sprintf("oauth:auth_request:%s", state)
56
89
}
57
90
58
91
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
59
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
60
118
data, err := r.client.Get(ctx, key).Bytes()
61
119
if err == redis.Nil {
62
120
return nil, fmt.Errorf("session not found: %s", did)
···
75
133
76
134
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
77
135
key := sessionKey(sess.AccountDID, sess.SessionID)
136
+
metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID)
78
137
79
138
data, err := json.Marshal(sess)
80
139
if err != nil {
81
140
return fmt.Errorf("failed to marshal session: %w", err)
82
141
}
83
142
84
-
if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil {
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 {
85
173
return fmt.Errorf("failed to save session: %w", err)
86
174
}
87
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
+
88
185
return nil
89
186
}
90
187
91
188
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
92
189
key := sessionKey(did, sessionID)
93
-
if err := r.client.Del(ctx, key).Err(); err != nil {
190
+
metaKey := sessionMetadataKey(did, sessionID)
191
+
192
+
if err := r.client.Del(ctx, key, metaKey).Err(); err != nil {
94
193
return fmt.Errorf("failed to delete session: %w", err)
95
194
}
96
195
return nil
···
131
230
return fmt.Errorf("failed to marshal auth request: %w", err)
132
231
}
133
232
134
-
if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil {
233
+
if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil {
135
234
return fmt.Errorf("failed to save auth request: %w", err)
136
235
}
137
236
+584
appview/ogcard/card.go
+584
appview/ogcard/card.go
···
1
+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
+
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
+
// SPDX-License-Identifier: MIT
4
+
5
+
package ogcard
6
+
7
+
import (
8
+
"bytes"
9
+
"fmt"
10
+
"html/template"
11
+
"image"
12
+
"image/color"
13
+
"io"
14
+
"log"
15
+
"math"
16
+
"net/http"
17
+
"strings"
18
+
"sync"
19
+
"time"
20
+
21
+
"github.com/goki/freetype"
22
+
"github.com/goki/freetype/truetype"
23
+
"github.com/srwiley/oksvg"
24
+
"github.com/srwiley/rasterx"
25
+
"golang.org/x/image/draw"
26
+
"golang.org/x/image/font"
27
+
"tangled.org/core/appview/pages"
28
+
29
+
_ "golang.org/x/image/webp" // for processing webp images
30
+
)
31
+
32
+
type Card struct {
33
+
Img *image.RGBA
34
+
Font *truetype.Font
35
+
Margin int
36
+
Width int
37
+
Height int
38
+
}
39
+
40
+
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
41
+
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
42
+
if err != nil {
43
+
return nil, err
44
+
}
45
+
return truetype.Parse(interVar)
46
+
})
47
+
48
+
// DefaultSize returns the default size for a card
49
+
func DefaultSize() (int, int) {
50
+
return 1200, 630
51
+
}
52
+
53
+
// NewCard creates a new card with the given dimensions in pixels
54
+
func NewCard(width, height int) (*Card, error) {
55
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
56
+
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
57
+
58
+
font, err := fontCache()
59
+
if err != nil {
60
+
return nil, err
61
+
}
62
+
63
+
return &Card{
64
+
Img: img,
65
+
Font: font,
66
+
Margin: 0,
67
+
Width: width,
68
+
Height: height,
69
+
}, nil
70
+
}
71
+
72
+
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
73
+
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
74
+
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
75
+
bounds := c.Img.Bounds()
76
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
77
+
if vertical {
78
+
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
79
+
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
80
+
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
81
+
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
82
+
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
83
+
}
84
+
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
85
+
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
86
+
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
87
+
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
88
+
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
89
+
}
90
+
91
+
// SetMargin sets the margins for the card
92
+
func (c *Card) SetMargin(margin int) {
93
+
c.Margin = margin
94
+
}
95
+
96
+
type (
97
+
VAlign int64
98
+
HAlign int64
99
+
)
100
+
101
+
const (
102
+
Top VAlign = iota
103
+
Middle
104
+
Bottom
105
+
)
106
+
107
+
const (
108
+
Left HAlign = iota
109
+
Center
110
+
Right
111
+
)
112
+
113
+
// DrawText draws text within the card, respecting margins and alignment
114
+
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
115
+
ft := freetype.NewContext()
116
+
ft.SetDPI(72)
117
+
ft.SetFont(c.Font)
118
+
ft.SetFontSize(sizePt)
119
+
ft.SetClip(c.Img.Bounds())
120
+
ft.SetDst(c.Img)
121
+
ft.SetSrc(image.NewUniform(textColor))
122
+
123
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
124
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
125
+
126
+
bounds := c.Img.Bounds()
127
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
128
+
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
129
+
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
130
+
131
+
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
132
+
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
133
+
// knowing the total height, which is related to how many lines we'll have.
134
+
lines := make([]string, 0)
135
+
textWords := strings.Split(text, " ")
136
+
currentLine := ""
137
+
heightTotal := 0
138
+
139
+
for {
140
+
if len(textWords) == 0 {
141
+
// Ran out of words.
142
+
if currentLine != "" {
143
+
heightTotal += fontHeight
144
+
lines = append(lines, currentLine)
145
+
}
146
+
break
147
+
}
148
+
149
+
nextWord := textWords[0]
150
+
proposedLine := currentLine
151
+
if proposedLine != "" {
152
+
proposedLine += " "
153
+
}
154
+
proposedLine += nextWord
155
+
156
+
proposedLineWidth := font.MeasureString(face, proposedLine)
157
+
if proposedLineWidth.Ceil() > boxWidth {
158
+
// no, proposed line is too big; we'll use the last "currentLine"
159
+
heightTotal += fontHeight
160
+
if currentLine != "" {
161
+
lines = append(lines, currentLine)
162
+
currentLine = ""
163
+
// leave nextWord in textWords and keep going
164
+
} else {
165
+
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
166
+
// regardless as a line by itself. It will be clipped by the drawing routine.
167
+
lines = append(lines, nextWord)
168
+
textWords = textWords[1:]
169
+
}
170
+
} else {
171
+
// yes, it will fit
172
+
currentLine = proposedLine
173
+
textWords = textWords[1:]
174
+
}
175
+
}
176
+
177
+
textY := 0
178
+
switch valign {
179
+
case Top:
180
+
textY = fontHeight
181
+
case Bottom:
182
+
textY = boxHeight - heightTotal + fontHeight
183
+
case Middle:
184
+
textY = ((boxHeight - heightTotal) / 2) + fontHeight
185
+
}
186
+
187
+
for _, line := range lines {
188
+
lineWidth := font.MeasureString(face, line)
189
+
190
+
textX := 0
191
+
switch halign {
192
+
case Left:
193
+
textX = 0
194
+
case Right:
195
+
textX = boxWidth - lineWidth.Ceil()
196
+
case Center:
197
+
textX = (boxWidth - lineWidth.Ceil()) / 2
198
+
}
199
+
200
+
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
201
+
_, err := ft.DrawString(line, pt)
202
+
if err != nil {
203
+
return nil, err
204
+
}
205
+
206
+
textY += fontHeight
207
+
}
208
+
209
+
return lines, nil
210
+
}
211
+
212
+
// DrawTextAt draws text at a specific position with the given alignment
213
+
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
214
+
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
215
+
return err
216
+
}
217
+
218
+
// DrawTextAtWithWidth draws text at a specific position and returns the text width
219
+
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
220
+
ft := freetype.NewContext()
221
+
ft.SetDPI(72)
222
+
ft.SetFont(c.Font)
223
+
ft.SetFontSize(sizePt)
224
+
ft.SetClip(c.Img.Bounds())
225
+
ft.SetDst(c.Img)
226
+
ft.SetSrc(image.NewUniform(textColor))
227
+
228
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
229
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
230
+
lineWidth := font.MeasureString(face, text)
231
+
textWidth := lineWidth.Ceil()
232
+
233
+
// Adjust position based on alignment
234
+
adjustedX := x
235
+
adjustedY := y
236
+
237
+
switch halign {
238
+
case Left:
239
+
// x is already at the left position
240
+
case Right:
241
+
adjustedX = x - textWidth
242
+
case Center:
243
+
adjustedX = x - textWidth/2
244
+
}
245
+
246
+
switch valign {
247
+
case Top:
248
+
adjustedY = y + fontHeight
249
+
case Bottom:
250
+
adjustedY = y
251
+
case Middle:
252
+
adjustedY = y + fontHeight/2
253
+
}
254
+
255
+
pt := freetype.Pt(adjustedX, adjustedY)
256
+
_, err := ft.DrawString(text, pt)
257
+
return textWidth, err
258
+
}
259
+
260
+
// DrawBoldText draws bold text by rendering multiple times with slight offsets
261
+
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
262
+
// Draw the text multiple times with slight offsets to create bold effect
263
+
offsets := []struct{ dx, dy int }{
264
+
{0, 0}, // original
265
+
{1, 0}, // right
266
+
{0, 1}, // down
267
+
{1, 1}, // diagonal
268
+
}
269
+
270
+
var width int
271
+
for _, offset := range offsets {
272
+
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
273
+
if err != nil {
274
+
return 0, err
275
+
}
276
+
if width == 0 {
277
+
width = w
278
+
}
279
+
}
280
+
return width, nil
281
+
}
282
+
283
+
func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) {
284
+
// Convert color to hex string for SVG
285
+
rgba, isRGBA := iconColor.(color.RGBA)
286
+
if !isRGBA {
287
+
r, g, b, a := iconColor.RGBA()
288
+
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
289
+
}
290
+
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
291
+
292
+
// Replace currentColor with our desired color in the SVG
293
+
svgString := string(svgData)
294
+
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
295
+
296
+
// Make the stroke thicker
297
+
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
298
+
299
+
// Parse SVG
300
+
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
301
+
if err != nil {
302
+
return nil, fmt.Errorf("failed to parse SVG: %w", err)
303
+
}
304
+
305
+
return icon, nil
306
+
}
307
+
308
+
func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) {
309
+
svgData, err := pages.Files.ReadFile(svgPath)
310
+
if err != nil {
311
+
return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
312
+
}
313
+
314
+
icon, err := BuildSVGIconFromData(svgData, iconColor)
315
+
if err != nil {
316
+
return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err)
317
+
}
318
+
319
+
return icon, nil
320
+
}
321
+
322
+
func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) {
323
+
return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
324
+
}
325
+
326
+
func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error {
327
+
icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
328
+
if err != nil {
329
+
return err
330
+
}
331
+
332
+
c.DrawSVGIcon(icon, x, y, size)
333
+
334
+
return nil
335
+
}
336
+
337
+
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
+
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
+
if err != nil {
341
+
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
+
}
343
+
344
+
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
+
}
348
+
349
+
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
350
+
if err != nil {
351
+
return err
352
+
}
353
+
354
+
c.DrawSVGIcon(icon, x, y, size)
355
+
356
+
return nil
357
+
}
358
+
359
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
360
+
func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) {
361
+
// Set the icon size
362
+
w, h := float64(size), float64(size)
363
+
icon.SetTarget(0, 0, w, h)
364
+
365
+
// Create a temporary RGBA image for the icon
366
+
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
367
+
368
+
// Create scanner and rasterizer
369
+
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
370
+
raster := rasterx.NewDasher(size, size, scanner)
371
+
372
+
// Draw the icon
373
+
icon.Draw(raster, 1.0)
374
+
375
+
// Draw the icon onto the card at the specified position
376
+
bounds := c.Img.Bounds()
377
+
destRect := image.Rect(x, y, x+size, y+size)
378
+
379
+
// Make sure we don't draw outside the card bounds
380
+
if destRect.Max.X > bounds.Max.X {
381
+
destRect.Max.X = bounds.Max.X
382
+
}
383
+
if destRect.Max.Y > bounds.Max.Y {
384
+
destRect.Max.Y = bounds.Max.Y
385
+
}
386
+
387
+
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
388
+
}
389
+
390
+
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
391
+
func (c *Card) DrawImage(img image.Image) {
392
+
bounds := c.Img.Bounds()
393
+
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
394
+
srcBounds := img.Bounds()
395
+
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
396
+
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
397
+
398
+
var scale float64
399
+
if srcAspect > targetAspect {
400
+
// Image is wider than target, scale by width
401
+
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
402
+
} else {
403
+
// Image is taller or equal, scale by height
404
+
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
405
+
}
406
+
407
+
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
408
+
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
409
+
410
+
// Center the image within the target rectangle
411
+
offsetX := (targetRect.Dx() - newWidth) / 2
412
+
offsetY := (targetRect.Dy() - newHeight) / 2
413
+
414
+
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
415
+
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
416
+
}
417
+
418
+
func fallbackImage() image.Image {
419
+
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
420
+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
421
+
img.Set(0, 0, color.White)
422
+
return img
423
+
}
424
+
425
+
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
426
+
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
427
+
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
428
+
// this rendering process to be slowed down
429
+
client := &http.Client{
430
+
Timeout: 1 * time.Second, // 1 second timeout
431
+
}
432
+
433
+
resp, err := client.Get(url)
434
+
if err != nil {
435
+
log.Printf("error when fetching external image from %s: %v", url, err)
436
+
return nil, false
437
+
}
438
+
defer resp.Body.Close()
439
+
440
+
if resp.StatusCode != http.StatusOK {
441
+
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
442
+
return nil, false
443
+
}
444
+
445
+
contentType := resp.Header.Get("Content-Type")
446
+
447
+
body := resp.Body
448
+
bodyBytes, err := io.ReadAll(body)
449
+
if err != nil {
450
+
log.Printf("error when fetching external image from %s: %v", url, err)
451
+
return nil, false
452
+
}
453
+
454
+
// Handle SVG separately
455
+
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
+
return c.convertSVGToPNG(bodyBytes)
457
+
}
458
+
459
+
// Support content types are in-sync with the allowed custom avatar file types
460
+
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
461
+
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
462
+
return nil, false
463
+
}
464
+
465
+
bodyBuffer := bytes.NewReader(bodyBytes)
466
+
_, imgType, err := image.DecodeConfig(bodyBuffer)
467
+
if err != nil {
468
+
log.Printf("error when decoding external image from %s: %v", url, err)
469
+
return nil, false
470
+
}
471
+
472
+
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
473
+
if (contentType == "image/png" && imgType != "png") ||
474
+
(contentType == "image/jpeg" && imgType != "jpeg") ||
475
+
(contentType == "image/gif" && imgType != "gif") ||
476
+
(contentType == "image/webp" && imgType != "webp") {
477
+
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
478
+
return nil, false
479
+
}
480
+
481
+
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
482
+
if err != nil {
483
+
log.Printf("error w/ bodyBuffer.Seek")
484
+
return nil, false
485
+
}
486
+
img, _, err := image.Decode(bodyBuffer)
487
+
if err != nil {
488
+
log.Printf("error when decoding external image from %s: %v", url, err)
489
+
return nil, false
490
+
}
491
+
492
+
return img, true
493
+
}
494
+
495
+
// convertSVGToPNG converts SVG data to a PNG image
496
+
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
+
// Parse the SVG
498
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
+
if err != nil {
500
+
log.Printf("error parsing SVG: %v", err)
501
+
return nil, false
502
+
}
503
+
504
+
// Set a reasonable size for the rasterized image
505
+
width := 256
506
+
height := 256
507
+
icon.SetTarget(0, 0, float64(width), float64(height))
508
+
509
+
// Create an image to draw on
510
+
rgba := image.NewRGBA(image.Rect(0, 0, width, height))
511
+
512
+
// Fill with white background
513
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
514
+
515
+
// Create a scanner and rasterize the SVG
516
+
scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
517
+
raster := rasterx.NewDasher(width, height, scanner)
518
+
519
+
icon.Draw(raster, 1.0)
520
+
521
+
return rgba, true
522
+
}
523
+
524
+
func (c *Card) DrawExternalImage(url string) {
525
+
image, ok := c.fetchExternalImage(url)
526
+
if !ok {
527
+
image = fallbackImage()
528
+
}
529
+
c.DrawImage(image)
530
+
}
531
+
532
+
// DrawCircularExternalImage draws an external image as a circle at the specified position
533
+
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
534
+
img, ok := c.fetchExternalImage(url)
535
+
if !ok {
536
+
img = fallbackImage()
537
+
}
538
+
539
+
// Create a circular mask
540
+
circle := image.NewRGBA(image.Rect(0, 0, size, size))
541
+
center := size / 2
542
+
radius := float64(size / 2)
543
+
544
+
// Scale the source image to fit the circle
545
+
srcBounds := img.Bounds()
546
+
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
547
+
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
+
549
+
// Draw the image with circular clipping
550
+
for cy := 0; cy < size; cy++ {
551
+
for cx := 0; cx < size; cx++ {
552
+
// Calculate distance from center
553
+
dx := float64(cx - center)
554
+
dy := float64(cy - center)
555
+
distance := math.Sqrt(dx*dx + dy*dy)
556
+
557
+
// Only draw pixels within the circle
558
+
if distance <= radius {
559
+
circle.Set(cx, cy, scaledImg.At(cx, cy))
560
+
}
561
+
}
562
+
}
563
+
564
+
// Draw the circle onto the card
565
+
bounds := c.Img.Bounds()
566
+
destRect := image.Rect(x, y, x+size, y+size)
567
+
568
+
// Make sure we don't draw outside the card bounds
569
+
if destRect.Max.X > bounds.Max.X {
570
+
destRect.Max.X = bounds.Max.X
571
+
}
572
+
if destRect.Max.Y > bounds.Max.Y {
573
+
destRect.Max.Y = bounds.Max.Y
574
+
}
575
+
576
+
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
577
+
578
+
return nil
579
+
}
580
+
581
+
// DrawRect draws a rect with the given color
582
+
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
583
+
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
584
+
}
+65
-6
appview/pages/funcmap.go
+65
-6
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
+
"bytes"
4
5
"context"
5
6
"crypto/hmac"
6
7
"crypto/sha256"
···
17
18
"strings"
18
19
"time"
19
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"
20
26
"github.com/dustin/go-humanize"
21
27
"github.com/go-enry/go-enry/v2"
22
28
"tangled.org/core/appview/filetree"
···
38
44
"contains": func(s string, target string) bool {
39
45
return strings.Contains(s, target)
40
46
},
47
+
"stripPort": func(hostname string) string {
48
+
if strings.Contains(hostname, ":") {
49
+
return strings.Split(hostname, ":")[0]
50
+
}
51
+
return hostname
52
+
},
41
53
"mapContains": func(m any, key any) bool {
42
54
mapValue := reflect.ValueOf(m)
43
55
if mapValue.Kind() != reflect.Map {
···
57
69
return "handle.invalid"
58
70
}
59
71
60
-
return "@" + identity.Handle.String()
72
+
return identity.Handle.String()
61
73
},
62
74
"truncateAt30": func(s string) string {
63
75
if len(s) <= 30 {
···
67
79
},
68
80
"splitOn": func(s, sep string) []string {
69
81
return strings.Split(s, sep)
82
+
},
83
+
"string": func(v any) string {
84
+
return fmt.Sprint(v)
70
85
},
71
86
"int64": func(a int) int64 {
72
87
return int64(a)
···
117
132
return b
118
133
},
119
134
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
135
+
if handle != "" && handle != syntax.HandleInvalid.String() {
136
+
return handle
122
137
} else {
123
138
return did
124
139
}
···
236
251
sanitized := p.rctx.SanitizeDescription(htmlString)
237
252
return template.HTML(sanitized)
238
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
+
},
239
295
"isNil": func(t any) bool {
240
296
// returns false for other "zero" values
241
297
return t == nil
···
281
337
u, _ := url.PathUnescape(s)
282
338
return u
283
339
},
284
-
340
+
"safeUrl": func(s string) template.URL {
341
+
return template.URL(s)
342
+
},
285
343
"tinyAvatar": func(handle string) string {
286
344
return p.AvatarUrl(handle, "tiny")
287
345
},
···
297
355
},
298
356
299
357
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
358
+
normalized := strings.ReplaceAll(s, ":", "_")
359
+
normalized = strings.ReplaceAll(normalized, ".", "_")
360
+
return normalized
302
361
},
303
362
"sshFingerprint": func(pubKey string) string {
304
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
25
htmlparse "golang.org/x/net/html"
26
26
27
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
28
29
"tangled.org/core/appview/pages/repoinfo"
29
30
)
30
31
···
50
51
Files fs.FS
51
52
}
52
53
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
+
func NewMarkdown() goldmark.Markdown {
54
55
md := goldmark.New(
55
56
goldmark.WithExtensions(
56
57
extension.GFM,
···
66
67
),
67
68
treeblood.MathML(),
68
69
callout.CalloutExtention,
70
+
textension.AtExt,
69
71
),
70
72
goldmark.WithParserOptions(
71
73
parser.WithAutoHeadingID(),
72
74
),
73
75
goldmark.WithRendererOptions(html.WithUnsafe()),
74
76
)
77
+
return md
78
+
}
79
+
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
md := NewMarkdown()
75
82
76
83
if rctx != nil {
77
84
var transformers []util.PrioritizedValue
···
293
300
}
294
301
295
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
296
327
}
297
328
298
329
func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
···
77
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
79
80
+
// at-mentions
81
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
+
80
83
// centering content
81
84
policy.AllowElements("center")
82
85
+46
-130
appview/pages/pages.go
+46
-130
appview/pages/pages.go
···
1
1
package pages
2
2
3
3
import (
4
-
"bytes"
5
4
"crypto/sha256"
6
5
"embed"
7
6
"encoding/hex"
···
15
14
"path/filepath"
16
15
"strings"
17
16
"sync"
17
+
"time"
18
18
19
19
"tangled.org/core/api/tangled"
20
20
"tangled.org/core/appview/commitverify"
···
28
28
"tangled.org/core/patchutil"
29
29
"tangled.org/core/types"
30
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
31
"github.com/bluesky-social/indigo/atproto/identity"
36
32
"github.com/bluesky-social/indigo/atproto/syntax"
37
33
"github.com/go-git/go-git/v5/plumbing"
···
221
217
222
218
type LoginParams struct {
223
219
ReturnUrl string
220
+
ErrorCode string
224
221
}
225
222
226
223
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
638
635
return p.executePlain("repo/fragments/repoStar", w, params)
639
636
}
640
637
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
638
type RepoIndexParams struct {
654
639
LoggedInUser *oauth.User
655
640
RepoInfo repoinfo.RepoInfo
···
659
644
TagsTrunc []*types.TagReference
660
645
BranchesTrunc []types.Branch
661
646
// 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
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
669
654
types.RepoIndexResponse
670
655
}
671
656
···
700
685
}
701
686
702
687
type RepoLogParams struct {
703
-
LoggedInUser *oauth.User
704
-
RepoInfo repoinfo.RepoInfo
705
-
TagMap map[string][]string
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
+
706
696
types.RepoLogResponse
707
-
Active string
708
-
EmailToDidOrHandle map[string]string
709
-
VerifiedCommits commitverify.VerifiedCommits
710
-
Pipelines map[string]models.Pipeline
711
697
}
712
698
713
699
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
716
702
}
717
703
718
704
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
705
+
LoggedInUser *oauth.User
706
+
RepoInfo repoinfo.RepoInfo
707
+
Active string
708
+
EmailToDid map[string]string
709
+
Pipeline *models.Pipeline
710
+
DiffOpts types.DiffOpts
725
711
726
712
// singular because it's always going to be just one
727
713
VerifiedCommit commitverify.VerifiedCommits
···
753
739
func (r RepoTreeParams) TreeStats() RepoTreeStats {
754
740
numFolders, numFiles := 0, 0
755
741
for _, f := range r.Files {
756
-
if !f.IsFile {
742
+
if !f.IsFile() {
757
743
numFolders += 1
758
-
} else if f.IsFile {
744
+
} else if f.IsFile() {
759
745
numFiles += 1
760
746
}
761
747
}
···
826
812
}
827
813
828
814
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
815
+
LoggedInUser *oauth.User
816
+
RepoInfo repoinfo.RepoInfo
817
+
Active string
818
+
BreadCrumbs [][]string
819
+
BlobView models.BlobView
840
820
*tangled.RepoBlob_Output
841
-
// Computed fields for template compatibility
842
-
Contents string
843
-
Lines int
844
-
SizeHint uint64
845
-
IsBinary bool
846
821
}
847
822
848
823
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
-
}
824
+
switch params.BlobView.ContentType {
825
+
case models.BlobContentTypeMarkup:
826
+
p.rctx.RepoInfo = params.RepoInfo
860
827
}
861
828
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
829
params.Active = "overview"
889
830
return p.executeRepo("repo/blob", w, params)
890
831
}
···
970
911
LabelDefs map[string]*models.LabelDefinition
971
912
Page pagination.Page
972
913
FilteringByOpen bool
914
+
FilterQuery string
973
915
}
974
916
975
917
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
1100
1042
Pulls []*models.Pull
1101
1043
Active string
1102
1044
FilteringBy models.PullState
1045
+
FilterQuery string
1103
1046
Stacks map[string]models.Stack
1104
1047
Pipelines map[string]models.Pipeline
1105
1048
LabelDefs map[string]*models.LabelDefinition
···
1357
1300
Name string
1358
1301
Command string
1359
1302
Collapsed bool
1303
+
StartTime time.Time
1360
1304
}
1361
1305
1362
1306
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1363
1307
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1364
1308
}
1365
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
+
1366
1320
type LogLineParams struct {
1367
1321
Id int
1368
1322
Content string
···
1428
1382
}
1429
1383
1430
1384
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
1385
return p.execute("strings/string", w, params)
1470
1386
}
1471
1387
+7
-7
appview/pages/repoinfo/repoinfo.go
+7
-7
appview/pages/repoinfo/repoinfo.go
···
1
1
package repoinfo
2
2
3
3
import (
4
-
"fmt"
5
4
"path"
6
5
"slices"
7
-
"strings"
8
6
9
7
"github.com/bluesky-social/indigo/atproto/syntax"
10
8
"tangled.org/core/appview/models"
11
9
"tangled.org/core/appview/state/userutil"
12
10
)
13
11
14
-
func (r RepoInfo) OwnerWithAt() string {
12
+
func (r RepoInfo) Owner() string {
15
13
if r.OwnerHandle != "" {
16
-
return fmt.Sprintf("@%s", r.OwnerHandle)
14
+
return r.OwnerHandle
17
15
} else {
18
16
return r.OwnerDid
19
17
}
20
18
}
21
19
22
20
func (r RepoInfo) FullName() string {
23
-
return path.Join(r.OwnerWithAt(), r.Name)
21
+
return path.Join(r.Owner(), r.Name)
24
22
}
25
23
26
24
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
-
return after
25
+
if r.OwnerHandle != "" {
26
+
return r.OwnerHandle
29
27
} else {
30
28
return userutil.FlattenDid(r.OwnerDid)
31
29
}
···
56
54
OwnerDid string
57
55
OwnerHandle string
58
56
Description string
57
+
Website string
58
+
Topics []string
59
59
Knot string
60
60
Spindle string
61
61
RepoAt syntax.ATURI
+82
-54
appview/pages/templates/fragments/dolly/logo.html
+82
-54
appview/pages/templates/fragments/dolly/logo.html
···
1
1
{{ define "fragments/dolly/logo" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
class="{{.}}"
6
-
width="25"
7
-
height="25"
8
-
viewBox="0 0 25 25"
9
-
sodipodi:docname="tangled_dolly_face_only.png"
10
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
-
xmlns:xlink="http://www.w3.org/1999/xlink"
13
-
xmlns="http://www.w3.org/2000/svg"
14
-
xmlns:svg="http://www.w3.org/2000/svg">
15
-
<title>Dolly</title>
16
-
<defs
17
-
id="defs1" />
18
-
<sodipodi:namedview
19
-
id="namedview1"
20
-
pagecolor="#ffffff"
21
-
bordercolor="#000000"
22
-
borderopacity="0.25"
23
-
inkscape:showpageshadow="2"
24
-
inkscape:pageopacity="0.0"
25
-
inkscape:pagecheckerboard="true"
26
-
inkscape:deskcolor="#d5d5d5">
27
-
<inkscape:page
28
-
x="0"
29
-
y="0"
30
-
width="25"
31
-
height="25"
32
-
id="page2"
33
-
margin="0"
34
-
bleed="0" />
35
-
</sodipodi:namedview>
36
-
<g
37
-
inkscape:groupmode="layer"
38
-
inkscape:label="Image"
39
-
id="g1">
40
-
<image
41
-
width="252.48"
42
-
height="248.96001"
43
-
preserveAspectRatio="none"
44
-
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ KJGkgTBUSJI6ry8stIGhDQhTwAwJCu1CfhrYCGwGZoENwCZgS9/7pkgAmKuf0+5QTNbPnagfu9L3 7zYkMCwA54BL9deQYHGlvm+eXkBZrH/P1fpnl+mFj4K7HZLWiaFCktQpNUDcuMMwScLCDFn0tyFh K7Ctvt1Q/9yG+vsd9WNbgO319zvrxxrgInCaLO6n6t87S4JHA5ytN/o+PkMCwTHgDHCNXrg5A5yq f+9ivf9X6/tPA5dJ8Gjftrsd1+qt3eX4S8gwcEhaLYYKSdLYakuXqnY3YANZ2LeBoD88bCMBof/X 2+vHZ+kFjvbX01y/s9H+GrKIX7zh327DzK0+PkEW/m0IKH0fu0hCROn7d88DJ+pbyA7HMeAreuHi PAkdZ4EL9e9u+zkWgCXDhaS7ZaiQJI2VvmbpdvehDQJtydJOYD9wD7CL3i5DGyTaALGJBJAN9Pok 2tsk6/8a2u40tOGkIQGhLX1q6IWIy/XPXwC+Bo4DX5LAcY5e6dU5ElYuk7KqtsfDXQxJK2KokCSN tBuap2folS5tIWFhL7CHXnnSXmAfsJvrdyA2kgAxQy84jJq2vKkNVldIaDhHSqdOkL6Mufq+L0jg OFk/3n5sjoSMhaZplpCk72CokCSNnL7diP6m6V0kPOyhFyT2kV2JvVzfAzFLr4ToxtfCcXttbMfW zpOdjXac7QWye3GclEsdIyVS50j4OFH/zEUSMtpGcJu/JX3DuD1xSpLG1E12JNogcS9wCHgBeIIE ie314+3kprbXYYLeORFdew3sPy8DeqNr5+ntZiyR4PEZ8A4JHZ+R0HGalFbN1c9bBsukJEXXnlAl SSOmlNI/inUrCQy7gfuAB+vtAeBx0ifR9kBM3Ozv000tcf342kv0+i2OkHDxFfAB8DHZxbhcP+ea Z2VIMlRIkoZO3ZWYoje+dScJD48CB+mVNe2rH2snNU0P4v6OsWWyQ3GJlEZ9BLxFgsbx+rGv6fVi LLhzIXWToUKSNDRKKW1p0yzpgdhHQsQDwHPAs6TcqT03Ygpfy9bLItm9+JTsXHxZf/0JCRhfkzMz 2gP6Ft3BkLrDJ2JJ0sDccJL1NNlx2E9CxEP19gBwgISLvSR0tH0RWl9L9EbPXiFh4hgJGJ+TgPEp mSp1ht4hfDZ3S2POJ2RJ0kDUXon2JOk2TDwBvAQcJiGiHfnaP+rV167BakfWtk3d8/TKo06Sxu4/ AG/XX5+h9l+4cyGNL5+YJUnrrpQyTQLDPWQn4kGyK/EE8BRpwt4ysDuolWpP527Pv/iEhIr3SbA4 Shq9zwPz7lpI48dQIUlac7XMqb9fYjcZA3uY9Ek8SALGbrJr0ZY4afQsk7Knk/RO8X6LhIyP6/vb xu5FA4Y0HgwVkqQ10xcmNpEpTfvJLsTDwNPA88Aj5EyJ5oabRld74N4SCQ/tzsV7JFgcITsXZ4Gr wJKlUdJo80lbkrTq+sLEBjLq9SHge8CL9HYldtWPbcLXo3FWSN/FGTKC9jTZufgDKY9qT/K+gjsX 0sjySVyStOpKKRtJaLiPnC3xPAkUT5Eg0Z507etQdyyRSVDXyOF5H5JQ8RE5VO8jUhp11V0LafT4 ZC5JWhV1mtMMabC+D3iG7E48QfonDpKzJ3zt0TJp2j5F+i7eB34PvElG057DcCGNFJ/YJUl3rO+c iRmyA7GflDo9B7xMQsUeeqNgfd1Rq9CbGnUKeAf4d64vi7qAo2ilkeCTuyTpjtRAMUWarO8lOxOv 0pvmtJeMjfVsCX2bQkqjLpPm7feBN8jOxUfkpO7L2G8hDbWpQd8BSdLoqYFihkx0epT0S7xCeicO kKAxhWNh9d3acLqNHIS4k4TUg8C79HovTpZSrjRNszSoOyrp1rxyJEm6bTVMbCS9EfeQ0bAvklKn J8nuxMaB3UGNg/aci6/ILsUR4DXSb/EJmSI1Z0mUNFwMFZKk79RX6rSVNGE/ScqdHqu/PkR6KtyZ 0GppdyTOkhG0v6+3d0i/xWXst5CGhqFCknRLfY3YG8iI2MfIrsSPSTP2TrIzMY2BQmtjmRyQ9yXw R+B10sz9MWnwvmpJlDR4hgpJ0k3VQDHN9Y3YPyHlTg+TUqfpgd1Bdc08OTjvE3o7F38CjpIRtDZy SwNko7akNdN3lfu6d/e/8NezDdo/U/o/3vf5xcXC+qpf+00kTDwCPE3vROwHgFl8DdH62kD6eLYA +8hAgP0kXLxHGrmv+lwhDYYvCJJWpC424fqwMFFvk/VtU99Ok+eZ6fqxkr+iLJOSBur72ylBi8By /Xg7w576vkUyz779WPu2v+yhDSXWWN+FUko7iecBUur0A+Dx+vv92IitwZkkj81NpIdnNwkYu4E/ A1+UUi42TbM4uLsodZOhQtI33BAc2sDQkBf0NgRM9n18hrzIz5KridP17eb6vtn68Wv0QkAbCKbq xydJ3fRC/fhy/fgyKXu4TCbCtMFiAZirn7NE30FapZQ5vhlArgsqXs38prprNE0Oq3uCnDnxH8m5 E1vpPRakQZsiQWKW9PrsI+V4vwc+KqWcIU3c/pxL68RQIQm4rgypf9ehDQdtYNhSb9vq23aHoR0x urN+bCu5irij/rnZ+nefJiGgrdVvw8gmEgxu9vENwEXgJJkCU+q/t1h/f4IEDurnzdU/ewq4VH9/ pb69Vj9voe58XFdu1WWllEkSAg8CLwH/AXiB7E5swzCh4dJe5NhMTnDfSkLFPaSR+23gy1LKnE3c 0vowVEgdVnckJsnivQ0Ms+SFenP9/VayqNxOLyhsp3dQVbvwn62/33DDrS19gixY252IdrejnRpU 6sfbsoV2R2SKhIHLZMeC+jntCbyX6ue0i97LZLZ9GygukvGTbWCZI2HkNGnuvFJKuVY/d4k0e3aq fKqUMk0C4ZMkUPyI9E60pU4O9dCwaieT7adXErWX7Fz8O3C0lHK+aZqFW/8VklaDLxTSmOtrdm4D xBTXL/pnyYLyHvJCvIteeGh3HbbSK2VqdxbaQNH2Taz380lb1gS98izolUq1pU6XyW7GhfqxK8Dx +r4v6q8vkkBxBThff9+WUC3B+JZLlVJmSLnTU8AvSaB4gjwmZvB1QqOj0Bs9+zbwW+B35ETuk3hg nrSmfLGQxlANEm0J0wwJABtJMGibG/vDQztJZS+90qY2QGyof0fbQwHXP3cM4/NI6Xvbho/2frY7 FZfJ4uMYCRILZPfiS3KS74n6566SnZB5ej0hy6MeMmq520byPX8K+Cvgb0ig2MJwfl+l71LIz+sZ 4CMSKl4nI2i/AC7ZxC2tDV80pDHRtyPRNk7PksCwh4SG9nYvCRB7SKhodyH6DzDrv91sLOwoaxvE l0nAmO/72CkSJr4GPiMBoy2TOlE/fp56km/9e0auJ6P2T8ySWvQXyISn75Pyp+2M1/db3bRIdh6P kbMsXiNN3B8A5yyHklafPRXSCOsLEu2OxGZ6k1AeIVegHyE7EzeWMLU7EG1vQ1c09J77psnXpV1E byd9HXPkSudFsji5BBwBPidXO4+RwHEGuFxKmWdEDt7q6594GHgF+CkJFnvJ48NAoXEwRX62D5Gd t930SvreK6Wcbprm2q0/XdJKGSqkEVTDRNsb0TZS7yY7EA/U26PkbIEDJEQ4veebblxAz9TbNrIA WSaBa5GUBZ2h14vRhotPSNg4U0q5Sp0wNWy1233lTveQg+xeJedPPEMeIz4+NG7aAxzb58B2R3Yr 8FYp5SvSZzH0FwOkUeAVKWkE3GRHoi1tupdciTtQf32QXmnTTvLiabPt6mgXHnOk6fsCKZN6lxy6 9TEplTpNr2djKEqk+sqdHgCeB35MGrIfII+R6UHdN2mdLJOfyY9JA/c/k3KoY8BVx85Kd8+FhjTE +hqu2/McttNrqn6IHEr2Arn6vIleQ3X/ydb+nK+u/sP0FkiA+Aw4SibMfFJvX9LrwfjLJKn1Dhd1 h2Iz8CBpxv5r4DngPhI03KFQVxSyk3iCNHD/f8BvyM/vJYOFdHdcbEhDqq/EaTuZwf4g2ZV4CLif 3q6EZwkM1iK9A/baxtCvyKjaj4FPyaLlK9LwfXW9SqPqY6htyP4x8F9IQ/ZuUjrnY0Zd1E56e4sE i38kY2cvDlvZojRK7KmQhki9qjxJr+53H+mNeIHUvt9DFoTbyNXn9nA5F4eDM0Xv4MBCSs8eI83d X5Hdig9JidSH5JTfc9RRtWu1c1EfS9tIoHiZ3gnZ++lWY750o2nyXDpJLgjMkefQT0op5xw5K90Z FyLSEKgLwHZayV7SH3E/mdDzNClXeYiUN2k0LJNxtcskXLxDr/fiU7Kj8TW192I1r5DWHortZETs j0jZ0/NkIbVhtf4dacRdI2WKfyJlUK8D7wGnHTkrrZw7FdKA1NKU9oTrTWQUbHtuwGFS7rSf3jhY m2lHSzttCfJ93UPG+54g42nfrrejwNlSyhVWoe+ilDJFdigeB34O/C0JpZtxh0LqN01KSGfJYItt 9X1vllJOMyJjoqVhYaiQBqBvvGfbeH0/WXA+Q64oHyIvdG3jddt0rdHSfs8myYJlI5m49Dj5Xr9D pkcdIbsXx4HzpZT5le5c9PXg7Kh//09IqHiahFIbsqXrNeQ5djcZejFbb9NkV/FkKWXBYCHdHkOF tE5umOS0g5Q2PUcWfYdIaco+ckV782DupdZQGyQ3ku/vNlLmdpiUYLxHyjCOAMfbvovbCRf1sTVN FkfPAD8jB9s9TR5rBgrp1tpywSfIrvEW8vP5W+BYKeWawUL6boYKaR307UzsJAvJtvn6ZXJVeTcJ Gy7+umGC3gnnB0jvw+PkcfEOKYn6EPi4hotr3xEupkj53GHgF6Qp+xBZKFnyJH239mfycXqlgov1 drLuHhospG9hqJDWUA0T0+TK173kyvHzZPH3MFlQbsOfxS5qe2r6DzTcT66WniTlF78h5VEnSymX uEm4KKVMk0DxDDmDoi152oQlc9JKTZNy1JfJBLdrZAfxZCnF07elb+FCRloDffXt7VjYh0mYeJUs /vaRheQ07k6oV9vdnoL+KHnMPEom0vwJ+Ag4UUq5TG3mrk3Ze8jZE39F+iiexHNLpLsxSe+wyIl6 +3fS83RtgPdLGmqGCmmV1YXeZrIz8QxZ8D1Hrn4dIDXu/uzpZtqdiw2koXsrecw8AbxBdi+OAudK KddIedOzwP9CRsceIrtiBlXp7syS4RnT9bYM/K6UctKTt6Wbc2EjrZJa6tRebX6YhImXSe/EIVKO 4mJPt2uanFmylTTxt2eXvAd8AVwkO14/JldUH6Y3wlbS3ZkgAf0hMur5HHAFWC6lnPEcC+mbDBXS Xeprwm4PrmsDxU/Jycq7sRxFd6YhYfQg2f06SMrovgDOkF6K50mphofaSatvhoT5H5JwAfUcC0/e lq5nqJDuUN+I2M3AfcBTpEzlyfrrR+mdD2Cg0N2YJGVzW8mu18V6a6c+2ZQtrY123Oxz5OLQNJkI 9edSyrmVnicjjTNDhXTnZsiC7lFS5tS/M7EFy520uvonRW0kjz3I87iPM2ntTJJA/xT5WbsIXAU+ KKVcsMdCCkOFtAJ9h4y1jdiHySFj3ycvOHtwgae11wYMSeuj7bF4hExZu1Lfb7CQKkOFdJtKKZNk Isg+0rz3FPADEijurx8zUEjS+NpMJq4tk2DfAO/VYGEplDrNUCF9h7o70W5/Pwi8SHYnnqi/30fK UQwUkjTepslz/sv0hiNcIefIXLnVJ0ldYKiQvkVfudNuEiJ+RMZ3HibNe+0BdjbJSlI3TJHBCU8C F4BTwOVSyhdN08wP9J5JA2SokG6hjoqdJWM8XwR+BrxEpu/sxCAhSV01SS42PUuCxWkSLE5aBqWu MlRIN6i7E1MkOLSTnX5U396PZU6SpJQ/3Uv66r4AzgLzpZTzBgt1kaFC6lMDxQy5AvUM8HPgF/R2 JwwUkqTWRuAA8Cq9U7ffLaXMGSzUNYYKqeo7GXsP8DQJFD8nU548c0KSdKMJYBvwAnCJlEGdBb6q waIM8s5J68lQIfVsITsSz5PeiVfIYXabsX9CknRz7anbT5Ay2XPAEnAcsHFbnWGoUOeVUqbJlaZH SJD4KdmpuI+MkTVQSJK+zSQpg3qFBIpFYKGUcsKD8dQVhgp1Vi132gDsJSVOLwM/JNvYe8ioWEmS bsdWslsxTXYoLpCJUBcsg1IXGCrUSTVQbCa7ES+SsydeJidlbyZXnSRJWolN5FDUHwFfkTMsPiyl XDNYaNwZKtQ5NVBsAR4mT/y/IIfZHajvt9xJknSnNpELVD8j/RXnSX/FwgDvk7TmDBXqlL4dikOk 1OlvgB+QEbIzGCgkSXdngowgfx44CXxKyqDO21+hcWaoUGeUUiZJzesh0kz3MzLl6R78WZAkrY6G 9FXcA3wP+Bw4A3xQSrloGZTGlQspdUINFNvIiNi/An5MriLtx58DSdLqmwQeBf4D8CUphZoDrg3y TklrxcWUxl49JbudyvFz4H8l4WI7/gxIktZGQ8ptHyQXsT4CzpRSzrpboXHkgkpjrZ5B0R5K9FPg l+QMih2DvF+SpM7YBjwJPAOcAK562rbGkaFCY6uUMkPOmzhMpjz9BHiO7FpIkrQeNtA7XPUUmQb1 JTkgTxobhgqNnVruNE0mOh0G/hMJFI+SkbETg7t3kqSOmSb9ez8gDdsngIt1GtTyQO+ZtIoMFRor NVBMkUDxDJnw9FPSQ7GV1Lg6NlaStJ6mgH1kGtRxEizmgSuDvFPSajJUaNxMAXvJE/dfk7KnR0ig cIdCkjQIbdP2E2S34h3gRO2tcLdCY8FFlsZGKWWKNGA/Sw61+1vgKdIk52NdkjRIk8AucqHrCbJz sWGg90haRS60NBbqORTtlKefAb8g/RQ7yRO5JEmD1pDXpadJWe7uekFMGnk+kDXySikTXB8ofkmu BBmaJUnDZhuZRPglKYW6VJu2HTGrkWao0EirjdmbSYhoz6F4BNg0yPslSdItbCQH4r1MGrZPk7Mr rhksNMq8kquRVXcoZoEHgJfI2NinyVUgJzxJkobRBBlv/jg5u+KZ+ntftzTSDBUaSX2B4hB5Uv4J eWLegTtwkqThNkFGnz8LvECatqcHeo+ku2So0MipJU8bgAMkTPxn4FXgHnxSliQNvwaYIa9jT5Gy 3c31gpk0knzwahRN0ztE6JekLvU+Uqfq9rEkaRS0PYEPkxLe+4FN9cKZNHIMFRopdfTebjI54+fA 94F7yRUfH8+SpFEyCRwEfgg8SSYZ+lqmkeQDVyOj7yyKx8lJ2T8mT8aWPEmSRlFDegGfJGcrtRfJ pJFjqNBIqHWmW0jd6avkqs4j+OQrSRptEyRYPA08Cuy0t0KjyAetRsVGMjr2x6SP4jBp1rb2VJI0 6jaQXfiXyAWzjfZWaNQYKjT0SikzZLLT90ioeJpc1fHxK0kaB9PkwtnLZMysvRUaOT5gNdTqFvBO MnLvVbJDsYc0t0mSNA76S3yfAPbimUsaMYYKDa0aKNpxez8AXsQmNknSeGqAbWRE+n1kvKzrNI0M H6waSrWWdIZMd/oeCRUPk5Bhnakkadw0pH/wAPAYGZ/udEONDEOFhlV7HsVhUmP6NKkxtexJkjSu pkioeJ5cSNtiw7ZGhaFCQ6du924lV2peIU+u+/GKjSRpvDWkn+I54Blycc3eCo0EQ4WG0SbgfjJa 7yXgUH2fV2skSeNuE5kE9TS5oLZhsHdHuj2GCg2VUso0GR/7HDng7gnSuCZJUldsIMHifmCrJVAa BYYKDY0bxsf+iASLXfg4lSR1Szuo5DFq+a/BQsPOxZqGQg0UW4BHSWO242MlSV01DewjPYXtjr1r Ng01H6AaFu1VmRfJ+NhHsI9CktRNE2Ti4bP15mF4GnqGCg1cKWUS2AE8SQLFU+TJ1MenJKmL2rOa 7iMlUPfjYXgacj44NVC1RnSW7FK8QM6luAfPo5AkdVsbLO4lUxC34WujhpihQoM2RbZ1D5PSp/vJ iaKWPUmSuq4hZ1U8RF4r7TPU0DJUaGDqNu42es3ZbdmTgUKSpNhGLrgdICVQvkZqKBkqNBD1SXGG PEm+QELFAbwKI0lSvy0kVDxC+g8tgdJQMlRoUCbJ1ZengVfIboWnhkqSdL3NpPzpJdJ/6GulhpKh QoMyRU4LfZkccrcVH4+SJN1oivRVPE6atmctgdIwchGnddc3QvZRMipvDz4WJUm6lQ2kUXs/XoTT kPJBqXVVr65sIePxnsU+CkmSvsskee28l+xazLhboWFjqNB6mwb2Ac+T+tBDeHK2JEnfZRPwIOmr 2Iyvmxoyhgqtm75digfJxKfHcYSsJEm3Y5LsVNwP7MQpUBoyhgqtixoopslp2YeB75H60KlB3i9J kkbEJNnpf5C8lk5bAqVhYqjQepkgzdlPAT8g87Y3DfQeSZI0OibJYJPHgYexdFhDxlChNdd30N1B 0kfxIjmjwsefJEm3p70490y9bcUSKA0RF3VaDw0wSw7veZyMxLPsSZKklZkk058OArtIWbE0FAwV Wg8zpP7zCdJg5patJEkr15DX0D31tsG+Cg0LQ4XWVCllgmzRPkK2a+8luxQ+CUqStHIzZHLiHnIo nms5DQUfiFpr02TK0zPAk+RJ0BpQSZLu3Bby2jqLazkNCR+IWmubyKnZT9a3TnySJOnubCavqdux R1FDwlChNVNKmSYNZYdIL8Xmgd4hSZLGwyZSTryTlENJA2eo0JqovRTbSC/F8+Swno0DvVOSJI2H jaT8aQdOgNKQcMtMq6ZOoGjI42ozCRQ/IGdT3ItXUyRJWg0zJFBsx9dWDQlDhe5aDROT5PE0S7Zj 7yeB4qfkbAp3KSRJWh1TJFDsBGZLKRNN0ywP+D6p4wwVumN9YWIjKXXaQw64O0zOpHiM9FN4erYk Satnioxr308OwTtWSplrmqYM9m6pywwVWrFSyiTZbt1CGrHvBe4juxNP0juPYhuZoe3jTJKk1TNB bwJUO1p2HjBUaGBc7Ok79fVKtLsSO0iIeKjeHgAOklOz9wP7SOOYB9xJkrQ2ZuidrD0LnBvs3VHX GSp0SzVMTJAnrk1k52E/KWt6md5Up60kbEyR4NGGEEmStDYmyOvvdvIa7euuBspQoZuqgWKKlDjt J70RD5OJTo/V236y/doGCUmStD4myGv0DurJ2qWUxr4KDYqhQn/RFyQ2kKsfe0mQeL7eHqA3wm5L /XOGCUmS1l/bV7GjvnUgigbKUKH+KU5bSD/E/fV2iOxIPEPCxdYB3UVJknS9NlRsr2+ngGvYrK0B MVR02A07EztIf8Tz5LC6h+mNqmt3JSRJ0nBo6A1PaQ/BuzrQe6ROM1R0UA0TkKCwi0xyehx4gQSK J0lT9mS9TWCZkyRJw6Qhr+O7yfTFHcCVUsqyfRUaBENFx/SdMbGNlDi9ALxCgsQB8uS0FWszJUka Zg15Pb+HHDh7hJQ/nSqlzBsstN4MFR1RSpmgtzPxAPAo8DQpd3qWPClND+wOSpKklZogQ1WeJ6VP 20m4OFlKOV/ft9A0zfLg7qK6wpKWDiilTJO+iHtJkHgZeI4cXLe3fmwKHw+SJI2aBXLw3RfARyRU fAwcrbfTwJX65yyN0ppxETmm+g6umyZ1lo8A3wd+SEqeDpIGLw+rkyRpdJV6uwZcIgHjaxIwfge8 QwLH2frxeQwXWgMuJMdQLXXaSEqdDpBA8T2yQ/F4ff8Mfv8lSRony8AiCRjngE+Bd8nOxcfAh8CX 9WPXgGK40GpxUTlG+kbEbiPjYZ8jdZaPkDMn7if1ln7fJUkab0ukp+IU2aX4DPgT8DbwCXAcaPsu Fg0XulsuLsdEneq0AdhDDqx7CXiVhIo9ZOfCvglJkrqjPyhcIGHiPdJrcQT4gJRGnSF9F4s2detO ucAccXV3om3E3k8asX8M/IAcYLe7ftzvtSRJ3dX2XZwjAeIY8Md6a8uiTpO+C3cutGIuNEdYDRQb yVSnJ4DDpAn7BVL+tIFeI7YkSdIS6b2YI6VRR+rtfVIa9X59/xw2dGsFXGyOqFLKFOmPeAj4Edmd eITsVuwmYUOSJOlWFoGL9XaMTIr6HfAm6cE4A8xZEqXbYagYMX2H2O0jOxM/AP6K7E5sIzsTkiRJ KzEPnCA9F38kTd3vAZ+TfgwP0dO3MlSMiFrqNEl6Jw6SBuy/AV4kJ2RvxUAhSZLu3BJwmfRXvAf8 HniDNHZ/TfotDBe6KUPFCOgbFbud9E68QkqeXgTuIaVOEwO7g5IkaVws0zul+yjwZ9Jz8SbptzhJ xtAu2W+hfoaKEVD7J3aTcqdfAj8lY2N34mQnSZK0+tpdizOk/OlN4DVSFvUJmRQ1766FWlODvgO6 tbpDsZnsRjxBmrF/ATxDyqAkSZLWwiTp1dxKxtHuBQ6Qw3R/R5q6vyylXCQlUe5adJyhYgj19U9s JtOdvkdKda4xXAAAIABJREFUnl4g4cJAIUmS1kNTb/eSkHEvCRcHSWnUx8DXpZQrTdMsDexeauAs mxkyNVDMADvI4XWvkOlO36c3KtaGbEmStN7aA/TO0Ou3+C2ZFvUZcB4PzussdyqGSB0Xu5HeuNgf Ay+R3Yl7sH9CkiQNTkPG2u8l1RR7yM7FflIS9T5wspQyb7DoHkPFkKiBYpachP094IfAq/X3W8nu hSRJ0qBNkVKojaQkexcJGntIQ/fnpZSLNnF3i6FiCNSSpzZQ/IQ0Yz9Hzp+YHeBdkyRJupUZUl2x hUykbIPFG8CRUspZbOLuDEPFgPUFigfI2RP/iexS7MLvjyRJGm7tYJlHyXla++rtdTIh6kQpZQ5Y NlyMNxetA1RLnraRCU+vkh2KF0nKtxlbkiSNggnSa7Ef2EQujN5DwsUfSVP3xVKKTdxjzFAxIDVQ 7ACeJIfZ/Qx4mvwQGigkSdKomSJlUBtIP+geEix+D3wAnCqlXDVYjCdDxTrrO4NiG/A4CRR/Czxf 32egkCRJo2qC9FgcIsGibeLeBbwNfFFKueSZFuPHULGOaqCYJjWHj5Meip+R8bHbMVBIkqTx0DZx z5JztvaRnYvfkSbuC8CSuxbjw1CxvibJtuAzpH/iVVL+tBMDhSRJGi+TZLfiYXLxdDepypgBPgTO AgsDu3daVYaKdVJKmSRbf4dJoPhb0qC9Fb8PkiRpPLXncE2TXotNZO3zb8CbpZSTwDV3LEafi9l1 UEqZJlt+zwB/TULFk2QEmyRJ0rhr10IvkN2KXWQd9Afgy1LKnIfljTZDxRorpUyRH5zvA39FDrd7 miR1SZKkrmgnXz5T326ut98Cn5VSrhgsRpehYg3VkqcdJET8FxIqHiSBYmKAd02SJGlQZoD7gL8h 66RZ4J9JsLhssBhNhoo1UgPFdlLm9AtySvYh8oPTDO6eSZIkDVRDgsVe4FngMmnYngGOllIuOHJ2 9Bgq1kA92G4rGRv7E7JDcQgDhSRJUmuSjJr9PgkU24DXgPdLKWcNFqPFULHKaqCYJWVOr5DG7KdI yDBQSJIk9bRrpi0kVGwiYeOdUsq5pmkWB3nndPsMFauoHm63CThIUvdPgOfwHApJkqRbaUuhXiTr qHYE7dullDPAgiNnh5+hYpXUQLGRBIq25Ok58kPi11mSJOnWpkgv6nOkumMnmQz1BnCilOLp20PO xe7qmQbuJadk/2+k8Wh/fb8kSZK+XXsC9+Nk92IOmAeWgdOlFHcshpihYhXUw+32kW27/0CCxV4c GytJkrRSG0mfxY+Aq/V9b5Jg4enbQ8pQcZfq4XY7yc7EL8gPwE4MFJIkSXdqA/AEUOitV/8EnCbj ZzVkDBV3oZ5FsRV4FPgx8DJwAEueJEmS7kY7nv8ZEjCmSCnUm3UqlONmh4yh4g7VxuwtwMMkUPwQ eIhs2Tk6VpIk6e5MkLXWIeAl4BRwjd45Fp68PUQMFXfghklPLwE/JWdRbMdAIUmStJo2A4+RnQpI 2Hi7lHLeYDE8DBV3ZgrYQ7bkfkTGn+3BsygkSZJW2zRZZ71ISqEgAeO9Usplg8VwMFSsUF8fxWOk h+J7ZPKTfRSSJElroz3H4gVSAnWJjJw9arAYDoaKFahlTxvImLMf1NuD9FKzJEmS1sYk6bF4EjhP pkBNAJ+UUi4ZLAbLULEy0+RAuxeAV8jhLFtwfKwkSdJ6aEgp1PdJyJis7/uI7F5oQAwVt6mWPe0E niaTng6TB7WBQpIkaf1sIMNy2kqRq8DlUsqngKduD4ih4jbUsqetwCNkh+JF4F7so5AkSRqEKWAX OXz4DHCO7FR8DSwO8H51lqHiO/T1UdxHttpeJvOSZwd4tyRJkrquLUt/GbhAAsXVUsoF+yvWn6Hi u02R6U6HyYP2SWAblj1JkiQNUgNsAu4nVSTHyG7Fh8DFAd6vTjJUfIvaR7GNNGS/DDxPAoZfN0mS pMFryOF4j5LDiM8Bl0op14Br9lesHxfHt1DLnmbJyNiXSOnTg8AMnpotSZI0LCaAvWS34hRwErgM fF1KsXF7nRgqbm2aPECfJ83Zj+H4WEmSpGHTkDXtTuA50lsxB/yJhAwbt9eBoeIm+qY9PUzKnp4G dmOgkCRJGlYbyaTOJRI0loA3Sinnbdxee4aKG9RAsRE4QNLuC2R87Mwg75ckSZK+1QSwg1wMXqY3 YvZD4MoA71cnGCq+aZLMPX6K9FEcwvGxkiRJo2ILKVs/B3wBXCilfNE0zcJg79Z4M1T0KaVMkNFk 7SF33yf1eZODvF+SJElaka1kx+JvSV/FpVLKacug1o6h4nrtmRQvkEBxP5Y9SZIkjZppsqZ7ETgC nCAH410xWKwNQ0VVdym2kbKnV4EnsOxJkiRpFPX3yP6U7FZcAI6WUuYcM7v6DBU9MyTRPkdOz947 2LsjSZKku7SRrO1OAp8BZ4EFHDO76hyRynW7FA+Sfood2EchSZI06iZIf8Uj5KLxvcDGOu1Tq6jz OxV9I2TvI3V3z5BQIUkajP6yBF/4Jd2tBrgHeBb4mEyFulZvWiWdDxUkwe7m+hGymwZ5hySpwwq9 0oRJUppqsJB0t3aQC8enydkVToNaZZ0OFX0jZB8Aniejx3bQ8a+LJA3QIjmw6gyZ3rKH7CZbkirp brRVKS+RsytOk2lQl23aXh1dXzxPkReswyRU3EtexCRJ668A88CXwLskSDxOprdsIc/P47RrUfre 2uMorb2N5LiAHwFfkR2LOWzaXhWdDRV1l2KWnLj4Ehkhuxmf2CV9t1Jvy2SRO8F4LXYHZYFcPfx3 4N/IC/3npN/tEWAXKYcah+fpQv5/banXRsYvNEnDZhLYTi4kfwy8B5wspSy5W3H3OhsquP6gu+eA /bi9Lun2LANXgctkgTtLSinHYbE7KIV8Tb8A3gBeJ1/fz8koyJdIieoB8vUe5efrZbIjc4b8f78g YeIZcqHLx5G0dqZJL+2j9XaUhHubtu9SJ0NFnfi0GTgIPElepDYM9E5JGhUFuEIWu0fJ4vYgGUk9 6ovdQVomB1O1Vw8/r78/V29fk8OrnicDNbYzmk3ciyQ8nSAlXm8Ab5MSjJeAvyHhaSs+lqS1MknK oJ4nZ1dcLaWcbZpmabB3a7R1MlSQlLqLJNQHSK2uJN2Oa2RB2F5Nb8hu5wS9YDFqC91hsECCwxFS 63ylaZrFUsoZsuA+R3YsvgZ+QK7o76G3QzTMX/O2XG4BuEh2Jv4MvAb8DviEBI236q//K3lM7SMX vIb5/yaNognSR/t94Di1aZvsjuoOdS5U1F2KTWQCwAskWGwd6J2SNCqWyGms7wK/An5d3/c1WfzN kheqUbyCPkhLJDQcBT4g4eEaQNM0y6WUdmfoEtmtOA68SoZsHCQ7z1Pk6uMwfd37w8Q8eex8QsLo v5EQcaJ+bJmEjf9Gdmv+K/Az4CHs95PWwjZyceKHZDjE2VLKnLsVd65zoYK84Owk51K8COzFLWZJ 362Qq1gfkUXhG2Shu1g/Nkt2Pb9PDlkal4bitbZMvq5HSIP2e8DF/tnx9ddzpZR2Uku7a/EFKRW6 jzyXb+f68bODChhtmFgkpXKnyH39gOxQ/IH8f880TbPQ93mLpZS2Uf08CSD/kTymduNrlbSaGnKM wNNkd/RTcoHoyiDv1CjrVKiouxQz5AX/EeylkHR7Fkl9/4dkd+I18gJ0hSyKj5GgMU0Wvd8j9bqz GCy+TSFfr2NkId0Gtfmb/eFaDnWufs4FeqNnHwEerreDZKEwQ17j1rM0qg0S10hQOkv+bx8B79fb kXq/L93s0K06geZyKeVNsjNztt5eJeFpZu3/G1JntGvCF8hz0Ed1t8ID8e5Ap0IFeXHZQbaTD2Ht s6TvtkCm9LxPSlZ+RRayF9tt8lqe8ykp47lIrjK/Sp5nttK959rb1X5t3wR+S3YpzpOgdlN10X21 lPJl/dwjZHrfIXLF8en66z3ka7+JXDxqy6NgdZ73+8+YaMcLz9PbRTlGypg+IKHic+opvsC171q0 NE1TSimfkjrvs/Xz/rr+37wYJq2OhqwFHyDPHW8B50opVw0WK9eZF7q6S7GBXOk5TA5U2obbyZJu rpCQcIZM5/lHskvxPmnq+8thSXUBeJUEi/bq8nlSE/8YuZjhc831lkkA+wj4DSkLOgEs3s68+KZp lmqYmyPfo8/JTtKfSK9cu2uxj5QObSe9CTNc33uxkp2M/gCxTB4D82Rn4gp5XBwlofNtEiiOkcfE Ink8ldudh1//j6fIztil+m/8ov7/NuIumLQaJsh68Fny/P4l8FUp5ZpnV6xMZ0IFedBsJYfcPUtS qadnS7qZNlCcA94hgeIfyQL4LLBw44tNbSieJ1ep/51cYZ4nV+OfIRPnFO2ZFJ+TsrHf11/Pr+RF vP7ZJWCplLJASo7aUa27yA7G/fV2oN7uIa8F0yRc9JdJQe8ww/b3y/XfaEPEAgkyV+jtSnxFmse/ rP+PY2RX4jxwV6UUTdMs1AlYf6TX7P0fyVVVL4xJd68hIf0Z8vP7PvnZXqC3I6nb0KVQ0R528jAJ FNuw9EnSzbXjTd8G/oWUPL0PnGuaZvFWn1QXuQu12fYteiduT5KLGVvweQeyOD9OFsqvkTKhi3e5 +F4CrpRS2p2LL0hp1HZSCrWPhIr95LVgK/l+bKIXKqbJ4mKWhI1FejsEcyQInSf9HOfJzsQxEmTO kZ2Xi/XPLaxW+UTtJTlLdnPmSHiaI2Nnd2GwkO7WJHmeeIIM8jlKfu49EG8FOhEq+g67u4+Eit24 bSzpm9pJRMdInf+vSa3/B3xHoOhXy1baXY4pUno5Qw7b3Ey3g0U7lvcdEijeIlOQbutr+13qQr7d VZirV/k/J1/3reSCUntry6GmSKDYRAJFe4jhHAkP7eKi/f0FEjYukjBxZa3rr2tgvVhKeYfsWEzU +/4sCU5dfkxJq2GSXHRoy6DO1QPx3K24TZ0IFeSJdw95QX+U1DdLUqud2nOe3sjYfyU7FV+RBeRK Z5e3J0S/S2+87CR5DurquQPtFK33SaB4g3x9bzrtaTX07R61YeAEee1r6PVHTJFA0R6kt0QvRCyQ 71u7m7HYd1sCltZz0dE0zdVSylHgf5LXslny2ubgEenu7SKh4hPqlDbcrbhtYx8q+nYpHiSzvg/V 30sSZFE5R8px3iSB4nek6fdU/djySheOtXl7kd50o/7a/MdI6U2XgsUC+Xq+A/wTCW2fkJ6DNV+U 13+jANdq/wX9/25ttG+4PmyU+n1s3/+XP37j56+zy2T37P+lF3iexj5B6W5toXcg3pukjNJQcZvG PlSQF4Lt5OrgUySFdumFXNLNtScdX6B3TsJr5GCyT+v7V9Q4fKO6IG0X02/Vd28ki7/H6q+7cHV5 gV4PxT+T4PYhcH4Qp9fe7Hv6bfejL5AMhfq4ukQWPW153XZS4tuF13VprbSToB4mF6PfLKVctATq 9oz1k0+9ujRNGvQepnfKraS1116Zv3GazjBYJLXwJ8jV8nfI7sTbZMv7MqtU1lL/jmt1NOjb9Gr2 p8jQiHE+IK8Nbl+TUa//QHYoPiWBYlX6KLqoPq7OlVL+TMq2tpFzLO7DcyykuzFFem8fIuvGk46X vT1jHSqqzWTix/306mUlra1r9CbktFd+trD+Jxy3+seBXiElSR+RMPEOKSU5ShqI59eo6XaRTAv6 I7m4sQS8TK6GbSMXQMZp16ItK/uaXFH/J3J44EfU0DawezZezpNdtkJ+tn5OXu98rZPuzAR5vXqE XJD+nBvOJtLNjXuoaBczB8ghSF2rYZYGoS11+RNZrG+gd4r9XjKBZ4Ze/fpaLqTbBuzLpATpc7Ko fY+MMf2C3nkCl7nNg9fuRC1ZuUZ2R35LJgedAH5ESjP3kq/VOASLZRLeviTlZL8i/+cjwKVBlDyN qzpu9hT5Ou8m02t20d1hANLdashF6CeA56kjr0sp6zqUYRSNbaiopU8bydbVfeTJdmz/v9KQWCYL yX8B/o4sIjeQMp/H6tt7620fvYDRf/jYalkg5wWcJiVO75Mw8SEpvzlNgsQ1EibWdCQo/CVYzJPQ NU/v8LQTwAtk12Irox0slslO0PskSLxGyr6OYaBYEzVYnCFf792keftFPMldulNT5ELY86Qf7jPy nO3z17cY50X2BEmaB8lV0r345CqtlUKecE+RReTfkWBxmvwsfkzKjO4hV1LvIwGjPYxsF72zAWZI EJmmd7I19WM3/gy3JU3zpNRmjowAPFv/7TNk0X6UBIv2pOOLJEyseKrT3ar/3ny9utyeynyq3s8f AI+TheGoTfIp5P9znLwIv07vjI9TrF1ZmWKB7MT9igTTfWR33tc9aeX6J4c+SkoMz2Go+FbjHira for7yGQMt4KltTFPzhv4PRlz+WuySF6oV+fnyMLyCFno7CAL5wOkZvV+Eiw20TukrH8yUnvScduL 0AaJeXqnHJ+jFyI+JaVNp+kdUna1/vlF1vlsgZupV5cvkcBzkYSdkyQQPUvC1gYG04Nyu9qpSIvk //AZOXvi16T87TPSW7Mw6K/3uOv7OTtCwtzD5HXvIKMXUKVhsZ3sWBwkF0wcL/stxjlUtAfeHSQP Cklro53u83vg/yELyuP0LSTrCdPLXN8o/Tm5iv0GCRS7SA/UDL3Z+xvpnX68sf57F8nC+wq9CVNt cLjU9/ELZOdiqd4K9dyBtfkyrFzTNMu1HOoEva/L8fr7F0nYmiXhYi1KxO5G2/x+iQTGT0hd/2vk wL/j5P808ADXFX2jZt8C/hv5mfkZKTeUtHLbSLXL48DHpZTLTq27tbEMFaWUtnP/MXIg0H7cApbW Qls//2cyLvR16mFBNy4k+2b9LwOLdTF9lewwHCcL5/bk6XYM7XTf+yfplVnNkQVtewX/Wr21Jx2v W5/E3apfl8VSygVqQyAJFe+TBu5HSLjYy/CUsyyT791J0qPyLilve5eEizOk3MlSgXVWg+pZ0j/0 Z3La9h7crZDuxCZSAvUyeY47XUq55IWSmxvLUEFedHcBz5FgsYPhLR+QRtlFsnj5Z+A3pNzl6u08 4baL6XqbL6Vc5vqf05udb1H6btzq/aP4hF93c9rG8YtkJ+ctUsbyZL0dImVj/RO01ssSaWw/Q0LP Z6TU5iMSJNpJWpdYwylaui3tMIA3yONnB+lhGoZAKo2SadIL+DzprfiYugM7yDs1rMY1VGygd+Dd Hsb3/ykNSiGL36MkTPwrWVhevtPF5C1OLe7UE3ffQXlnSLD4kuxYvE12LZ4kW/EH6ZWLzZIXvrUo j1oioW+O7Ch9Rm9n4n3y/T9DgsQcKXkb+t2hcVd3Ky6SK6u/IYFiP905wV1aLW3D9v1k13gvcKqU su5DPkbBuC62N9ObKOMJ2tLqWyRXQn9PDjT7ALjik+zqqIvCa6QHpd0d+JQ0P99HGtwfIAHjfmAn 1weMW51ifuPZIG05Wvu2vS2Rq90XSZj4mgSKd8n3+jOyW3Gh3sdlhqxfRSyQ3qIPSOB/kfE5B0Va b20Z1EHyXDw/2LsznMYuVJRS2uPVHyEPgNnB3iNp7CyTaUt/JmVPfwLOWz+/uvp7UEop7W7BGbI7 sJWUtOwjAePBemvP/thEQsZmUvKyUP/aDfVjG0hwmCMvjldIgGhPQW9LnI6Svolz9KZrXSBBZx7L nIZW35kon5LdpdPkMTNMzf7SqJgiz7X3k5+jC3jC9jeMXaggOxM7yTd/FzanSavtMlmk/COZ3X3S aRhrqy7cF4CFUko7JeorcgV6K7mQso9sze8gwWETeT6cIWUv/e8r5EXxEr2+lkskULTjeU+T3ajz 9BrjF6m7GYaJ4Vf7dL4mfU/vkcfHTgwW0kpNkufY++vbrzxh+5vGKlTUU7Rnyfi8B+mNoJS0OhbI AXKvkVrtz3Fu97qqPQvL9AJGOz3rQzIdajO9Eijqr7fW97fN3ddIqLhS/8wkvala8323K3jGxEhr mma+lHKETGY7SCYibh7svZJGzgS5ePMEqYT5iDyPukPfZ6xCBfmmbyW1xg/jLoW0mgpZwL5FTsv+ mLtozNbdq1/7JWCplrpcJgGhv2digt5p5O37276Jtqm64fq+ir9M0/L7OxaOkwsBD5BgYaiQVmaC 7PI9BRwGfkcuzBgq+oxrqNhHvvnj9v+TBmWZXJU5Qp5M38Y+iqHSBox6yOBN/8gNv79pWDBEjKXL pFTuT8Bfk/JgSbevITu995IBGbvJrr079X3GZtFdS5/aULEFJ1xIq6E9BfsyGW/6Gpn4dJxe86+G yLeEAsNCRzVNs1jHFL9HJkJJWrl2vOxecvF6UynlqmO0e8YmVJBv9kZyLsUuxuv/Jq2nQmrpT5GG 4LNkEtDHpI/iPVL25BOpNDqukiurX5CyjS3YsC2t1BSphLmPTNg7T6+MtPPGaeE9Qb7BB8gZFbP4 hCndjrYuf5HsPlwk5xC8RYLEV2Qxcrz++pxlT9LIWSIXCN4mhyg+jofhSSvVkAl7D5GL2Cdx1/4v xilUTJJv9AGyNeUhP9J3K2TKz1myM/EVOZvgbRIqviRXNS+Q2tFFdyik0VPPrbhMyhcfJa+T+/F1 Ulqp9oTtg8BnpRQHllRjESpqP8VGUuN2kJQ/uUshfbdrZBfi30iQOEpvV+IMKZlY9BwKaSwskmEL 7wLPkddMXyulldlE1pqHyM/SKTwIDxiTUEGvK38nufKyY7B3Rxp6hZy0+y7wDvArcs7BKXqnJXvA mTRelkh/1IfkjJmnGJ91gLReNpKdiufJRLVPMVQA4/Nk0pDyp81k+pOlT9LNLZKeidPkROx/IguM T0ivhE+M0pjqK4H6jCyELGWUVm6alA8eJg3bm0opc16EG59QAQkVsyRMLNXfS4r2MLOzwB+AN8ju xJvkQLs5XGBIY6+Olz1BdirmSCmHpJWZJuWD+8nF7Au4WzE2oWKKTH7aT8bkSeoppHfiHAkR/xfw OtmduEB6Jjp/hUXqkPNkp+JzUsphsJBWpj0XbS8puT9eSlnq+mvpuISKGfKNfQDPqJButETKHd4A /hn4F7KgcGKF1E1XSKB4i7x2zuDuvrQSEySQ7wK2k3Vn50/XHpepD5uAe+htQ43L/0u6W4tkktO/ Av8n8Pfk7AkDhdRRTdMsAF8D75P+qs6XbUh3YIqEij3YywuMweK7lDJBein2km/uhsHeI2loFDIW 9jXgv5Edis8AG8okXSTjo0/jFVbpTjTAbnJRezNjsKa+W+PwBZggQWIHaZqxNlRKoDhPxt39H6SH 4uumaTpf8ykJSJP2MXLg5ZUB3xdpFDXkYrZVMtU49B40pAt/Q30rdd0ymfL0R+DvgN8BJx0XK6nP AtnJ/BpDhXQnGhImdpMhQZY/DfoOrIIpsu20A7+h0jK9HYq/J2dRHG+axvIGSf36z6y5OuD7Io2q WbL+3MJ4XKi/KyMdKkop7S7FTnIAyQwp+5C6qJDTsD8A/gH4n8BH5HRsSeq3TMLEWQwV0p3aRNag u4ANdV3aWSMdKqpJMs7rIL3D76QumidjIv+x3j4ArjZN46F2km7UhopzwCWcACWtVENK73cD95Lz 0jq9WzHSoaI2nM6QpDiBgULdVYCTwG+A/0FGRV4yUEi6hWUy9elyvS3gTr+0UlOk/OkA2a2YGezd GayRDhV1m2kL2anw4B512VnSR/E/gbeBc055knQr9flhgRyOuYBjZaU7NUvOqthBxwcGjfo2zSTZ btpL70RDqUsKGQ35HvAr4LfAaXcoJN2G5XpbwPIn6U7NkDXoVjoeKkZ2p6LuUrSTn3aTYGGoUNdc Az4lgeKfgc/qabmSdDsWSbCQdGfaULEdmOlys/bIhoqq6Xs7iT0V6pYlMg7y30hj9vuOjpW0Am2j 6S4cyy7dqWmyS7Ed2Mjor63v2Lj8xwtZYFlDri65ALxJr4/i4mDvjqQRU8jUuPbCnKSVa0PFDgwV I2+63hYxVKg75oCjpOzp34GvbcyWtEKFXIzwRG3pzk2RZu1t9e1UV0ugRj1UtI3a23DbVt2xDJwA 3gD+BfjSsidJd6AhfVlzpFlb0spNkKMNtpE+3842a49yqGhIc8wu4B56Z1VI46w9NfttskvxQf29 JK1UQ8o1CgkX7nZKK9euR7eRMqgZOnqhe9QX4VPkmzeN9aDqhnngCPBrUvZ0tmmapcHeJUkjbGN9 606FdOemSahom7U7aZRDRUPuf8H52uqGRXJq9mv19ikuBCTduYYc2rWTjp8ELN2lSa6fANXJnYpR P9eh3Z1wxrbGXSHTnt4G/gl4q2maSwO9R5JG3QQJFHtIg6mkO9MfKjbR0VAx6jsV0+Sb19mtJnXG PPAZ6aP4PdmxkKS7sQycI31ZBXsqpDs1QYL5LKN/wf6OjXKomCSH9uypt06mQnXCMnCK9FD8DzLt yRd/SXdrAfiQXKj4CJu1pTs1QS5wT9Ph9eiop6m2MWYbox2QpG+zTKY8/Xfgg6Zprg74/kgaD4vA x8DfkzDxc+BhYAu+pkor0YaKGbK2niilNF27ADjqoWKy3jqbCjX2CnAM+Ld685AqSauiaZqlUsoZ 4LfkuWUe+CXwOJ7/JK1EO555MynLn6KDg1RGOVQUcv+XyME90rgpwCWyQ/HfSR+F42MlrZoaLM4D b5Hnl0KqAJ6gw1NspBVq+3y319sGsjZ1p2KETJInQetANY6ukFrnfwDeBa51bStV0trrCxbv0TvE ayNwiCyODBbSd2snQG2ho6dqj3KoaOs9l/CcCo2fBeBz0pj9R3LInYFC0pqoweIc8CYp4ZgkpVAP MdprBWm9NGT6U/vz07kwPpJPFKWU9uC7CZIGPbRH46SQaU+/B/4F+MJTsyWttRosTgOvk0XRxnq7 lxFdL0jrqCG7FG2o6JxRfpJoyP1vj0V3UoXGQQGuAu8D/0jKES4P9B5J6oymaUop5RRp3p4iF+5+ Chz6hBLrAAAgAElEQVRgtNcM0lpr6E0k7eRo2VF+gmi/WRvxJFCNjyVS9vRrMu3pvGVPktZTX7B4 nV6J8c+AB+jgQkm6TQ3pqdhGR3uRRjlULJO682vkCc8SKI26ZeACuUL4K+ALOjiSTtLgNU2zUEo5 CbxBqgH2A/vIYsnKAOmbGlL6tIWsSSfp2Gv4SIaKehVlmYSJK/W2iQ6mQo2VeVL29K9kvONVdykk DdAC8DXwOxIotgKH8XA86VamSPCeAZquHYA3kqGiWiYhYoEsxqRRtkSas/8V+ANwqmma5cHeJUld Vi/gXQM+IaduT5Fdi4fJhTxJ15snr+ednP40ylcaGvJNW8ZzKjT6LpKm7NeBo03TGJQlDVy9uDEH HCUT6d4kF0A6VdYh3YZl4AxwnqxJS5d2KWD0Q0VDvomO29QouwocIdOe3gLODfbuSFJPXRhdAt4B /m9SDnVmoHdKGk6L9E6m71y1wSiXP7XbStN0tMteY2EROE5Knl4HvmqaxiuAkoZK0zTLdSLUG2QK 1P3ADvL6Kykm6Z1R0aldChjRnYp6+F1720zGdxkqNGqWydW/D0lZwUdk10KShlHbuP2HevuSDl6N lW6hoTf1qZM/F6O8U1HIVd5pPKdCo2mBvCj/EfgzqVNeHOg9kqRbqLsVF8jz1S4yZvZecl6UpPws TNMbJtQpI7lTUes7C2nQvorTnzR6lslj90MSKj4G5rvW1CVp5CwBZ8lgibeB09i0LUFCxKZ6mwYm a2VNZ4xkqKjaqU+XgMt0sHZNI20BOElKCN4FzjZN48ABSUOtXvhYIIdzvknO1rlER8s9pBtMkhKo KdypGCnLZIfiAhnHaajQqChk5NyfSdPj53ilT9LoWCa7Fe+T57CvsGJAKqQCYW7Qd2RQRjlUtOVP hgqNmnngUzJC9m3gnGVPkkZF0zSlaZpF4BgZMvEuGTHrbqu6rJCfg7Nkfbrctdf2UQ4VkKsll7H8 SaOjkBGybwB/Iidn+0IsaRSdJxdGXiN9YZcHe3ekgVui16TduZLAUQ4V7fSnKxgqNBoK2Vn7kPRS fIYjZCWNrnkywe5NcoDnBXwtVrdNkb6KdqBQp4xyqIDUobflT17t1bBreymOkFrks/i4lTSi+k7a /oSUQH1FBxdSUtV/TkUnX9tHOVQUeo3a50nA8MlMw2yRTHw6QnYp5rpWbylp7CyRA/H+DHxAdl87 V/YhVTNkbd3JM6dGOVRAvmlXSfnTHIYKDa92tvsfSenTcTp6JUPSWGl3K94hz2/HSJOqr8fqogl6 5fmOlB0V9QrvMlmYLZBQ4dURDat58mL7B+AjPOhOY6qUMlFKmay3zr2odk19Hmt3Yf8A/AY4hRdN 1D3L5LHfHgjZudf4qUHfgVXQHoJnqNCwWiS7FO+Q8oAzdPDJRuOpBocpsu2/id72f8mHS0Ovxrgd rLFEB8ctjqv6fZwrpXxEQsVjwE7GY40h3a5l8vp+hjpSdrB3Z/2Nww/8Anmhso5Tw2qelDv9mexW 2EuhkVZKmSBBYZoEiW3ALmAPsKV+rOn7MxvIhZ8TZFrQReBiKeUysOBY5bHxNZkE9RHwMLB5sHdH WlcNeb2/ijsVI6k9vfAcToDS8LpKwsQH5FwKT8/WyCqlTAOz5Er0LmA/cC9wsL7dQXYrpugFio3k 4s+X9fY5OdfgM+DrUspF8iLs7sUIa5rmSinlc+At4AUSMkd9nSGtRHtBZRlDxcgp5OrXeQwVGk7t ZJT3gKOkoVEaSaWUSWA38ARwGHiIhIq9JGBsJ4Fjmry4trcJUg7wJAkXJ8nV7HfJuS2fkuDdnkSr 0XWO9FY8Qx4rBwd7d6R11emRsqMeKqAXKi6QK13SsCikfvwoKX36imyNSiOlljttITsRzwCvAC8C D9T3byQ7Em2YmOCbk09mSZlUqX/PfcCj5OfjCPkZebuU8gVwxR2LkTVPzq34IymB2kUeHzbta5y1 xxxcJK/7nRwpO+qhopCrWhdJsGjH2PnkpWFQSMPWR2Sn4nzTNPb9DIG6SG5vDb3TT5fp681yYfuX r9V24BHg5Xp7HjhU33+7z7dtjwXAVtKLsbv+PU/XtzuAX5OgMXf3914DsEim37xJGrYPkgA5M8g7 Ja2xNlScIjuu9lSMqAWSCi/Qa9ae/NbPkNZeIY/Nr0gvxRd09MrFMKgTiNrm4nZK0Sy5ut5OKmoP LLpWb1dKKdfo1fp3KhD2TW3aRnYUfgz8EniOlDtNc3cXcKbILscsqb3fTa5ozwNXSynHgWsGu9HS NM1ybcB/H3iDhNHd5Ps9smPspdt0kZQ5W/40apqmKaWURVKje6G+XcRQocFrS5/eAv5E6ow7tSgd Bn1hYgNZHO8kC+IHSFnGlvqxGXLVfTO5OPExqfc/Tq48nakLpaUOLXLbHYqngB8BPyU7FHvJ12w1 tMFlgvRmvEiexxeA3wJfllIWOvQ1HxeLpJesbcY/TH62DBUaVw250LJERw++gxEPFdUy1zdrL7B6 L3jSnVomi6MjpJTDhdE6K6VMkRDxAPBgvR2kN61oG7ky3vYBtH0B10iQOEF2mN4jZ4x8DBwvpVwa 9wlefT0UjwM/J4HiCVY3UPRr6t97EHiV3k7fNdLU7S7fCKnPdfOllFOkAf88ToLSeGvP67lZP1ln jMMP+DLZLm/Hyo71i71GxhxZiL6Ph92tuxoo9gEvAT8jtd0HSBnGVlJy0x7S9o1PJ8HjMKmNPUx2 Ld6vtw9qM/HFMT5fYSOpg/8h8Nek5Gkba18Xv4n0VkyS5/SzpBTqwhh/rcfZBTI++Bj5eZzG3QqN p2Wyy32NXATpZGXCOIQKSKg4T69ZWxqkJXKl+5/IVe55dynWR73Cvgm4n5TS/Cfgb0ip0xS9K0jf diWpv6F4N7li/wDwfbJb8TppJn6nlHJ23HYt6tdwJwlTP61vd7M+i8GGXrB4hewWnSNB7qo/RyPn EhkX/C7ZHZytN2nctCV/p0mVQpdKZf9i5ENF7atYIFdEDBUaBhdJmHiXHHZn6cb6mSVnIfzvwC9I k2h7wvOdaMty9pKF9kESMA4Cfwf8vvz/7L1nkxxXmqX5eKRGJpDQBAGQoGZRgaIUS4vu6p6e6e6x td39m/tlx2xnp7tna0qwWCSLWpNQhNbIBFJnxt0P5156IAmVQEb4dffzmIUFFAlHhPu997zivCFc algUfRRldb6P+im2M/jo8gjKLv0MCfSLlBFAUx/mUQnhp6h8bj8WFaaZrKFz6MX43qQ94Z6pvaiI LKNo1kXUHGtMVSyjxsS/oI30WrWX0w5idH0rOgSn7MSzqIznQUmZi+QcNYS+5xlU5vZxw4TFbuAl VDq2j2p61AqUXXoeRbrfR2WEFhX1IvUnfYVKoJ5H36sxTaRAZ9DWztlpiqhYQbW3Z9FGv0pz/m2m XsyjwU9/A846S9F/oqDYBbyCshO/QkO3NkNQrKdAkdbHgZ9T1s1+GEK4XGfb2eiUNYGas3+ERNlU hZc0jDJETyBxcxTVLJuaEO1lb6BsxVmUxV3DDo2meSQn0jla3NvblIP3Kko3nUXRrGW0aLW2A99U QhdF5b5EA+88PbvPxIPwJJry/C/A39P/EothFG19Mf44bSYLIYT5GguLJM5eoJxFUfXhL5We7QMm QwgzbY0A1phlFPRLZSErtNwhxzSOgLLW54iB7RBC0ca1qhGiIvZVLCFBcQVFsyaqvSrTMtJcii9R qcZ5XKoxCFL9/28oXZ7SpOx+MoRmOLyEvveLSFCeCiEs1nQzGUHNtE+hz7RqQQG6pu3ourYDl0II HohXI3r252uUfY+2fTdNokBBxJPEadptXaOaZO22hpwmruG+CjN4VlGU4h0kKuZqHLGuBTFLMYXK dH6GmrIHObW3g7IkT6JyodcobTNrRU9PyjPo89xNHpHkIfQdH0DX1I+SNtN/ltGB6xu0R7fywGUa z3UU1G7t3t8kUdFFouIySrF60TKDoovuuQ+Ad1H9sLMU/aeDypAOI0emKrKTBfAQ6uf4ITr81jEK O4wyAa8gkTRBHqICJCT2otKscfK5LnPvrCJR8RXK4np/Nk0izaiYR+V9FhUNoItU4jmUfvKiZQbF MnI2eQsNR2ut88OAmUIH4FdQKVJVn/kWNCjuWSQq6miZOYr6Fp5BB/ic9oZhlEWZQlkgP1v1o4sm o3+JxEVrD12mkSyigHZyBGzt/Z3TxvGgdFHZ0zn05XrjMYPiBhITHwHn7PjUf2Lp0wFUdvQSEhVV rmejqDxnP7AthJBDP8JGmEQuS3vJL9PSQRmKEaCVzY91J5aC3kDOeCdoqYe/aSwLSDRfQKXPrV2j miQqAooYp2ZtH+zMIFhD99snqF7Y/Tx9JgqKEVTy9BLVzVLoZQQJm32ojGi02su5d2I/xU7k+rSH /HpCOuj77Z2IbupHFxkanMVDak1zSEPvziJR0WrXxyaJCijnVVym5SkoMzCuocnZ7wLni6JorT/1 AEmD0Q6hsqMcmneHgG0oe7If2BoP63VgHImJR1CZUW7XPYQyKWNAJ4pKUzNi9PY6ckmbw9kK0wzW 0LnzJLq3W30GyG3zuG/igrWGDnmXUKq1tSkoMxDS9Ox3UKZittrLaQ3D6OCek/VpOvjuQ/an08Bw TQ7Ak5SN0FVnfG5FcoAawfMN6s4iOnx9iDK8Fham7iQ7+Qvo3Nnqe7oxoiKyRtmsfRYtYMb0g1T2 9CnKUpx1lqL/xOj/BIqqP4MO8TmsYwUqedqBsihT5CF27kgUPVvR57iL/EqfQJ9tatDuYlFRW2K/ 2RHgv6MJ6d6jTd3poHL7lH1rdTA7h81404jZikXgOPAesq5z7abZbFL/zgkkKD6l5SnPAVIgUfE4 yggMkc8hcwg5P31bqlPt5dyZKCiSEDqAshU59oKsogz0dfTcuay13qQetOO4B83Um4B6KG4gO9nW r01Zb3r3yQoSE58BZ2h504zpG2so0vYF6uFp/WIyIEZQ/f/TKLqei6AAXctwfEE90uBpMvhDSAzl uCcsomftHLDQZmeVhjCPBMWXyILTmDpzBVnKX0bnzVavTzluIA9Kapo5hr7ohWovxzSQNdS38wXa HBc9PXtgjKOypyeRy1JuomIEZSmGqIfQ7KDPcQ/5uistoQDRRbye155YJnoZlUFZVJg606UUFZeA 5bYHPRonKuIXOod6Kk6itJQxm8kiirJ9jHspBs0W4HuoQTu36cqpnGhLfO9mvsGkUrJdaMbGEPlF 2QKKbKfBUi5nbQaLyOTiatUXYsx9kkqfLqEs6jU8yqB5oiKyjDrxj6Iv2pjN5DpyL/kC318DIw6U m0a9FDvIr6m4N1MxfJc/mwMFEhT7UXP5CHmJNNDh8wKKBM46I9gYVlGZ8hnkmufv1dSNLrp3T8fX DXwfN1ZUJBeo0ygS4kiy2SzmUYP2x8AlH3IGyhQaePcIqv/PkQJtLAEYytxStkCzNXagrE+O+0ES FUdx1rlJrKLv9W20lra+Ft3Uki4KLF7F/btAnpvIAxMPemkzuojKobxgmQeli+6pD5ERgOdSDJYJ lKV4FJUY5UZyn7tMPe6NDhJq0+jzzE0ApdKnc6iU1f0UzSEgkXgclUHN4z3a1It0v95AQezcy10H QiNFRSQ1gx1FGQtHQsyDsgKcAv6GshU+5AyW3vr/HIe0rSExcQ5FrtZy3WRiBmUcZSl2ocxPbqKi iz7HVHvf+nrlphCfi1XU5HqeeCir9KKM2Rir6IyZ7t8s1/pB02RRkRas94GP0KbkRcvcLwGJiOMo UzGb64GxiYQQRlBEfQ95zlKA0nnuMvlPVh1CQ+/SfIox8hMVq8hw4ytcWtBEuqiS4Bv0zOT8vBiz niU0o+oYFsXf0mRRkVLn36Av3bMEzIOwhkowPkH3lF1oBsswaih+AUXYc2QFHZKuAkuZi84CCYkD KPOTG6k85hTKCs67f6lxpD36KnL28vdr6kSaiXYJ2crnvN4PjMaKivgFr6Aa+BPx3ZEQcz90USTi beCvOOs1UGKpzhga0PYoeWYqUunTGbTRzFd7OXdlCPVT7I7vuRFQA+QF9Lm69KmZLKGKgkt4fzb1 YRGV1R9HAWsHGSONFRWRNRQBOYluAG9M5n5YAr5GguJz8o9CN43kUrQLNRQPVXs5t2SVcujmBfIv 1xkHDqLSp9zmfUBZvnoJN/E2mUUkwk/hg5mpD4sogHQCuFYUhQVxpOmiArQhnUGK0j7CZqOkiZlv otKnGZdhDJw0T2E3+a5ZKyiyfgxtMrkfgseAp1H2J7d+ioAOmKmJ16UxDSQ+I0vI2OAodmk09WGB cuhd7lnpgZLrBr0p9JRAXQSOoGyFo15mI8yhDe9d7Pg0cGLp0wgSFQ+hjEWO61Y6BJ9C90zuDKHP cpL8hggmS/DLaO2+gdfsprKGxPhpJB5dTWByJwnhL6hHVnqg5Lg5byoxLTWDylfeQW4iHoZn7pW0 gBynHhHoprHe+nSK/NatgATFF/E96/UlhDCMnLTSfIocy8lST8UM9n9vLPF7vYGqCS7gEiiTN2so 2PEFqly4hrOoN5Hb5twvFpBjzztEJ5FqL8fUhBW02b2P+nIWq72cVlKgxuyp+Bohr1KdxGVkL1iH Up1xZM37EJr9kRtdtEafR5u2aTBFUaR+pPN4jTV5E1DZ01eo1NWudOtohaiI2YqrwJdIVFyv9opM DUiR0o+APwLn4+ZnBk9vqU6OEes0aPMU2mRyvMZehtHnuY88RcUqKns6gyOBbWERPT+z5PmMGxNQ puISulcvkXlWugpaISoiy6iM5RgqUfDCZW5H6sU5iUTFEdxLURUFOgTvQeVPuRFQwOIc6qWowwE4 9ansIE973jT07hI6bHqtbj7zyFnvBPXoSTLto0vZ/3MWnQns+rSONomKlK34CtXHOyJibkcXRZ4/ RqLC0zKrIx2At5JnPwWoFvwoEhZZR65i4/skEmlTSLDlxhoK/FzD9s1tYQGJik+RmDQmN9IE+GPE rDQ+Q36HHDfoftFFEZCvgQ9QRCTrA4CpjGQjewT14vhgUx0dVKozgQRGTv0UKaN1FUWurlCPyNU0 8Aj5WclCOWX5W1FR7eWYAbGCnqHjKKDjII7JjWUUQEqlT8s+F3yX1oiK+OUvoxviQ9S979S6WU+g rO89jhYP91JUxxDl0LscD8BLSFRcBRZz32Ti9U2ifoqxii/nVqR5H+eAWQ+Vag1d5AJ1DomKVbw3 m3xI5hFnUa+XK11uQ2tERSTZyx5DUWiXtZj1rFKWPn2BDotePKojZSq2k2dT8RJyrblY9YXcC7H8 aQwJizHy2wNWUJbiG2yo0SbSoe0cepbsAmVyYpVyuOlZYCH3AFJV5Lah9JWeYXgXUPnTFRyFNjez jBaNz1BDllOc1dNBPRWT5JWtSG4g59E9k/V90tNPsQ3ZyuZoz5syhWex9Xdr6Nmbr6PAn+vVTU4s o6qF47h64Y60SlQARE/hWaQ4P6dMtRqTBjF9gxpvPeyueobRwbdDngfgJXTPLFKPrOcW5Pq0jTyH 3i2gNXkW97y1jS4SFen7r8PzZJpPGniXnCBnPJvi9rROVEQWUKbir6hx2xZ2BsrF4wNkJ2sb2QoJ IXSQ5ekkeR6A0yGoTs3EyUlrO3l+ptfRszePD5VtIwX8zqMqAn//JgduoCDj31DA0eeCO9BWUbGK Fq53kfq8Uu3lmExYQSVPbwEX3CSaBaPAXtRPkdshI/mWp/kUuWe1UqZnDNnJ5pb5AYmKNKsg98/T bD7zqLzEDlAmFy6jqpYv0VnRGdQ70EpREUta5lB93CdIYHgDazdpgvZxHI3IhfRM7kA9ALkdMgI6 BF+jHvX/aebHeHzlKCoWiDX1FvWtI/VVXMOZCpMHAa1H3yDXJzdo34VWiorIKlq4PkXZilPUw2Pe 9IdVJDB/j9xH3GdTMXHxDuggnHorcqODBMWNGmw2BSp92kaen+c8Kn9ZwM9fW1lF98BVfA+Yaumi rNmXlFkK35N3oc2iIqDN6xgqd/k0/jz3g4HZfLqo7Okd4D1grgYHxMYTQhiitD0dIr8egDV076zW KKq+FQ2/y63xPa3HM6iGuS6fp9lcVpG4TL1KXodNFSQXumNortnXaF1y9uwutFZUxEPjKlKiH6Js xQzezNpGKoX7AHgf2Q37HsiDYVT7P0x+a1UapjkLLEe71twJqOxpgvw+T9B6nMrJvHm3jLgnryEx MR9fXotNFaRhjF8je/mz2F7+nshxYxkY0RZsEbmNfIFKoDxlu12k5uz3ULbqmu3ismEIHYJzLNVJ ouIsEqW5ZVFuReqpGEMN8Dl+potIVPgw2U7SELxrOMhnqiPdh8eRccQMPhfeE60WFfCtsJhB3f1/ RcKiThaR5sFIkzKPY8enHOlQCouc6MTXFWpgSR3tebcge94tSFjkJCq6qPzpOh581lpiJHiB0gFq udorMi1lHs2k+AJVL6w6S3FvtF5URBbRofJN4GPsPNEWkqD8AtVOzlZ7OWYdAX1HW1CEPTdSpnOJ /COqBcpOJOen3KZp9zpprWJR0WaW0H1wCdt3msGzDJxD5dBf4yzFhsgt+lcJRVF0QwiXkaA4AOxB B5ntlV6Y6TddJCDfRZZxi9VejllHQAffKXQgzok1yina3ZpEsQrytZMtUMbnetUXYipnFdWzX8ai wgyeG5QN2qexjeyGsKgoWaIcfPYQsBu5pOS2+ZrNI01E/gK46oUjOzqUcxVyW6uSS00SPnUgoGBJ joMEQRHCJeoxSND0jzUkMF3+ZKpgBpU+HUHnAtvIbgCXP0Vib8V1dCN9iJq3V/Dm1mQuI0FxEffR 5MgIylKkMqic6KIDT+5lT70UaEbFVvISQsmJbxHV09sso9100Xo8h0WFGRypn+c0OhecxdULG8ai 4mZW0dCdI2jYyXksLJrMZWQX59rdPBmhPADn+Ax20X1TB2GReipSpiKntT+gQ+QCyv4s2oGt1aSm /TSp3veCGQRraGr2Byiw7HPBfZDTxlI5sfxlGSnVdGPN4EWtiXTRpnUKmLXrU5YkS9ncmopB11Og w08dNp4CCYpkJZvb2r+KhMUSnlrbdpKouIT2X2crTL9Jcyk+A94GvgLmXRK9cXKrU86BNVQO8z7q rdiLDjZT5HewMfdHGnh3ETVqe9PKmxHymwORRMU8sBJCKDLfgDoo65Nbw3siZStWyE/wmAFSFEUI ISyitfkKKkEZr/aqTMNZQiXv7wGfoIoVBxrvAy/e6+jxyT6Bmrb/Gn+8UOV1mU0loNKnI8jloQ6R 5jbSRVHrHCdqJ1aAlcwFBZTOTylbkSNzKFpY1GRCuekfyQHqCt57TX9JZe8fowqV08BSDdb0LHGm 4hbESMksSoH9BWUsdqIN2dSfFGWeRX7UjkhkRjxUFkhYDJGfqEjN48Pkl0W5HV009C7XqO88OkgG b+itJ000vhzfjekXyQHyLUonSJe83ye5bdTZUBTFClrQPgE+QlMVTTNYQ4eXWdwUmjM5R6tTk/YY eQ7mW08HfZ5j5JepCOiZXKY+je+mvyQHqBnswGP6SxIVH6JGbTtBPgAWFXdmEdmKfYTcoK7ipu26 E9DB5Qgqa8OlFvnRE6keIs+5BUlU5JhFuYl4fw8j16dR8rzeZfSZ5ur0ZQZLMk2ZxeVPpn+soN7K r1BPxQ1nSR+MHDeXnOiiRe1j4P9DrgBzeNOrO12UgfqEetTDt5UOiqyvkWf0OlCDtSDe3x3KyeS5 XXMqdVnFe5IRyQHqAtqDHcwzm00X9U+8i4x5LuL+ygfGPRV3IPZWLKNsxTvAw8ATqNxhjLzLM8yt 6SJheB1lorxZ5UuyPl0lv++pIE+r29sRKNes3D7L3jkVdmIzoHt0EdnKXkOHvWSHbMyDkoYdf4Z6 KY5gC9lNwVGhuxDr7ReQS9CbwBuo7s6Ktp6sAEdRg7ZrJ/MmlRjlKCrSDI1hoFODErpAnta8iWTP 60yw6Z0ZNYv6KlwCZTaLgATrMeBvqLzdg+42CYuKeyAKixlULvM/UDnUVbz51ZEVVDt5CtdP5s4q cQ4E+T1raUJ1h7IJOmdSX8VYfM/tejtoo7fTj0msIZF5jegKVu3lmIawikx43kMVKCeQYYvvr03A 5U/3SFEUqyGENBTvMWAPanycJN/on/kuaeFYxNGv3FlBh4rUwJsTqTQrWcvmThI/Y/GVE13s/GS+ S6oSuIpKVXzoMw9KGnx7FPXIfo4NeDYVi4qNsYQae/6ANuZV4HlgOxYWdSA5P60Bq45MZE/KVORI OqQPAUXOtsSxNCv1f+ToVtVFkehl8hOPplrW0Bowj0WFeTBS79YZ1Jz9MXAeWPZZYPOwqNgARVF0 Qwg3kLodoYz8vYSyFiZv0qKSGv9M3iSv+lVKp6WcDp0d9Nznvo520Ho1Tp7Bj4BERbLoNQbKINB1 dH8Y8yB0kZvYhyhLcRyYs6DYXHLfDLMjlkFdBT5Fm/RuYC+wn3q5wbSRVKN7GpgLIXRyjjAboCyN ya0sJgmcSfIbJncrhoBpdK1d8stWLKLv2OunSaRm7RlsK2sejIDuoS+Av6Lm7MtxyLHZRCwq7oMo LC5T3qB70We5D3+mObOKFpZTKPpl8qaDnqdUspbjgXML+U/UTsMDt6PMam6RuTRR2z0VZj1LKEsx h9bvOgh4kxfJ7Sn1UbyDzFo8qb0P+AB8/6yierx3gG3Azvi+lTwPP0ZRrytIUCyT3+HK3EwRXzlm KkDR/zSlOlvivJ01tN4Pkd/61KWndt7lCCYSKO2k049zK4E0+bNC2UfxFvA1Knty5qsP5JYCrw1x 45sHvkHq9010s9r6Ll9Sjf4a0PXhJXuSs9ICOlDkRG+fwmgIIdu1NF7bGGWTdk6HsnRYLIA1lyOY daygoMJSfPkgaDbCGgokfoTOaJ8CV4qiyG0/aQzOVDwAPY3bX6OheNtRpuIx8i+JaCuppCangwZ6 K7AAACAASURBVJW5NenAmYRgTiRHpSHKDECuB54R1PuRBEVO934qfVoFuiGEwmLfwE0Ztt5Bkznd uyZvAqpK+BqVqX8AnCmKYrnSq2o4FhUPSFEUa7Fx+yNgB2rc3oLmWLhxOy/SALDk3GXyJh04U/lD TiRxCjr0jIQQcrUpToey3LIUiWQpu4qu1VFEkwjo3t1OKYyNuRvJ6fEUmpqd3J5ytShvDBYVm0Pq r3ibclP8EXCAzOutW8RNEdGKr8XcG8n5KUdRAaVATVOqcyaJoNwOZUnoXEONkzmKHlMtnrRuNsoa pX3sm8hUZzbToE+jyH0jrAUxTbuIHAWgbDz8GfAo3ihzINXmX0NR0dzKacx3WUPf2Up85WaFOoSy khPkPV8hrUej5HmdQ+h7XiJP8WiqZQbVxfveMPdCF7k8fkaPfSzOgA4Ei4pNIvZXzKHG7YA28GnU Y7GdvA5DbSVFvheBNddvZ88a+q7SALw18nqO0pyKXIfKJVLD+xR5Zk5X48uWsmY9HbQG3MAZZnNv LFDax76LSqCWvNcPBouKTSQKi15HqB0okvl8/LF7LKojlX8MUdbpm4yJGcAkAlMZVE50kKiYIC+x cyvSteYmKlZRaUsXCN74TSKEUKD7Nk3WdqTZ3I0l4CylfewR4IbtYweHRcUmE4XFArqZ/ydaDBeB lymbt83gSRtUgQ4wtpStB2kKeo5zRQo0myZlAAryu0YoG7VzDGikEjdnKMytSPdsb39V7gLeVMMa cAm5PL2Byp+uFEXhtWWAWFT0gR6r2a/iL3VQxmILKoUy1TCMorVT+N6vCwHVx6Zodk6k8qdtlO5K uV0jqJE8lWDmJnpsoGBuScxUdtG+OYnuk9zuX5MHAfXdvA/8OxpKfA4JUTNAfLDqE1FYzKKMReqv GEGlUNPkGTVsOsNog9pGfmUgZh2x/KFAgmKR0gUql2enQAf2LURRkWmfzggSFUn45ETv1HSLCrOe lJlI90huz5apnjSI+Avgz6js6SQwn+Fa3HgsKvpIFBYzwJdoQwdFW15AwiLn5s4mkspAcm+sNTez QJ6HzjQAb4LSASrHidBp+vcE+a35vcJxJVNRZqojzRuYQ+tAbmuAqZ4l1Jj9BvAX4Bjuo6iM3DaY xhGH411DtmZrlIeOV5AzlBkcHUpbzVyi3ebOdCkzFan8IZfvrkD301bKkroc+yq6aN1JRgU5kZyp 5tDhwJj1zCNb2UXye7ZMdaQ+m3PAH4HfA58C1y0oqsOiYjCsAlfRDd9BB5DdwGPk20DZRIbRZ5+j C465NavA9fjKbY5BmtC+DQmL3A7sqYRsiHwnySdnH5snmFuRjDWS3bDvDwO6D26gMqf3gP8APkZz qNyYXSEWFQMgbpQrIYQrSFikw8cvgWdQKVRum30TST0VY8BICGHIzhD5Ehs1kztQimTndqjoUJbU 5Ugq0RojQ9FD6f6U3NmMAW4SxMkAIYlP024CylB8g3oo/oAatC8CKw5MVItFxQApimI1hHAJPQBp oM8cat7ehe1m+80QqiufouyrsKjImzW0gcyhZyYnUj/AODq0p0bynAiUwif9PCfSnArPIDC3okNp hjCGhafRWnEOZSj+F5qafRYPuMsCi4oBE4XFFdRjsUxZL/4iKonK0aGlSYwBO1G5ygi2nNswIYQ0 SHCN/pespEbN6yiinRvpwD5Gfgf2lO1JvR85ip4uWv/myLPJ3VRLgQJBO+PLZ5Z2s0ppHftGfD8L LFpQ5IEf0AqIzdvXkSvUGhIWV4FXgUdRJN30hxEk3nYAoyEE285tgCgoptEGfx01UPazwbaLxMRV lNnLjRRJzTmKmkRFjnMqUj/ZLPllokyF9JQ/JoONZIRg2st14EPUQ/EmcApnKLLCoqIiorCYQQPy FtHDkgb9WFT0jyEkKJJvv9kY24AfAz9Bi/tfgDN9/jtTs/YN8jsUJyGRmrZzdH9KwidHg4JUN7+M MxXmu6TBiKvo/sjt2TKD4waaRfFvqJfiKDBnp6e8sKiokJ4BecfQpjpM+Z3sJ78DQBMYQlH2XSit bu6BEMIE8AjwfeAfULleQI4bff2r0YHiRnytkk/EMh2Iu/GVy3WtJ8TXGPldX/rsIL9rM9WT7gmL ivayilydvkI9FH9EQ4WvO0ORHxYVFRNTvPPIGu1PlPXFvwAOoMxFrmUVdSRlKvbF95M4QnpLetxX poBDwG+Af0RleksMruQniYpUIpPT4bhDGU3N9TktyLcReg3dS2mGRq7XaQZMXH/GUPBnnHxFu+kf qYfiQ3Q++iMqG79hQZEnFhUZEIXFIrE+EB2eZoGfA0+hkhMPbNscUk/Aoygb9FUIYc0p1JuJG3rq P3kWlTv9GjiMmtwvouhRvxvdU6biOmVfxRR5HeADOhxn5yQWQhimtJPNcRNOcypG0H7kAXgmkZq0 dwMPYev1NpGyq9eBT1APxR9Q+dOsreDzxaIiE3qExXlKm8VLaJbFYWAPjtRsBh2U/XkYOIg2qkXs AvUtPW5Be4HXkJh4HXgClY6lBb3v5QjxuVihnKp7HR0ycrFfTtkc0DXmJk7T/IdhtK7kNJEcSkGW 47RvUy2pT2kryipPYFHRBtIcitRz+h+UGYoZMgzemBKLioyI6bylEMJFFLGbQ84388BL6CA8SV6H grqRUurPAK+gtOpMCMFDc0omUCbnB8CvgB+h6e+TlAe/FP0eCiF0+pzpSQ5QMyhbcZA8JtGnKPsK el6XM7yH0hTiJCq65Hl4z+1zM3kwjPrfdmFB0RbW0ByKD5HD0/9CguJaURQuj8wci4oMKYpiJYRw DaX6VihLP15Bte3T5BOprSPDqF/lBeBJ1Cjf+gFcsVRmCgmIn6MMxSuoTKz3ED8Uf55KVtLE275d Gvp+LqJM3pPcLHCqIqAs1zl0bbllKZIZRJfSTjY3UdGJr2Va/vyZ75CyynuQqKg6iGD6TxdVaLwH /DsSFUdRU7bXhxpgUZEp0XJ2ltiUhB6088APge+hhTZ5z5v7YydlSc9VWnyoCSGMoHKn76H+id8B z6Gyg/X9PKnfYuQWv9cv5oDTwHEkBtN1VUlA1/U1Eqa5Rtt7xU5uB7M0g8AliGY9Q6ifcCf59VGZ zSX1zs0A76KSpz8hl6d59zzWB4uKjFnnDJWExTngAuWgPLtD3T/TSFQ8ApwNIbRyKmcIYRSV1v0S +C26tw6hWubbHdyH0b3X9zUkPgcLaHLqEZQV2I9KsKpkFYnR0+jZzHXjW6WcRp6jqBihbMw0JlGg NXoc19E3nQVkVPMu8HvKDIUFRc2wqMiceMhdjH0Wi0jJX0IHqx8id6iduBzqftiKPr9X0AC3ecrD V+OJ07G3IWH1IyQovo8Ext3sYocY7ADBZC14HH1Xz6DvryoCEvofoixFzvfNKsqo5HgwS/MHOuQn eEy1DKPs6U48s6nJLKPA6RvA/wT+hgSGB9vVEIuKmlAUxWrss0jTty/F14+B59HcBbtjbIwJ4HHg p+hgeL4t2YoQwhCKAr6AxMQvuLms7m6kBsqBDBCM2Yo5JChOUbpAVXW/L6KN8C107+Tc6B9QI3lq 2s6J5HTXwXMqTCQGPCaRqNiN1hmLzmbRpexJexsJijdR5rcV+3ATsaioET22s2dQlPQKKoW6yM0l K7aevTeG0Yb1MvApSrfOhBCWmhohWTd/4mXg7+LrGTa2cafJ5IOcSr4MXEYlUCfRv2GKanor5pCR wifAhVybCKN47FC6VOV2Xydnr2R9awxobZ6mnE/hs0qzWENnmG+A95GgeAudbSwoaowf1JoRH7bk DvU5Koe6gJq4f4QGlaVos4XF3UmH45+hmv2rwMkoLJq4sI2g8qa/A/4PdM9MsfH7JUUSB1aWEJ2M ZoCPKAX0s1Rjs3wdlWKdRZH2nEk9FfOUg+ZyWRuSLW9u8zNMtQyh5/sAEhWmOayg4b5H0UC7P6M1 3YKiAVhU1JToDpWauOdR1uI8EhiHUfOxHTPuToH6Al5EadgTaMG7TMNKMUIIUygj8VvgH9Bgu133 +b9bRgJsicEeBueRI9pfUWnELiRsBllzfRX4GKXqz6JNMle66Prm0X29RJ6lJKvk2fNhqiGty48g pzfTDNbQmeVDtIb/FWV8L5PnnB+zQSwqakzPsLzzlMPBLiLFfxh4GqWPB1miUkdSf8HL6JC4CLwX QriUa1nLRoh2sVvR/fAr4J+RiHqQCOAaitavMcDyo9hbdBmVqz2EmsULJDD6PRBvBQn3j5Dl4fvA TM4bYSyZXKXsxVoir76KIWQKsEje4swMlmGUgZzEJiRNIGUnzgHvoAnZf0PlTzNFUTig0BAsKhpA zFrMoI35KooEHEGlLa+haE/qtTC35wAa+JYmJH8QQrha1/6K2D8xiqL5z6OG9F+i5uxtPFgWK/23 I/HvGKT70RoSzm9RlvK8TOla1Q9WkOD8M5rw+hfgbFEUdTgIp/Knmfie0wC81EuxXNfnzPSFCRQo KND9a/en+tJFlRSfIiHxFspUnAQWcg7KmI3jQ2ZDiPXmyUlhFkVUTyInhR+hQWZ7KSfYuizqu0yg 2R8/RunYeeDzEMJs3Q480T1lDPXXHAb+HngdTaPexoMfKpNbzygwFEIoBrU5xOj7dTR0Ll3LCMpU pHt8s0hTqC8hIfH/oE3xNPUZ1rZC2Xs1iw5pOUR/06T0KzSs1DARhT0+OG2YSWR1vZX8SvXMvZHW zmvAZ2hC9p/Ruu1yp4ZiUdEgepq4r6MDT0o3fo2Excsoar2PcrH2gn0zk6jvYI3SO/+TEMKNugiL eJCZRBmql1H25SeouXmSzYlSF2j9qGS+QBQWN1CzX5rK3EH/3t2Uk74f5NoCpUD/APhvSFCcol4R thVUFnkEZVsepf+lYvfCGvos/4RET6OIwn4Yie7GOsptNj1214+ifoocBLDZGMnG+gpyyPt9fH2F RMZqjdZPswEsKhpIT9ZiGdVRX0RZi89R9OcwOjjvxi5R6xlCG9mL6LOZQpOjPwkhXM653CWKiQl0 /U+h4Yg/BV5CE6i3bOJfl/6uISqq0Y9lf7NINHdQac9FdG+noVmT3N86l6xOP0YZirdQLfA56udQ soo28mMow3KDPJpf00TyE+Q9PPB+GUU9P1vQ575U7eXUhgm0N+1kcMM1zebSO8fnDbR2HgFmm9Cn aG6PRUVDiYee5BB1Cm3ex1G0+iQ6HD1PadnnaFBJmhb9MioVmkYZnvdCCGeA+ZwOlTGyN4Ku9SAq dfsBZdnbNJtf7pYsH/vVw3BP9PQTfYqyCidQQ/ozyG42RTuTQ1TvmreGxMMaOuAuxddc/H9dQP0T f0COUzN13BBjkGEB9aGcRGvBfqpf/y+gTNMszXR+GkV9PgeBxRDCeTek3hNT6HPbh3sp6kbqnziK hMS/of6Jc9Qru2vuk6o3FdNnesTFdRQNvExZEvUK6h9Ik5THcb9FokAH5qdR1Gw/2uTeAI7Ez3Ot ykUylleMosP9HtQv8QrqnUg9NOk73Ww6SKxsoSw1qipj0Y2lUMfQQfUTJCZeRE3ph5Ao3Is+qw7K 4i0jEbGAMnoXUHnQ6fj6Bm2OF9CGWOfylVW02X+DyrmepvzeBk0gOqwh0dZE++aUyTuExP0q8G4I 4WLN76NBMImE2NNsbnbV9IcUmEm9W+8gd7y3cblT67CoaAk9/RbJq/4yiup+iQ6hL6Ma/O0o4u17 o+wb2AX8AlmYPoamf74PXAohDNy1Jh5Yhigjes+hw/P3UNnTIfQ99rO0bQjdJ8nysdISuthjsYQ2 tuvo/j6OfND3IZFxAImKdKhNsxGW0GZ4Lr4uoej5DeLAuAZsiMkC+Gx83eC7mZtBXstR5ATzBZll /h6U+HwOo2fzNTRoch+6394IITTq37tZxM9tBGUWd+PseR1IZgvnUMDiM1Qu+i7KjM7VMbtr7h8f HFtGPAAvhBBSv8Ul9PB/joTFYVQ2sh1nLBId9Hmk2Q4PA4+jQ9GxWH6zNIjShhDCMNp0U5nTD5Gg OBB/fStlr0M/SZHYKfJo+v02K4cycyvo4Jyyctvja4JyivNq/PEaEhk3UOnTUvy9blMOfz2i6xjl 4MBX0eFt0PtAoBR9Z2hYliIyjtaJQ0jQ7kX/3m/QmtEEobrZpOzwM+jedOlTvqR1cw71SryDzCw+ RwGD82hPdFauZVhUtJRYi76AopYpSjuD0s0HefA5Bk1kCyox2omyOk+gEo4jwIUQwiXg+mZFZnoy EqPo+9iJypweQ1mJw0gI7mPwcwcCKhtK0cXKRUUv8cC2Gl8LIYSraL1La17aFNPBrguEJh/04jN/ GpUl7ESCYiq+BkVA5RAfo9KIGw38zDvoOX0aPatT8ec/Qz0ty/G9iWLqQUiudYfQOutMRb6kQaBf oWbsvyBBcQEJDZc7tRSLihazrpn7CmrgXMF9FXcilUOloYKHKaMznwAn4gF2AR0eVtGBFdZFvpOH PeWBPFmgJivKCZR52IuyRy+iQ8ohJCR2IKFTxXe1ipyWkrDImp7yv9Wen7eOoijmQggnUFTxCZS9 Ochgsk0rKEPxAZr38Rnls9EkxtAz+gJ6XifQM/oKKqubQett42x0H5AOKjF9CH2G2a8rLWMN7WnJ Ze89ZAf9N7T/XaMZpaLmAbCoaDk90fBdKDr0JIqseUG/PR3K8oadyLL1CjoknUZuW5+g0o6rlPX7 S7HsbA19vkPxlQbJjaDNdAId9h5GpRNPoYzEs/H3Ui18leIvRfqJ11CLjcQbHqDD7IfoPlpDtsOP o3urX8/9Kurh+iMagvU+DcxS9JQnPooOx0lQFCgz9BLKbH4eQlgsisI2s3y7D42hz+cFlJn1HpQP yejhKOqD+gLtcZ+jaod5GlQuau4fiwrTQQv4s6g+/ykU/faCfmdS2c8IEmGpuXCR0mXnBuVU82Sb OULZTLyEDuMddPjYicTdLlQusTe+p1/fOpB/2b2RhNUUOgx4M6kPK6hM4V1KB6gRlLnox57QRUL7 fwH/N4psXmioveoYEhPPoCzFtp7f66AM4+vo85hHfRam3IcOonXP85PyIM3ruYBKFv+KMo3HUbYi 9RN6/TeARUWr6YkOPQp8H6Xn7bpxf6SyKNDB4fH440to8Z2PPx9DYuIa5TCs3lKn6fi+FYm7UQbf L3EvdFD98zS6dlMTepq2z6ED/hjlkMd9bN6+0EXlEleBPwP/HdVfN1JQRIvnaRSgOYzME9bPcZlE /VC/A46HEK6gPqy2H8om0D70BAqiuPy2elZR8Os4yiy+Gd+PIzGx4kZssx6LinaThry9hETFIey4 sRkku1fQQe0Rbt4k03yEZD05wneFQ+5RuuT+NI0OSkM0c4BZI+kRFqeRc0sqhXoFRYq3UM44ud97 8Toq9fkI+G+ombORgiIygoIyL6Im7e23+DPD6ND8CmrcPg98EkJo7WCwHjH2PSTIdt35vzB9ZhmJ hovcLCg+Q/frfIOfYfOAWFS0m3F04H0Npetdx7r53CriNkqZDUrN2XWjQPdPyqyM2CazXkRhsYwO /jOoVO8DZFX8DGUj9wiluOi9V0PPe7fnlSaUfwz8X8D/QP0UTT+MjCFR8ShaS2+XYRxGZY2/QY3r 14CTIYS2lpGMUbplHcCZzypIz+4y6pH4G3KJ+wTZUF9EQQKv8eaOWFS0lNhQuBMt5IewoBgkdRUS vRRIHE2he2cc1d56w6kRPa5YF9Ah4jgqUXoa+AEyCHiIsiQv1bp3UW9Gmkh+DdVdJ6OC00isfAac bElD8k4kxh5H2bvbPeOp7PQ5JCwuAX9Axg6L/b/MfIhZiikU3HqWm3tQzGBYRc/vadSI/Snqt0qZ iTlc6mTuEYuKFhJ7KbahSORryPEppyZgkz+pdGsrimZPomi3N54aEjMI10IIN9Dh9hjyoH8TNc8e iu8j6OCbLJPTAKyLSEx8gyKdqYlzseHZCQBCCCPo8zmMMhV3i7anxuQXkaiYB/4aQjhbFMVyP681 M4aQaH0JCbJBzkxpOykwcBG5wb2LsotHkMC4Aiw7M2E2gkVFy4iCYgTVTb+EopH3sgkas55UC70X uV+dq/ZyzIMSBzeuxsGY5ykH5R0C9sc/NodERbJGXkbuZlfi+xKw1pbDSIy270KZh5fij+/V7GIP ct2bi6/FEMKFNnx2cS+aRp/bjyjnpZj+E5A74SkkKP6ASp5OoGd4GVvEmvvAoqJ9FCga9ARqFnwK RZmN2SjpULAf1ZLn6FJl7oOiKLqx3wKUeThP+f32Nm+neSVrtGAq+W0YRevoa2hdXe/4dCeS69FP UWZnFk2Ab7QjVBQU4+hz+wnaizwfaXAElKF4G/U8vY2yE4u08xk2m4RFRYvoyVLsB15Fm+AOfBg0 988YKuOYxjaQjaLnYJFEw0r6vZ5p8K0eKBhCGEIlgM+gnoA9bHw9TcLil6i+vYscuZrchzKMmrJ/ BfyczbUyNncnufeNUmYal9w3YR4UP8TtooNKGV5F6WZbyJoHZQgJizEsKlpDm4XEOlIp6TPokHw/ M346SJh8L/54DZgJIRwFGmc1G4XYLjQE8NdIjG0ku2MenAIFFJ9E2bW/Ud57xtw3FhXtYgxlKV6m dNrwQdA8CMOojGECryemRcRsTRIDL6Asxf0yjA55L1BOMf5/gSMhhLmGRZC3As+jAYCHseNTVUyg PpZnUbP8BXqykcbcDz4EtITYTLgV2R0+jjIWLnsyD0Iqp0uTwMdDCJ2GHYCMuR2jwMOoJ+B5bj3s biOkfrfDKACU3LWOhhDm656xiCJsAkXGf40+t71VXpNhEtn5PoLsZOeqvRxTdxylbg/jqG71WVS/ a+s+sxkMo+nLvZO1jWkDW9E8j9dRpHczmowL9Bw9Bfxd/H8fRIK9tk3M8drHgMdQH8XvkCBzYLNa xtH38DSwJ4QwWuf7zFSPH+gWELMUaS7Fy6j21zWsZjPooOhjmqw9jFPopuHEg9dOVJO+m83tTUsZ i9dQpmIM+CNlKVStMhZx/5lA+84vkFh6FgUjTLWMoLK9V9D07AvIGtq9Fea+sKhoB8OoMe4ZlKbf ibNUZvMYQ6Uf2/CaYtrBJIq6v0B/nM/SULjX0eF7G/B74MsQwtW6DBSM4msSBbR+hDIUr2K3uFzo oLX7ZeB94GtgNoTgGRXmvvABoOH01LHuR6JiH85SmM1lDB0SdmA3MdNgemy5DwHfR4exftlyd1DP wY/i37ETCYsPQwjnyXzaccxQTKLSmt+isqdX0L/JgiIfxlBJ9PeQC9QplG3O9t4y+WJR0WDiBjiM 0vNPosjaCFosXDdpNotRdOjZgwWraTZD6HD/QzRf4Qnuz0Z2IyS3pN3o8PfvwJ+BE3HyeVZR5bjv pPkdz6Jyp39FQa2tuO8qR4ZQedqjwGeoYduGG2bDWFQ0m5SleAx4CW2A41hQmM1lFJXXPYzuN2Oa yhgK0PwUZSkm6X/UPa3jB5Br0j7k1vMfwOfA1RDCag4lUT2Z8QPoM/ot8AN0WJ3Ee0+udCgbtj8B LocQ1nISq6YeWFQ0mw6KID8PvIjSzv2Oqpn2kRygtuFMhWkoIYQ06O511EQ96DKeYZQN3ILq4B8C 3gA+Bs6EEGbRsLyBi4s40G4cBReeRkLiF6jc6WFc7pQ7Bbq3XkR9FceBRdywbTaIRUWzScPunkGR rQkcKTKbTwdlK8aQ9eVwURSrFV+TMZtGjMBPo0PXr9Gsn6oCNJPAc+gQ+BjwNhIWJ5G4uILKV1b7 HWmOfRPjKHh1AAWwXgd+jPYcD1itB8kh8lnUSP82cBUNYTTmnrGoaCghhGG0CT6GhMVkpRdkmkwH CYrJ+BoFLCpMkxhGJTy/QWVPDzro7kFJWYufoqbx14AjwBeoJOob4FoIYQ49i2uoRn7D/Rfr5hYU 6HkfQs/5NJqjkZwFDyPBkwxBHMSqD6OUs6weAb4JISy6BMpsBIuK5pKG2ryABintwAu86R8jKNI1 jQ4T89VejjGbQzxUb0XuOD9DDdM5RN+HUCnUIVQKdRg4g0pXTsXXJ8A5YAa4ASyEEHrLWtKecLuD Y4dSRAyjfWUalX49hqLaP4h//zSar7EFl9nWlXEkVh8HPgVm8dwhswEsKhpIz7C7p1AE6yBe5E1/ GaYUFeMVX4sxm0kHRW5fRFnfnNbSNKk62TrvRIf962iI2QlUxnIm/vgKsIzKo67G13L8/6x3ZQrx 1yaQqJpGB86D6PN4HPVPPI57qZpCh7LCYS9wHosKswEsKprJMNpcnkZOJduqvRzTApKF5A7sAGUa QgzQbEeC4jC6x3PIUtyOccqG6YMou7IKXEKZi1lUBnUNCY3L8fdH46/fiO/JjjyJle3o2d6FDpu7 4q+lckfTHCZRqd9+4Fic4m57WXNPWFQ0jJiqH0ML/+NIUNgX3PSbIVT6sB2LCtMcJlCPwOuoZ2CK vEVFIg3pG0YZhylUJpXKnG6gbEZAImQs/toZlLkYQWVMU/E1EX8tlUMVPS/TLMaQIH0S+BIJz+VK r8jUBouKZrIVpaefxN7gZjCk6bk7gC0hhI6jW6bOhBBGkaPRr1BD9EHqF6C53cF/Gu0TUIqErSgD kYaj9vZT1EFImc1hHJU/vYaa/o+HEFbcsG3uBYuK5jFMae+3G6emzWDooIjmw8TJ2nYOMTVnEvWl /TS+59RL8aAkwdBL6p8w7WYIVTg8iYT0JMpieS03d8XRhwYR63+3IEFxCJWi1C2yZupJuvceQfef M2SmtkRL7v3AL5GDnvvSTJsYQcGhfXjWiNkAvlGaRYEWgEeRqHA/hRkUBcqK7UX2khYVps7sBL4P /A7d08a0iXSW2IfExei6eSXG3BKLimaR0pYPoYVgHB/szODooPK7SVQK5XvP1I4QwhByevonbJdq 2kmBSuH2o+xzXQwKTMX4JmkIPaVP+1GmYhf+fs1gSdmKZEHpLJmpFTEauw3ZcT+HBLLXHGDV2QAA IABJREFUUdM2knvYXnSe2EmzeopMn/Bi2RxSluIZtCHuxN+vGTyjSFB4EzJ1ZARlJ55FByqbmZi2 0kHr+CFkwDHuEihzN3zobA6jqOTpJeTa4PITM2hSpmI7ypSNxwyaMdkTy562A6+i5uxpvIaadpOG 6D6NzxTmHvCG3xzGUOnTk+hA54ffVMEIypjtxkYBpl5MognUr6NMxQReR027mUbVD4eRVb3Xc3NH LCoaQIwGp4F30/jBN9UxTDlEaweek2JqQAhhhNJC9nVU7mFBYdpOr6PkTryem7tgUdEMRlBk+Nn4 7lp2UwUFEhUTSNxOoyF4XmdM7mxF6+ffo54K91IYI8ZQafUu5ChpzG3xZt8MJpCN7CPYdcdUS3Ih 24XuySl8P5qMiVmKp5CgeAmVQRljxAil+cYWB4nMnfDNUXN6Sp8ewlkKUz0Fuh8PAo+hEihHfU2W xPVzH/Ab4F/w5Gxj1jOEgkO7kOB2kMjcFouKGhPt3YZRBOEJ1KS9pdKLMkYbzwFkRbgdC12TL6Mo Q/FfUB+FD0zG3EyBzhUPoyCR13NzWywq6k8HRRF2ogff36mpmiHK7Nk2nKkwGRJCGEV9FD9AAZkR 3JxtzK0YoayG8LwKc1t8AK0/I0hUbAVCfBlTNaNI6E5jxxCTGbHsaTfwYzyTwpi7MYSelx2ocduY W2JRUW8K1KS9Bz3wTt2bXBhGpU/bcWTLZES8F6fRTIqf4LJRY+5GBz0z27EDlLkDFhX1pkAP+aPI +WkcR9tMHgxTDsGzA5TJiTHU7/Nz4PvoHvW6acztSdPm03rus6O5Jb4xakqMto0Ce5F7yVZ8cDP5 kBxD9hCbtZ2tMFUTQhhGteGvoUF3j+JyDmPuRspUPIzOHJ4/ZG6Jb4r6kkqf9iGnnW042mbyoUCZ s714aJLJgBDCEDoYvQD8NL5P4XXTmLuR5g/tR8JiEp8fzS3wTVFfOuigtgtFg7fi79PkxTC6P3ej DcmHN1MJMUs2gaZl/wT4Ibo3nd015u4UKKO3m7IywucN8x18U9SXNKNiDJVBeXM0udFBpU97UYTY 642pilFU9vRj1EvxJLY6NmYjJKvwnbjc2twGb/L1JUUOtiBbWVvJmtwYQlm0Z9B07TH3VZhBE++5 7cDLwG+B53DmzJiNUqCyp91IWFhUmO9gUVFfRtBGuT++G5MbHbT5PIfsO+0aYqog9VH8DngVlT1Z UBizcbYgUbEXWYV7PTc34RuihsTI2xhK5z+CRIU3SZMbqY79UeBpNDjJJSdmYIQQxoAnkNPTL1AQ xvegMffHGBLlD+MSKHMLLCrqyzhyfdqDDm4WFSZH0qCx/ZRWhL5XTd8JIYygNfJ1JCoO4enuxtwv BaqQ2ElZITFS6RWZ7LCoqCdpBsABFP31g21yZgzdp7vjjy0qTF+J9rE7gZ8Bf4/Knxx8MebBGKac V7ETi3SzDouKepLsZLehximn803OpPkAaZ6KU+amb8RMWBIU/4gG3bmx1JgHZwidOfagMqgJ91WY Xnwz1JNhFHUbx1kKUw+m0YyAPXiCsekvU8CLwP+GhMXDOPBizGaQ+uR2o57OrfjZMj1YVNSTEbRx TuEH2tSDKdSs/STKVhizqYQQithH8SQqefo7ZBLgEg1jNo/UrH0Ql1+bdVhU1Iwe56edqJxkotor MuaeSKLi+8DBePgzZjMZQmYAvwT+Kzr4eI8zZnPpoMDQQfSMWbSbb/GCWz+SA8MU0Su62ssx5p4Y BfYBryCLzym7QJnNIjZm70CzKP4Z3WMWrsb0h1F0/tiFzyCmB4uKepJExQh2MzH1IE1jPYQyFg/h xlmzeUygidn/hLJhdnoypn8Mo2qJPThAZHqwqKgfBRIUO+LPQ4XXYsxGSBO2XwSeBbbYOcQ8KCGE cWQC8I/AD9A9ZozpHykzuD++D1tYGLCoqCPDaOjMfmAL/g5NfeigbMUrwA9RlMvZCnPfxN6cQ6js 6R9Qn5kxpr8Mob6KA6is1ZlBA/hAWitiJGCYMlMxhQ9lpl4MA48Bh1F02fW45r6IWa49wI+B/4zu K9sVG9N/krXsw0hYTOOziMGioo50KCMCfohN3ShQk98B1FsxHUKwLbLZEFFQbEVZr98ikTpV6UUZ 0x7SOv4oZY/ciEugjEVF/ShQH8Uq0K34Woy5X3agvor9OLpsNkA8uIwjh6dfAa+jPgrvZ8YMjiFU +vQUspf1Om68CNeQDiohsV2iqTPTwDPx5WF4ZiMMIzvLn8fXY3gvM2bQ9JZAPRJ/7Oew5fgGqBep p2IbivQ61WjqyiSafPxjNAzPA5TMXYllTzuBV1Fj9rMoQuq10JhqmEblrFtxSXbrsaioF2nw3Q6U dvRBzNSVURTh+jHwPdRb4fXI3I1pNI/iv6B+ih13/uPGmD4zRZxXgUVF6/EmXi8CylSMx5e/P1NX 0jC8x1EJ1EPo3jbmlsR5FE8Dv0G9FA/hNdCYqtmCRMU0LstuPV6Q60VA31kXWMCD70y96aA63KfQ YXG7naDMekIIRRQUj6Eeil8h1xkfYIypnnFgFxIW43aAajcWFfUilT91kPuTRYWpO2PA94FfA8+h KdvelEwvo6gR9O+BfwJewILCmFwYQQN59+ESqNZjUVEvOigqsAVttD58mbozhA6MrwIvos3Jm5IB IDbwPwn8n8B/BV5CDaFe+4zJgw4qZd2FRUXrsaioCTF6O4QExU70APvhNXUnzRx4BImKJ4CtzlaY EMIQ8r//FfCvKKO1CwsKY3JjDJ1LpnAWsdW4frleDKEHdiuK6FoUmqawA4mK48BlYBH1DZkWEgXF LuCHwH8CnkfrnjEmP0bQ87odu1K2GouKepF6KLrAWpUXYswmM4VmDiwAJ4FLIYTFoijcN9QyoqCY Rpaxv0MTs7dUelHGmDsxitzYHkLP6pVqL8dUhSPd9SJQljwt40Zt0xyGUJTrKdSIewA7ibSVLege +FdU+rQTl3oakzMjwG60bu+0i1978RdfPzpITFhUmKYxjGwJfw1cAmaBEyGEZWcs2kEIYQJZx/4G +BmyjvU+ZUzeDKPs4h5gGzASQljzut0+vFjXi4JSVNhS1jSNNBDvVeAicBqYBy4gEW0aSpymPokE xS+A36KmfddnG5M/aebQNGWz9hI+o7QOlz/Vi+QANYy+O5eGmKaR3KCeRYfLl1A63Y4iDaWnh+IF 4B+AfwYO48ZsY+pCWre3oed2DJ9PWokzFfUiWcruiC+LQtNECuBxNOwMlKX4IIRwpSiKbnWXZTab mKHYipy//gWVvh3CsyiMqRNpMO8UEhbj+PltJRYV9aGIry7lA+yH1jSVMTS74kfISWQJ+Bi4VuVF mc0jNnNOA08jl6ffoqnqY7gx25i6kQIE0yj46fNJC7GoqBeB0r+/izde02zGUF39b+LPh0IIHwDX i6KwpXKNiSVPe5CIeB0JiqexdawxdSX1xG2P766kaCEWFfUhoNkUS0hYLKPvz9EA01SGkZ3oK2iT GkX3/2chhOsuhaonsT9mO/Aa8HcoG/UC7qEwps4UKCgwTRQVIYTCDlDtwqKiXgT04CZL2QksKkyz Sa5A30MZugvAHHA8hDBnYVEvoqDYhYTiv6Cyp4fwWmZM3ekVFVvx+bKV+EuvFwEdsrp4ToVpDx20 WT2DDqErqPTvWAjhhkuh6kEIIZWz/QMqaXsF2I/6w4wx9SY5QCVRMYLWbq/PLcKiol54ToVpMztQ ycwQchh5A5VCXS6KYrXSKzO3JTZk70XZpp8iUXEYHT6MMc2gQH1wW+NrHLiORUWrsKioF2lOxSh2 fzLtI03c/gmwO/54K/B+COEisOT63XwIISSXul1ITPwT+u72I+tJY0xzSM/7JKWtrJu1W4ZFRb0o kKBItm12fzJtI/VYvIA2rj0og/E2cDKEMO8+i+qJ8yfGUYbiZeA/A79CNsFD+LBhTBMZRqWqKVPh wGfLsKioH8sonejvzrSVDkqzP4o2sH3xx38APgkhXLWwqI6YoRhH/ROvAz9HmYpHcP+EMU2mQGeT ERz0bCU+mNaLgBxw5pGw8ENr2swo8DAqpdkRf/x74O0QwtmiKJYqvLZW0jMh+zE0Hfuf0fyJfVhQ GNMGRlDQx+fLFuIvvV50kaC4jvz63Vdh2k6ByqBeQqVQD6Ma/j+GEL4B5u0O1X/iMLtRJO4OAz9A ouKnuAzCmDaRmrXdU9FCLCpqQlEUIYSwRikqFlDph7MVxkhgH0AC4+H4/ifgSAjhKrDiJu7Np6cZ ezv63J8D/nfgVTR/woLCmHaxBQUXpvD5pHVYVNSLNJ9iDriBHlw/tMaIDhITLyIjg2eA/0BN3KdD CIsWFptDFBNDKCq5B4mIH6HP/pX4a6NYUBjTNtKsiil8xmwd/sLrRUC9FAtIVPiAZMzNFGgzexoJ jIdQPf+fga9CCDNu4t4UxlCZ2UHgedSM/UOUrZjG/RPGtJVRlK0YA0ZCCIWDOe3BoqJepKF3c6gE yocjY27NMCqHmgJ2InHxB+CjEML5oiiWq7y4uhJCSAeGQyg78RoScM+gz3usuqszxmTACDBB2azt qdotwqKifiwDs8A1/KAaczem0cH3IWRp+u/AmyGEM0icrzmKdntimVMHHRS2okzEAdSI/Vvg++gA 0cFNmcYYZYvHUUDHJZAtw6KifvSKCmcqjLk7Y0hQbEPzLJ5F1rMfApdDCCtAsLgouYWYOIhcnV5H 8ycOosnYk1hMGGNuZhyVR9qooWVYVNSPVeQANR9/bIy5M2kS/W4UVd+D5ij8G/AecAa4EUJYsP3s t4ygLM/DqLTpMCp3egkZRIzFP2NBYYxZzxhaJ2x73zIsKupHF4mJlfgK+KE15l5ITdxPoT6LXcCT wGdIWHwTQrgA3GibuIiZiWGUediNPpcXUe/EY/F9P/rcLCSMMXdiFNlM27ChZVhU1I9AKSyW449t K2vMvTOEDs4/R4flY8ApVA71IXA8hHANuaytNFVgrCtxmkIZnEeRm9NP4msnijp2cPDCGHPvpCZt 0yIsKmpEzwC8RWQpO4+atS0qjNkYBTosP4b6AxZRec/7wAfAV8A3wPkQwhzKCnaRqK9l/0UUEaB/ +xASE1uAvajE6VXUb5J6Jnbh8gVjzMZYQ+eTKyj4aWHRIiwq6kcXHYBm0IO7ilKNxpiNkaZBJwvE cZTBeAk4h4TF+8Bx4CpwGT17SyGEJUqjhGxFRhQSKSMxjNaKSdS0nuZMpJ6Jl+KvTcY/5/3BGHM/ pOCnzWRahjeN+tFFZRkzaFaFm7WN2RwmUN/AXjR74TngZSQwziCRMQ9ciD+fQZvnfAhhEehWOf9i XSYiiYhhygm3+5Ed7D70b9yLGrH3x/fdOOtpjHkwhtD6Y0HRQiwq6kcAlpCl7CwWFcZsJkPxNYas VB9DvUuXUMZiCTgNHEXZixXUj3EBWI0ZjHkUpVuMfz6l/1eA5d6sxt2mzYYQUi9DEgzpz6bG6uF4 vamkaTS+xpFImkQNkwdR4/XTSEDsiP++LbjEyRizuaTghNeVlmFRUU+WkKCYQQcVY8zmk8TAOIrm P4QO9bMoS5g2zE+RyAjxv7mIMhnnkfCYQOL/EnAphJCyGQHoxjkZ3fjfJlGzvmyp99eGkRCYRKIg TbEeR0Jhuue1DQmI/cjRaVf88+n/Dd74jTGbS1rLvLa0DIuKerKCDjWzSGAYY/pHygKk6NsuFP1P G+ZONFk6/fwqejZX0Oa6BQmML5GwmEDP7TmU9ZhFa/EOytKkFdTDkX5vPP5/kkjYRdlIPYcCDEPI xWkaiYtJyl6R9LLFozGm3wyjtWgYC4tWYVFRM6ID1Apq0p6lbIayw4Ixg6HDzc9bygokdqHMRMoq DKGD/7PoeR1FouJqfK1ROjFtQ5vxMhIKi/H3xijLmrb0vIr4Zxbi3z0S/9wIZXmUN3VjzCAZRWui gxgtw6KinqSp2jPosGIHKGPyIR3me5mMr8RafMGtexrS7ydhcidhMI4yJ8YYUzUBrV1dShtu0xIs KupJspWdRRmLZSwqjKkTveVU9/P7xhiTI2lORbLgtqhoES6ZqSddVD4xg4RFZTaWxhhjjDGRgtL6 PuDyy1ZhUVFfVigdoBYrvhZjjDHGmGSD7dKnFmJRUUOir/0ySi9eRH0VxhhjjDFVktzy1s/WMS3A oqK+pIFcp1G2wg+uMcYYY6pmvUOeaQn+0uvLGhITZ4ErlE4yxhhjjDFVUSBXu4ADnq3CoqK+BNQI lQZtLeOH1xhjjDHVsYrcn5IzZbfayzGDxKKiphRF0eXmZu3ktGCMMcYYUwVpOO9ldC6xqGgRFhX1 ZhW4jsqfbmBRYYwxxphqSZb3SzEAalqCRUW96aIH9zwSFn54jTHGGFMVw8j9aQkghOA5FS3CoqLe BJShuICcoCwqjDHGGFMVI5RN2uDhd63CoqLeBBQNuIxFhTHGGGOqZRHNzlrB7k+tw6Ki/qwgB6hL qMfCGGOMMaYKbqDzyDywEof1mpZgUVFj4sO6ivopzmIHKGOMMcZUxwLq9UzZCtMiLCrqzxpwDTiD eisWsbAwxhhjzOApUCn2Ei7Jbh0WFfUnoIjACeB94CIugzLGGGPM4BmNr7WqL8QMHouKmhNLoJaA c8DH8X250osyxhhjTNsIKDuxRtmobVqERUUzWEUlUCeQqFio9nKMMcYY0zK6lAN5l7CoaB0WFQ0g ZivmgFPxdb3aKzLGGGNMC7kMnERnEpdAtQyLiuawhBq1TyDnBWOMMcaYQdFBVrIzqAzbmYqWYVHR HNbQg3wKlUIZY4wxxgyKVRTgXMA9Fa3EoqI5dNGDfBZlLBawnZsxxhhj+k8XlTz1zqiwqGgZFhXN Yhk1an+NahqdfjTGGGNMvwlIUJxHjdrLnqbdPiwqGkJ8eFeAS8AnwKfADZytMMYYY0x/SXay11EJ tpu0W4hFRbPoIiFxAjiKH2xjjDHG9J8kKhawrX1rsahoEDFbsYyman+Dshaerm2MMcaYfpLMYmZQ s7ZpIRYVDaMoijXgKhIVp1FJlDHGGGNMP0jl1ydRT4X7OVuKRUUzmQPOAMeBxWovxRhjjDENJqCz xllUIWFR0VIsKprJMrKV/RwJi/lKr8YYY4wxTWUZZShOoonadn5qKRYVzWQNWbp9DryPHnY3bBtj jDFms1lFZ44LwCwuu24tFhUNJEYI5lFfxfuUMyuMMcYYYzaTgITFHLDoLEV7sahoKEVRrKCG7a+Q qJjDNY7GGGOM2VwCqoZYxo6TrcaiotksIQeok2hmhTHGGGPMZpEG3p0m9lNUezmmSiwqmk3qrfgC ZSxm8IRtY4wxxmwOAQ3dPU7p/GRaikVFs0kRhM+Bd1GPhRuojDHGGLMZLKPgZaqI8BmjxVhUNJjY LLWExMTHwBEcRTDGGGPM5jCDshRngAXcu9lqLCqaT6B86I/iYXjGGGOM2RzmUdnTFez81HosKhpO T7biPBIVV7E7gzHGGGMenDWUoZjDZ4vWY1HRDtZQreNR4EsUUfAwPGOMMcbcL6uob/MKatb2uaLl DFd9Aab/FEURQghzwDHgT8AuYALYWumFGWOMMaaOJNenkyhgOYtFRetxpqIlFEWxDJwD3gE+Q5kL 1z4aY4wxZqOkKdoXgVPAkvspjEVFu5hHEYVPkVPDChYWxhhjjNkYq8AF4ER8dz+FsahoGWkY3qfI YvY8HoZnjDHGmI3RRXb1x5HDpEufjEVFywjIpeFL4C1UBuW5FcYYY4y5VwKqdDgFnEbnCgcojUVF m4j1jisoQ/ERylbM4xIoY4wxxtwbK6hH8xvUU+HgpAEsKlpHFBYLKMLwCXKEmsPCwhhjjDF3JiCn p8+Br1FJ9aqbtA1YVLSVLhqC9wXwNnAWRR6MMcYYY27HCpqg/RkKSs5YUJiERUUL6clWnADeQIuD B+IZY4wx5k4sIVFxDJVSL1Z7OSYnPPyupRRFsRZCuAJ8ABxAg/BGgR1AUeW1GWOMMSY7AhIRl5Gg uF4UhYOR5lssKtrNInJueBt4KL6mgaEqL8oYY4wx2bGGshRfoUnaC9VejskNlz+1mKIousB1yoF4 p3EJlDHGGGO+S+rHPIpdn8wtsKgwq6if4ghq3L6Im7aNMcYYUxKQBf05lKW4jqdom3VYVBhQCvM4 8A7wLnAND7IxxhhjjAioj+IoEhZLdn0y63FPRcspiiKEENaAC6hpez9wENgSX27aNsYYY9pNQFUN p1CjtkufzHewqDAURdENIcyjlOZbwKPAOPAYMFHhpRljjDGmWnobtI+gagb3X5rv4PInA3w7u2IW +BLNrvgc1Uw6vWmMMca0k9RLcQT4EJU/zeGzgbkFFhXmW4qiSJMyP0FuUOdwI5YxxhjTVroo4Pg5 GpR7DlhxP4W5FRYVZj3LwBngI7SIXMVpTmOMMaaNdFFm4ijwDRp4Z0Fhbol7Ksx6uqhe8kNgF5qw /TPUtG2MMcaY9nAdlT59iSoZXL1gbotFhbmJ6Aa1hBwe3gb2AI8AjwOj2A3KGGOMaQNLqHLhfeAY cMNZCnMnLCrMd4jCYh7NrngTCYoJZDU7VOGlGWOMMWYwzAJfo8qFs8BitZdjcsc9FeZ2pDKoT4B/ Q/0VN/BQPGOMMabpBNRTeTS+ZoqicOmTuSPOVJhbElOcKyGEy8B7aGbFVuB5YBsWpMYYY0wTCaj0 6Rwqezoff27MHbGoMHcj9Vf8CRhDTVrPoQZu3z/G1IMu6odyT5Qx5m6sAReAL9DAOw+7M/eED4Xm jsRp2zfQ3IoOOpSMox6LqSqvzRhzV9ZQHfQyMAmMYGFhjLk9AVhAJU9p2N18URQufTZ3xaLC3JUo LK6ivoopYD9yhRpDhxRjTH50gRnk3nIDGS7swuu+Meb2rCEb2S/Rnn8B28iae8Sbi7kniqJY7REW f0L9FSPAARz5NCY3uuhg8DnqibqBMo1b0LNrjDG3Yg0FI44Ap4E528iae8WiwmyEZdS49Q46mKTh eBO4cduYnFhGVpB/BP4CrKDypyngCVTCaIwx67kK/A34CGUp3Eth7hmLCnPPxDKoOeAEEhapDOpJ JCycsTCmelaQp/z/396dPdlV13sff6+9e246ExkYQhgFDiAochSfc3xuPGWVWPVY5Y3/hTeWF97K n2CVVVrlpVX+AXrx1HNkEFFmAmHMQBIghCSdsaf0sNd6Lj5ruTeoB5J0p3vvfr+qdnWTENho77XW 9/edXiJZxf0k6N8C7AS2ks+un1dJvWZJY/ZfSPnTjFkKXQmDCl2RnsDiMPA8sINun8Xoer43SZTA NDlp/AvwFhkH2SZNl7eR8dA34SJLSV0dMunxFZKlmCYHFNKXZlChK1YURaeqqnPkgWU7CSxa5EHF wEJaH83CyreA/0seDk4VRbFcVVWHlC6+AzwKPEJKoMxWSCpJ2dNBcvjwMZn4ZJZCV8SgQlelbtw+ RR5cRkgN9zeAvaQZVNL1UZETxU/JCeMzwAtk6tPlnr9njiyy2k92zdxLyhYlbV4luTYcJhnOt4Gz OPFJV8GgQtdiAfiQPLA0zVyjwO3r9o6kzadDHgJeA54G/kr6nv5+0lgURVVVVdNrsR+4hwxa2INj oaXNrDlweB94g1w7FsxS6GoYVOiq1Q8q8ySwgJRTbOl5WbMtra0SuERGxz5fvw6SMZCfWVZV90Nd qn//BdJbMULKF70XSJvTMjmUOEjGyJ4ngYZ0xbyR6JrUDyoLpAbzBfKQ0iKlUDtIYGHdtrQ2mmkt z5HP31H+SUDRqPuhTpPdFbeQIQvjuLtC2owqcijxdv36FFgxS6GrZVCha1YHFvPkgaZFTk87pCF0 D2YspLWwSD5zz9WvQ8ClfxVQ9GjKFl8nfRW3YVAhbUbNffuvOEJWq8CgQquiPgG9RJq9KpKxmCIL t6ZwOZ60mjrARyQ78TQ5ZTxXFMUXNlfWhwAz5GHifeB+sm9mGLOK0maxTIY5vEoOGD4lA1ekq2ZQ oVXTU7N9hPRX7Ky/foVuj4UPLdLVK0mm4RTZlP00aa6cLoriSmbKr5A59O+Qz+ceUg5lVlEafBXp nXiLXEeOkbJJsxS6JgYVWlV18/YsOQFtRs0ukhGW2/FnTroW88AHZLHdM2Sk8xmufPxj0+D9DslS 7CKfz0kM/KVBVpF78hHgJTKG+iwuutMq8AFPq64uhbpIHliacbNt4KskYyHpylQkQP8QeBb4bzIa 9hSwfKUnjHXwvwicIKUPe4CbyahZAwtpcK2QUqe36tcJ4LJZCq0GgwqtiXo53jngXRJQTJAdFveT iTOSvpwOcJFuD8WfSEDxaVEUV10DXZcrzpHSh5dJw/ZWUrLovUEaPB3gAunBepU0Z18imUvpmnnj 0JrpCSzeIT9rJdnw+wB5eLF+W/qfNQHFO8CLZA/F68BpVqFcoQ4szpNyxVdI0L8b7w3SIJoj5ZMv kYOJk9dyMCF9njcOrak6sJgmpyIz5JRkHvgasA0nzkj/SkV3D8UzpOzpHVL/vLRa5QpFUSxXVXWm /mcfBPaSrKL3B2lwLNKd9vQSCS5m1/UdaeB409D10Jy2vktOVxfr18OklnsUAwup1wqZ8nSUTHh6 hpQsnGVtllM1/64/kyziMOmxcBS01P9KMtDhANlJ8S5w0T4KrTaDCq25+sK1Uo+bPUT3gek88Bhw B+m5kNR9AHiHjHt8hjqguMKxsVeiQwKWV4EbSQnULjLBTVL/Ksmh3vukJ+tNMk7aaU9adQYVum56 lm4dJr0VcyRDMUZKLnyA0WZXAudIvfNTdDfdXvgyi+2uVj0N6jKZLvUKcBMJKu4kmURJ/aci99pj ZBjDq2Tgg9OetCYMKnRdfW7iTAcYJ8FERcotxrEUSptPRTJ4zUKqp0kPxUFgpiiKNZ/OUgcWc+RE c4rsrdhKAgw/k1L/WSZjp18nQcVhYLYois66visNLIMKXXc9M/I/Jg9PF8np7H9x/ax4AAAWuklE QVSSUqhJrOXW5lGSHRQnSc3zn4HnSKngdQkoGvWOmfOk5no3mQY1ScZA+5mU+kdJBqM0k+OaIQ8G FFozBhVaFz3lFifICe0sCS4eJ9u3d+PpqAZf87P/MSlNeK7++iE5UVyP+fHN6eYrwC3kIeQBMq1t CD+XUj+YJROeXiTllJ8Ai5Y9aS0ZVGjd9GQsTpJpUHMkYzEDPEpquh05q0HVlDu9S0oTXgTeIDf/ hfUqUag/lwvAcdIk3mQovkoCCz+P0sa2SA7sXiPXlmPkkMKAQmvKoELrqr7ILdW7LBbJ6co8aS57 hPRZTOCiPA2OivyMnyY1zs+Shuz3WeUdFFerp/fpPdLzdAMJKEbJ59HAQtqYOmS60wEy7eld4Pw6 ZT21yRhUaEOol+RdJHXkS8AlcmF8jEyg2YJZC/W/igTPR8gJ4ivkNPEwKf8r1zugaNT9FRfIZ3IX 2SkzCdyGE6GkjahD7p3NOOr95PBizSbHSb0MKrRh9JyOHiWlUNPkgvjvpKa7WZRnw6j61TIJKP5M RsYeIP0L8xtxIksdWJwls+1vIFOhtpCeJ0kbyxz5rD5Ngorj2Eeh68igQhtKHVjMk3rQeTK94jRZ BvYIsI+MubQcSv1kmfwsHwP+QnoV9pOAYt3Lnb5AU5/9Khkzu4vslpnCzKG0UTT7KJ4nAcUR1m/Y gzYpgwptOD19FmfI5u0LJKiYBr4B3EMebCzB0EZXktKDT8n+ib/Vr/fIz/TyBg8oejOIHwIvkb0V 28i42eH1fG+SgFxjTpCSyr/RXZhpQKHryqBCG1bPBu6jdHssTgHfIpNo9pITU8uhtBF1SDnCSbJ8 6llyw/+QDCRY2egBRaP+LM6Sh5XnSfnTDmAnaeQ2YyFdfxW5zpwm15hngLeBs0VR2Eeh686gQhta z9jZ0yRrcY6c+p4iTdz3kBpvf5a1UVQkQ9E8hD9FSp7eIT+7ixuxf+JLWCGfvzfoLsP7NtllMYaB hXQ9VWSoyQVSSvkMyVScqn9duu58ENOGV5/mLteTaBbJHouzJNBoluU1Tdw+2Gg9rZCfyw/J1KTX yf6JQ6QcYXkd39s16fkcfkrKoCry3/s4cDswvo5vT9pslskhxaskoPgrWaJ5uV8yoBo8BhXqG3V9 6FxVVR+RspLzJLiYplsO1Yyela6nptTpNDk1fJn0UBwhy+zmBqi++TLwEQkqChJMTAC34gAF6Xoo yf3vLeBPJBN6lEyRM6DQujGoUN8pimK5XpY3Ty6sp0iT2iPAfaTeexx7LbT2mprmCySAeIP0HOwn p4az9EEz9pWo/1sWqqpqNvZuJ+VQw8CNuE9GWksV6TF8n1xrXgSOFkUxs67vSsKgQn2qnp8/R0bo XSTzuA+SJu7ehXktfMDR6qtI+cEC+fk7SE4LX6i/P01O9DfMMrs10GQs/kzKoC6TnTI3Y7ZQWgvN 8sz3SK/Wc8AHJEsqrTuDCvWtupzkcj16dpaUQp0k9eyPAQ+TkqhWz0taDQuk3OA90i/xHmnEPkaC jIHKTvwzPTtljtJtTh8iWYsd6/nepAG1Qg4tniHT5N4HZgaotFJ9zqBCfa8enTdTT4m6RJrXjpMH vIdJOdQeUqbhz7yuxRIpuTtCshKvkKDiJAkm5jfTDb7OGM6SwKIFTJKA4iGypNJAXrp2HXKQcZJk J54D3gXOOzpWG4kPWBoYRVEsVVV1lmQtTpMHvzfJdKim32In9lvoyjQL7BZIwPo2mX70Ejk1PEvG xA50ZuJf6dlhcZTs4Zgi/5s9QJbkDWEJonS1mh6Ko2TS01OkQfucAYU2GoMKDZT6Aecyad6+SEqh DpLMxWPkBLXpt/BhR19khW6Q+hEJUl8hN/WBbMS+GvU+mVlSjlGQjM4S8DWSuXAqlHTlKrqlls8C T5NhEKfJtUnaUAwqNHDqB7yVupH7MtlrcYY0tL1DgouvkxGYY80fwwBDXU2PwDmSmXiR3MwPkxKE 86RhcpAbsa9IXQp1ifSXtMhnazvJDE7i50u6EiW5xhwjOyieIlPlzgBLXne0ERlUaGA1wQVwqc5e nCOnzcdIBuNBEljsIWUao+vzTrXBzJLdEsdIluvN+nWMlCEsbqa+iStRFMVKVVUXScZihAQXl0kJ 4nYsO5S+jGZM9UHSu/UMCShOF0XhtmxtWAYV2hR6+i2arMXHwAHgHvLAcy8ZhTlJxmFaGrV5lGQ8 7CIJKJra5VfJw/HHJCB1U+2XUGcszpNA7DL533SOLKjcheNmpS9ykWRI/0R2UbwDTBdFsbyu70r6 AgYV2jTquu8lsoF7lpw8v0ECi6+TzMVtpAZ8J9kSbC34YLtMbuBn6U4Ne4ssdTtCgolFoGNA8eXV GYvz5GFongQV88A3yDS2JoshKZpFmvPkMOOp+vUucKEois46vjfpSzGo0KZSPxh26vn6zQjaU6RW /iXgLpK1eLD+emP9R9v4EDQomn6JJZKV2E8CiSMkK3GKBBlzwIrBxNWpMxYzpJepWdo1RwL4fWSf hdlAKZZJA/YhslDyKRJcXDSgUL8wqNCm1NPM3aF7Wn2CXMTfJqdDD5EgYzdZotdMjFL/Kkmm6k1y in6A1C2fJDXM8yTYMJhYBT17LI7QLS+bA75LGrj9PEnp/ZsmJZfPkcEQ75NrkgGF+oYXdG1qTeaC jO1bqKrqAjktOk4eOm8H7iZL9O4lZVGjuOui33TKsry4vLx8fGVl5dXh4eFnhoaGDrRarY/xJHBN 1U3ts1VVHSMBW4dulqJ3Apu0GTVT5t4kI2P/QrJ7M1h2qT5jUCH1qGvBL5DT1JNkPObNJHPxDdJ/ sQu4iSz5GiX14W0cS7thVFXF0tJSZ2VlZbGqqosjIyMny7I8eP78+RcPHDjw/IULFw5t27Zt5nvf +54jYa+ToiguV1V1gpRCtUhW6NskcLd/SZtNUxJ4npRfNk3Zh4EZJ8ypHxlUSJ9TN3QvkxrXBVIa 9QkJLPaRZu47gFvqv76VBBgj9cvJUeujAsqyLFeWl5cXT5w4cfG999774IMPPnhp9+7dz957770H t2/fPj03Nzf/1ltvLT/55JPetK+/JfJZep70M50D/jfJAm7Fe5I2h4qU3X4EvEy35OkoMGtAoX7l BVz6J3pOr3uX6F0g+y22kjKo3eSU9U6SzdhLAo7dpDxK19fiysrKh5cuXXr50qVLr50+ffrYxx9/ fPoXv/jF6TNnzpwi5QTerNdR/blarKrqFKkjnyOBxX+Q7ds348hZDbYVMgjiIBkO8gIpfToBLBhQ qJ8ZVEhfoGeJ3gowX4/K/ITstHiPboBxJ9l58RW65VE7SIBRYg/GqquqaqUsy0+qqnqj1Wq93+l0 jpw5c+bd3/3ud+8/+eST0+T/M20w9d6YMyRzMUuyFgvAo3T3xZjt0yApyc/4GZL1fo4EFIeAM0VR LK7je5NWhRdt6SpVVdUideBDpNl0J93G7n3k4egesrF7nGwUburG26RUSlfnMnCuLMujS0tLL12+ fPmPs7Ozr//+978//7Of/cweiT5RVVWbBBC3Af9OMhZfJwG609Y0KEq6izUPAK/QDSguYUO2BoRB hXSNqqoqSBaiRRq3byABxE66JVF3kCzGWP33TdENNobpNnrrHzUTupqt1wukDO0vwH+vrKy8vbCw MD09Pb141113WTrQZ+rgfJQE4Q8C36pf99PdwG2WT/2qImV+H5DpTs+TseWfkP4Js6kaGD7ESKuo J8AYIg9KE/VrCthGdxztTpLF+CrwACmXarYM+7mMZkndZXID3k+aGj+o//pT6iV1joTtb/XnZpiU C95NyqAeBx4hWT+nQ6kfNdev48D/q19vkhKoJa9bGjSmlqVV1LP3okMaUmfoBgpNNmOYlHzcSCZH 3Vp/v63+/iEScGy73u9/g2jmth8n5QHHSNnA4fo1XRTF/Lq9O626+nPT9Fk0yyinyc6Yb5A+pR1Y Mqj+cpJkJV4GniW7j84URbG0ru9KWiOeiEobQFVVQ6SG/A5SW/7Vqqr2VFW1DdhVFMWeoihuZPAm 4zSjFWdI1mG6/vohaYJ/iwQUZ4HL1h0PvjprMUaGH9wHPEayFv9GMnoTWA6ljatDyp3OkDGxz5Ae iqNk/4TZCQ0sgwppg6gfptpAe3l5eWh+fn68qqpbR0dHvzY8PPwf7Xb763VgMQ6MV1U1DgwVRdEP D1gl6YlYIkHEEumPmCeBxAekLOANkqE4V/99nfrPVgYUm0f9WRgi45tvJ83bj9dfb6fbxO09TBtB U6pZktHj75NG7OfJde1Tcihiz5cGmhdkaYN64oknit/85jcj27ZtmxgZGZkaGhraVhTFrrIs7+p0 Ot8CHiuK4rZ2uz1RBxbNRu8myCg+9/V6qHq+Nq9lkon4hKT/D5AyppP1ry+SAGK+fi3avCj4+3So CTLU4AHgmyST92/1r1kOpfXWe407SwKK54G/kuvceXJN81BEA8+gQuojnU5ntNPpbCvLci9wS6vV 2tput8dardYkaf7eR0qobiYTqCZIydTVlk01gUHzfVlV1d9/rSgKyHWkOalr9g6cJun+4ySYmCbZ h9PAqfr7GU/u9EXqrMUo+Xlumri/RbeJ+wa8l2n9LJJMxAFSrvkW6aM4TgINx8Vq0/BCLA2AqqrG q6raCdxWFMUddIOKSVIm0oytHSElJdtJzfrW+vcrMi/9HN0AYIGUH1H/flUHFE0gUBV1VEE3+Fgk QcUZukHFp8AlAwhdizprcQMZ0fwwyVg8QgKN3STw8J6m66UkAwWOk8l0TanTR6QEyh4wbTpegKUB UZZlART1g37zoudrRXfR2B1kJ8A+0hRbkozCofp1lAQZKz1/nsQUf/9nfT5T0au3/Ml+CK2KnqzF LhJMPEQauR8DbiFBx6ANM9DGUZJr4iLJQrxLypxeJKWdTe+E1zttSgYV0ibSsw9gmDycDdO9DqyQ 8qUlMkPdzII2nJ4m7htIcHEfKYf6dv39jSQj1xtYS9eq2Yp9imQnjpAMxetk0MQlct00oNCm5QVX ktR36k3cI2Sfyz6ySPJxkr24lSycvAH3MenaNLuHZkkW92Uype4IKXU6jaNiJcCgQpLUx+pei2ZL /f0kqLiPlEfdQ7ffQroay8AJsjfnJTIq9iCZ9LQALJvVlcKgQpLU13p2vDQlUXeS4OLfSe/QXpK5 MGuhL6NDxlvPkD6J14G/kQzFMdKI7VQn6XMMKiRJA6EuiWqCi1tIxuJh4Gv1110kq9HsdZF6NY3Y F0lm4nUyKvZdepZympmQ/jkvqpKkgdLTbzFJxibfTHou/ovsubgFS6L0WSukb+ITkpF4of56nCyw mwdWzE5I/5pBhSRpoFVVNUx6Lh4izdzfBf4Xjp9VXCaN12/QXWB3GDgJzGEwIX0p1pdK2jDq2ni8 gWs1FUWxDJysquoUcKgsy0/LslwqiuL+Vqu1qyiKsfV+j7quOiSQuEimN50EXiF9EwfJAlAzE9IV MlMhaUOoA4oWLszTGqqqqnXo0KHtY2NjD+/YseP/jI6OPt5qte4EtrZarRHyM6jB04yGvUwmN30I vA28SvZMfAScISVQNmFLV8GgQtKGUVVV4c1ca+23v/1t69vf/vb4nj17dp47d+4rw8PD39myZct3 t27den+r1dpKmr29Pw6Gpvl6gQQTR0lGYj/plzhFpjw5Hla6Rl40JUmb1qOPPjrxy1/+cu9NN930 ULvd/tbIyMh/Tk5O3j8xMbF1aGiovd7vT9dkiUxxegN4n2QjPiVZijPU/RKYmZBWhUGFJEkw+vOf //yuJ5544vGbb7750YmJifvb7fZXxsfHbxofHx8dGrIFsU+UJFg4T3ZKPA08R8bCnqn7ayStAU9h JEmCzve///1z7Xb77T/84Q/7Dx8+fGxmZmZhYmJieWxsbH5oaGiuqqpF0u/T9P94MLcxVCSYWCTB xEESSPwB+BPJVpwvimJl3d6htAl4QZQk6bPaP/nJT8a++c1v3vDggw9OtFqtHcPDw3dOTU09vH37 9kfGxsbuabfbe6qqmiCHc+060GheWhtNiVJJN5BogokZMsnpCJnk9DIpeToHLNkrIa09L36SJP3P 2r/61a+2fOc737lx7969O4eHh3cuLi7etLS0dHu73f7KxMTEfWNjY7e22+2tZOmeVl9JeiRmyMjX U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu 0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1 MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7 hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
-
id="image1"
46
-
x="-233.6257"
47
-
y="10.383364"
48
-
style="display:none" />
49
-
<path
50
-
fill="currentColor"
51
-
style="stroke-width:0.111183"
52
-
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
53
-
id="path4" />
54
-
</g>
55
-
</svg>
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{ . }}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
10
+
inkscape:export-filename="tangled_logotype_black_on_trans.svg"
11
+
inkscape:export-xdpi="96"
12
+
inkscape:export-ydpi="96"
13
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
14
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
15
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
16
+
xmlns="http://www.w3.org/2000/svg"
17
+
xmlns:svg="http://www.w3.org/2000/svg"
18
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
+
xmlns:cc="http://creativecommons.org/ns#">
20
+
<sodipodi:namedview
21
+
id="namedview1"
22
+
pagecolor="#ffffff"
23
+
bordercolor="#000000"
24
+
borderopacity="0.25"
25
+
inkscape:showpageshadow="2"
26
+
inkscape:pageopacity="0.0"
27
+
inkscape:pagecheckerboard="true"
28
+
inkscape:deskcolor="#d5d5d5"
29
+
inkscape:zoom="45.254834"
30
+
inkscape:cx="3.1377863"
31
+
inkscape:cy="8.9382717"
32
+
inkscape:window-width="3840"
33
+
inkscape:window-height="2160"
34
+
inkscape:window-x="0"
35
+
inkscape:window-y="0"
36
+
inkscape:window-maximized="0"
37
+
inkscape:current-layer="g1"
38
+
borderlayer="true">
39
+
<inkscape:page
40
+
x="0"
41
+
y="0"
42
+
width="25"
43
+
height="25"
44
+
id="page2"
45
+
margin="0"
46
+
bleed="0" />
47
+
</sodipodi:namedview>
48
+
<g
49
+
inkscape:groupmode="layer"
50
+
inkscape:label="Image"
51
+
id="g1"
52
+
transform="translate(-0.42924038,-0.87777209)">
53
+
<path
54
+
fill="currentColor"
55
+
style="stroke-width:0.111183;"
56
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
+
id="path4"
58
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
59
+
</g>
60
+
<metadata
61
+
id="metadata1">
62
+
<rdf:RDF>
63
+
<cc:Work
64
+
rdf:about="">
65
+
<cc:license
66
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
67
+
</cc:Work>
68
+
<cc:License
69
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
70
+
<cc:permits
71
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
72
+
<cc:permits
73
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
74
+
<cc:requires
75
+
rdf:resource="http://creativecommons.org/ns#Notice" />
76
+
<cc:requires
77
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
78
+
<cc:permits
79
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
80
+
</cc:License>
81
+
</rdf:RDF>
82
+
</metadata>
83
+
</svg>
56
84
{{ end }}
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
···
2
2
<svg
3
3
version="1.1"
4
4
id="svg1"
5
-
width="32"
6
-
height="32"
5
+
width="25"
6
+
height="25"
7
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
+
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
+
inkscape:export-xdpi="96"
11
+
inkscape:export-ydpi="96"
12
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
9
13
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
14
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
15
xmlns="http://www.w3.org/2000/svg"
12
-
xmlns:svg="http://www.w3.org/2000/svg">
13
-
<style>
14
-
.dolly {
15
-
color: #000000;
16
-
}
16
+
xmlns:svg="http://www.w3.org/2000/svg"
17
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
+
xmlns:cc="http://creativecommons.org/ns#">
19
+
<style>
20
+
.dolly {
21
+
color: #000000;
22
+
}
17
23
18
-
@media (prefers-color-scheme: dark) {
19
-
.dolly {
20
-
color: #ffffff;
21
-
}
22
-
}
23
-
</style>
24
-
<title>Dolly</title>
25
-
<defs
26
-
id="defs1" />
24
+
@media (prefers-color-scheme: dark) {
25
+
.dolly {
26
+
color: #ffffff;
27
+
}
28
+
}
29
+
</style>
27
30
<sodipodi:namedview
28
31
id="namedview1"
29
32
pagecolor="#ffffff"
···
32
35
inkscape:showpageshadow="2"
33
36
inkscape:pageopacity="0.0"
34
37
inkscape:pagecheckerboard="true"
35
-
inkscape:deskcolor="#d1d1d1">
38
+
inkscape:deskcolor="#d5d5d5"
39
+
inkscape:zoom="64"
40
+
inkscape:cx="4.96875"
41
+
inkscape:cy="13.429688"
42
+
inkscape:window-width="3840"
43
+
inkscape:window-height="2160"
44
+
inkscape:window-x="0"
45
+
inkscape:window-y="0"
46
+
inkscape:window-maximized="0"
47
+
inkscape:current-layer="g1"
48
+
borderlayer="true">
36
49
<inkscape:page
37
50
x="0"
38
51
y="0"
···
45
58
<g
46
59
inkscape:groupmode="layer"
47
60
inkscape:label="Image"
48
-
id="g1">
61
+
id="g1"
62
+
transform="translate(-0.42924038,-0.87777209)">
49
63
<path
50
64
class="dolly"
51
65
fill="currentColor"
52
-
style="stroke-width:1.12248"
53
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
54
-
id="path1" />
66
+
style="stroke-width:0.111183"
67
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
+
id="path7"
69
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
55
70
</g>
71
+
<metadata
72
+
id="metadata1">
73
+
<rdf:RDF>
74
+
<cc:Work
75
+
rdf:about="">
76
+
<cc:license
77
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
+
</cc:Work>
79
+
<cc:License
80
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
+
<cc:permits
82
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
+
<cc:permits
84
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
+
<cc:requires
86
+
rdf:resource="http://creativecommons.org/ns#Notice" />
87
+
<cc:requires
88
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
+
<cc:permits
90
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
+
</cc:License>
92
+
</rdf:RDF>
93
+
</metadata>
56
94
</svg>
57
95
{{ end }}
-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
13
<div
14
14
id="add-member-{{ .Id }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
17
19
{{ block "addKnotMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
···
54
62
</div>
55
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
64
</form>
57
-
{{ end }}
65
+
{{ end }}
+1
appview/pages/templates/layouts/base.html
+1
appview/pages/templates/layouts/base.html
···
9
9
10
10
<script defer src="/static/htmx.min.js"></script>
11
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
+
<script defer src="/static/actor-typeahead.js" type="module"></script>
12
13
13
14
<!-- preconnect to image cdn -->
14
15
<link rel="preconnect" href="https://avatar.tangled.sh" />
+5
-9
appview/pages/templates/layouts/fragments/topbar.html
+5
-9
appview/pages/templates/layouts/fragments/topbar.html
···
15
15
{{ with .LoggedInUser }}
16
16
{{ block "newButton" . }} {{ end }}
17
17
{{ template "notifications/fragments/bell" }}
18
-
{{ block "dropDown" . }} {{ end }}
18
+
{{ block "profileDropdown" . }} {{ end }}
19
19
{{ else }}
20
20
<a href="/login">login</a>
21
21
<span class="text-gray-500 dark:text-gray-400">or</span>
···
33
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
34
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
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">
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
37
<a href="/repo/new" class="flex items-center gap-2">
38
38
{{ i "book-plus" "w-4 h-4" }}
39
39
new repository
···
46
46
</details>
47
47
{{ end }}
48
48
49
-
{{ define "dropDown" }}
49
+
{{ define "profileDropdown" }}
50
50
<details class="relative inline-block text-left nav-dropdown">
51
-
<summary
52
-
class="cursor-pointer list-none flex items-center gap-1"
53
-
>
51
+
<summary class="cursor-pointer list-none flex items-center gap-1">
54
52
{{ $user := .Did }}
55
53
<img
56
54
src="{{ tinyAvatar $user }}"
···
59
57
/>
60
58
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
61
59
</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
-
>
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">
65
61
<a href="/{{ $user }}">profile</a>
66
62
<a href="/{{ $user }}?tab=repos">repositories</a>
67
63
<a href="/{{ $user }}?tab=strings">strings</a>
+9
appview/pages/templates/layouts/profilebase.html
+9
appview/pages/templates/layouts/profilebase.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
+
{{ $avatarUrl := fullAvatar .Card.UserHandle }}
4
5
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
6
<meta property="og:type" content="profile" />
6
7
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
8
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
9
+
<meta property="og:image" content="{{ $avatarUrl }}" />
10
+
<meta property="og:image:width" content="512" />
11
+
<meta property="og:image:height" content="512" />
12
+
13
+
<meta name="twitter:card" content="summary" />
14
+
<meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
15
+
<meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
16
+
<meta name="twitter:image" content="{{ $avatarUrl }}" />
8
17
{{ end }}
9
18
10
19
{{ define "content" }}
+53
-25
appview/pages/templates/layouts/repobase.html
+53
-25
appview/pages/templates/layouts/repobase.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
-
{{ if .RepoInfo.Source }}
6
-
<p class="text-sm">
7
-
<div class="flex items-center">
8
-
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
9
-
forked from
10
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
-
</div>
13
-
</p>
14
-
{{ end }}
15
-
<div class="text-lg flex items-center justify-between">
16
-
<div>
17
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
-
<span class="select-none">/</span>
19
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
4
+
<section id="repo-header" class="mb-4 p-2 dark:text-white">
5
+
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
6
+
<!-- left items -->
7
+
<div class="flex flex-col gap-2">
8
+
<!-- repo owner / repo name -->
9
+
<div class="flex items-center gap-2 flex-wrap">
10
+
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
11
+
<span class="select-none">/</span>
12
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
13
+
</div>
14
+
15
+
{{ if .RepoInfo.Source }}
16
+
{{ $sourceOwner := resolve .RepoInfo.Source.Did }}
17
+
<div class="flex items-center gap-1 text-sm flex-wrap">
18
+
{{ i "git-fork" "w-3 h-3 shrink-0" }}
19
+
<span>forked from</span>
20
+
<a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">
21
+
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
22
+
</a>
23
+
</div>
24
+
{{ end }}
25
+
26
+
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
27
+
{{ if .RepoInfo.Description }}
28
+
{{ .RepoInfo.Description | description }}
29
+
{{ else }}
30
+
<span class="italic">this repo has no description</span>
31
+
{{ end }}
32
+
33
+
{{ with .RepoInfo.Website }}
34
+
<span class="flex items-center gap-1">
35
+
<span class="flex-shrink-0">{{ i "globe" "size-4" }}</span>
36
+
<a href="{{ . }}">{{ . | trimUriScheme }}</a>
37
+
</span>
38
+
{{ end }}
39
+
40
+
{{ if .RepoInfo.Topics }}
41
+
<div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300">
42
+
{{ range .RepoInfo.Topics }}
43
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span>
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
48
+
</span>
20
49
</div>
21
50
22
-
<div class="flex items-center gap-2 z-auto">
23
-
<a
24
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
-
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
-
>
27
-
{{ i "rss" "size-4" }}
28
-
</a>
51
+
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
29
52
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
53
<a
31
54
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
···
36
59
fork
37
60
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
61
</a>
62
+
<a
63
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
64
+
href="/{{ .RepoInfo.FullName }}/feed.atom">
65
+
{{ i "rss" "size-4" }}
66
+
<span class="md:hidden">atom</span>
67
+
</a>
39
68
</div>
40
69
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
42
70
</section>
43
71
44
72
<section class="w-full flex flex-col" >
···
79
107
</div>
80
108
</nav>
81
109
{{ block "repoContentLayout" . }}
82
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
110
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
83
111
{{ block "repoContent" . }}{{ end }}
84
112
</section>
85
113
{{ block "repoAfter" . }}{{ end }}
+11
-2
appview/pages/templates/notifications/fragments/item.html
+11
-2
appview/pages/templates/notifications/fragments/item.html
···
8
8
">
9
9
{{ template "notificationIcon" . }}
10
10
<div class="flex-1 w-full flex flex-col gap-1">
11
-
<span>{{ template "notificationHeader" . }}</span>
11
+
<div class="flex items-center gap-1">
12
+
<span>{{ template "notificationHeader" . }}</span>
13
+
<span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span>
14
+
</div>
12
15
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
16
</div>
14
17
···
19
22
{{ define "notificationIcon" }}
20
23
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
24
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
-
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
25
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10">
23
26
{{ i .Icon "size-3 text-black dark:text-white" }}
24
27
</div>
25
28
</div>
···
37
40
commented on an issue
38
41
{{ else if eq .Type "issue_closed" }}
39
42
closed an issue
43
+
{{ else if eq .Type "issue_reopen" }}
44
+
reopened an issue
40
45
{{ else if eq .Type "pull_created" }}
41
46
created a pull request
42
47
{{ else if eq .Type "pull_commented" }}
···
45
50
merged a pull request
46
51
{{ else if eq .Type "pull_closed" }}
47
52
closed a pull request
53
+
{{ else if eq .Type "pull_reopen" }}
54
+
reopened a pull request
48
55
{{ else if eq .Type "followed" }}
49
56
followed you
57
+
{{ else if eq .Type "user_mentioned" }}
58
+
mentioned you
50
59
{{ else }}
51
60
{{ end }}
52
61
{{ end }}
+62
-39
appview/pages/templates/repo/blob.html
+62
-39
appview/pages/templates/repo/blob.html
···
11
11
{{ end }}
12
12
13
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
14
{{ $linkstyle := "no-underline hover:underline" }}
19
15
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
20
16
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
36
32
</div>
37
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">
38
34
<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>
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>
51
56
{{ end }}
52
57
</div>
53
58
</div>
54
59
</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>
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>
76
89
{{ else }}
77
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
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>
78
99
{{ end }}
79
-
</div>
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>
80
103
{{ end }}
81
104
{{ template "fragments/multiline-select" }}
82
105
{{ end }}
+11
-11
appview/pages/templates/repo/commit.html
+11
-11
appview/pages/templates/repo/commit.html
···
24
24
</div>
25
25
</div>
26
26
27
-
<div class="flex items-center space-x-2">
28
-
<p class="text-sm text-gray-500 dark:text-gray-300">
29
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
27
+
<div class="flex flex-wrap items-center space-x-2">
28
+
<p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300">
29
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
30
30
31
-
{{ if $didOrHandle }}
32
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
33
33
{{ else }}
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
36
+
36
37
<span class="px-1 select-none before:content-['\00B7']"></span>
37
38
{{ template "repo/fragments/time" $commit.Author.When }}
38
39
<span class="px-1 select-none before:content-['\00B7']"></span>
39
-
</p>
40
40
41
-
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
42
41
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
42
+
43
43
{{ if $commit.Parent }}
44
-
{{ i "arrow-left" "w-3 h-3 mx-1" }}
45
-
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
44
+
{{ i "arrow-left" "w-3 h-3 mx-1" }}
45
+
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
46
46
{{ end }}
47
47
</p>
48
48
···
58
58
<div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div>
59
59
<div class="flex items-center gap-2 my-2">
60
60
{{ i "user" "w-4 h-4" }}
61
-
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
61
+
{{ $committerDid := index $.EmailToDid $commit.Committer.Email }}
62
+
{{ template "user/fragments/picHandleLink" $committerDid }}
63
63
</div>
64
64
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
35
35
36
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
-
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
40
</div>
41
41
</div>
+4
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
+4
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
···
29
29
<code
30
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code>
34
34
<button
35
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
48
48
<code
49
49
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
50
50
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
51
+
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
53
<button
54
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
55
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
···
5
5
{{ if .Split }}
6
6
{{ $active = "split" }}
7
7
{{ end }}
8
-
{{ $values := list "unified" "split" }}
9
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
8
+
9
+
{{ $unified :=
10
+
(dict
11
+
"Key" "unified"
12
+
"Value" "unified"
13
+
"Icon" "square-split-vertical"
14
+
"Meta" "") }}
15
+
{{ $split :=
16
+
(dict
17
+
"Key" "split"
18
+
"Value" "split"
19
+
"Icon" "square-split-horizontal"
20
+
"Meta" "") }}
21
+
{{ $values := list $unified $split }}
22
+
23
+
{{ template "fragments/tabSelector"
24
+
(dict
25
+
"Name" "diff"
26
+
"Values" $values
27
+
"Active" $active) }}
10
28
</section>
11
29
{{ end }}
12
30
13
-
{{ define "tabSelector" }}
14
-
{{ $name := .Name }}
15
-
{{ $all := .Values }}
16
-
{{ $active := .Active }}
17
-
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
18
-
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
19
-
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
20
-
{{ range $index, $value := $all }}
21
-
{{ $isActive := eq $value $active }}
22
-
<a href="?{{ $name }}={{ $value }}"
23
-
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
24
-
{{ $value }}
25
-
</a>
26
-
{{ end }}
27
-
</div>
28
-
{{ end }}
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
1
-
{{ define "repo/fragments/editRepoDescription" }}
2
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
-
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
5
-
{{ i "check" "w-3 h-3" }} save
6
-
</button>
7
-
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
-
{{ i "x" "w-3 h-3" }} cancel
9
-
</button>
10
-
</form>
11
-
{{ end }}
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
···
1
+
{{ define "repo/fragments/externalLinkPanel" }}
2
+
<div id="at-uri-panel" class="px-2 md:px-0">
3
+
<div class="flex justify-between items-center gap-2">
4
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span>
5
+
<div class="flex items-center gap-2">
6
+
<button
7
+
onclick="copyToClipboard(this)"
8
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
9
+
title="Copy to clipboard">
10
+
{{ i "copy" "w-4 h-4" }}
11
+
</button>
12
+
<a
13
+
href="https://pdsls.dev/{{.}}"
14
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
15
+
title="View in PDSls">
16
+
{{ i "arrow-up-right" "w-4 h-4" }}
17
+
</a>
18
+
</div>
19
+
</div>
20
+
<span
21
+
class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600"
22
+
onclick="window.getSelection().selectAllChildren(this)"
23
+
title="{{.}}"
24
+
data-aturi="{{ . | string | safeUrl }}"
25
+
>{{.}}</span>
26
+
27
+
28
+
</div>
29
+
30
+
<script>
31
+
function copyToClipboard(button) {
32
+
const container = document.getElementById("at-uri-panel");
33
+
const urlSpan = container?.querySelector('[data-aturi]');
34
+
const text = urlSpan?.getAttribute('data-aturi');
35
+
console.log("copying to clipboard", text)
36
+
if (!text) return;
37
+
38
+
navigator.clipboard.writeText(text).then(() => {
39
+
const originalContent = button.innerHTML;
40
+
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
41
+
setTimeout(() => {
42
+
button.innerHTML = originalContent;
43
+
}, 2000);
44
+
});
45
+
}
46
+
</script>
47
+
{{ end }}
48
+
+1
-1
appview/pages/templates/repo/fragments/og.html
+1
-1
appview/pages/templates/repo/fragments/og.html
···
11
11
<meta property="og:image" content="{{ $imageUrl }}" />
12
12
<meta property="og:image:width" content="1200" />
13
13
<meta property="og:image:height" content="600" />
14
-
14
+
15
15
<meta name="twitter:card" content="summary_large_image" />
16
16
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
17
<meta name="twitter:description" content="{{ $description }}" />
-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
35
{{ end }}
36
36
37
37
{{ define "repoLanguages" }}
38
-
<details class="group -m-6 mb-4">
38
+
<details class="group -my-4 -m-6 mb-4">
39
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
40
{{ range $value := .Languages }}
41
41
<div
···
129
129
{{ $icon := "folder" }}
130
130
{{ $iconStyle := "size-4 fill-current" }}
131
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
+
132
138
{{ if .IsFile }}
133
139
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
140
{{ $icon = "file" }}
135
141
{{ $iconStyle = "size-4" }}
136
142
{{ end }}
143
+
137
144
<a href="{{ $link }}" class="{{ $linkstyle }}">
138
145
<div class="flex items-center gap-2">
139
146
{{ i $icon $iconStyle "flex-shrink-0" }}
···
222
229
class="mx-1 before:content-['·'] before:select-none"
223
230
></span>
224
231
<span>
225
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
226
-
<a
227
-
href="{{ if $didOrHandle }}
228
-
/{{ $didOrHandle }}
229
-
{{ else }}
230
-
mailto:{{ .Author.Email }}
231
-
{{ end }}"
232
+
{{ $did := index $.EmailToDid .Author.Email }}
233
+
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
232
234
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
-
>
235
+
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
239
236
</span>
240
237
<div class="inline-block px-1 select-none after:content-['·']"></div>
241
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
34
35
35
{{ define "editIssueComment" }}
36
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
38
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
44
45
45
{{ define "deleteIssueComment" }}
46
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
48
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
···
8
8
class="no-underline hover:underline"
9
9
>
10
10
{{ .Title | description }}
11
-
<span class="text-gray-500">#{{ .IssueId }}</span>
11
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
12
12
</a>
13
13
</div>
14
14
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+19
appview/pages/templates/repo/issues/fragments/og.html
+19
appview/pages/templates/repo/issues/fragments/og.html
···
1
+
{{ define "repo/issues/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
3
+
{{ $description := or .Issue.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+4
-6
appview/pages/templates/repo/issues/issue.html
+4
-6
appview/pages/templates/repo/issues/issue.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
5
-
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
-
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
-
8
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
5
+
{{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }}
9
6
{{ end }}
10
7
11
8
{{ define "repoContentLayout" }}
···
23
20
"Subject" $.Issue.AtUri
24
21
"State" $.Issue.Labels) }}
25
22
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
+
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
26
24
</div>
27
25
</div>
28
26
{{ end }}
···
87
85
88
86
{{ define "editIssue" }}
89
87
<a
90
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
88
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
91
89
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
92
90
hx-swap="innerHTML"
93
91
hx-target="#issue-{{.Issue.IssueId}}">
···
97
95
98
96
{{ define "deleteIssue" }}
99
97
<a
100
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
98
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
101
99
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
102
100
hx-confirm="Are you sure you want to delete your issue?"
103
101
hx-swap="none">
+43
-24
appview/pages/templates/repo/issues/issues.html
+43
-24
appview/pages/templates/repo/issues/issues.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
12
-
<div class="flex gap-4">
11
+
{{ $active := "closed" }}
12
+
{{ if .FilteringByOpen }}
13
+
{{ $active = "open" }}
14
+
{{ end }}
15
+
16
+
{{ $open :=
17
+
(dict
18
+
"Key" "open"
19
+
"Value" "open"
20
+
"Icon" "circle-dot"
21
+
"Meta" (string .RepoInfo.Stats.IssueCount.Open)) }}
22
+
{{ $closed :=
23
+
(dict
24
+
"Key" "closed"
25
+
"Value" "closed"
26
+
"Icon" "ban"
27
+
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
28
+
{{ $values := list $open $closed }}
29
+
30
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
31
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
32
+
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
33
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
34
+
{{ i "search" "w-4 h-4" }}
35
+
</div>
36
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
37
+
<a
38
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
39
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
40
+
>
41
+
{{ i "x" "w-4 h-4" }}
42
+
</a>
43
+
</form>
44
+
<div class="sm:row-start-1">
45
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
46
+
</div>
13
47
<a
14
-
href="?state=open"
15
-
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
-
>
17
-
{{ i "circle-dot" "w-4 h-4" }}
18
-
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
19
-
</a>
20
-
<a
21
-
href="?state=closed"
22
-
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
-
>
24
-
{{ i "ban" "w-4 h-4" }}
25
-
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
26
-
</a>
27
-
</div>
28
-
<a
29
48
href="/{{ .RepoInfo.FullName }}/issues/new"
30
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
31
-
>
49
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
50
+
>
32
51
{{ i "circle-plus" "w-4 h-4" }}
33
52
<span>new</span>
34
-
</a>
35
-
</div>
36
-
<div class="error" id="issues"></div>
53
+
</a>
54
+
</div>
55
+
<div class="error" id="issues"></div>
37
56
{{ end }}
38
57
39
58
{{ define "repoAfter" }}
···
55
74
<a
56
75
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
76
hx-boost="true"
58
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
77
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
59
78
>
60
79
{{ i "chevron-left" "w-4 h-4" }}
61
80
previous
···
69
88
<a
70
89
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
71
90
hx-boost="true"
72
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
91
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
73
92
>
74
93
next
75
94
{{ i "chevron-right" "w-4 h-4" }}
+6
-6
appview/pages/templates/repo/log.html
+6
-6
appview/pages/templates/repo/log.html
···
27
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
28
<div class="{{ $grid }} py-3">
29
29
<div class="align-top truncate col-span-2">
30
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
-
{{ if $didOrHandle }}
32
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
30
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
33
33
{{ else }}
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
···
153
153
</span>
154
154
<span class="mx-2 before:content-['·'] before:select-none"></span>
155
155
<span>
156
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
157
-
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
156
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
+
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
-
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
159
+
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
160
</a>
161
161
</span>
162
162
<div class="inline-block px-1 select-none after:content-['·']"></div>
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
2
2
<div id="lines" hx-swap-oob="beforeend">
3
3
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
4
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
5
-
<div class="group-open:hidden flex items-center gap-1">
6
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
-
</div>
8
-
<div class="hidden group-open:flex items-center gap-1">
9
-
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
-
</div>
5
+
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
6
+
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
11
7
</summary>
12
8
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
13
9
</details>
14
10
</div>
15
11
{{ end }}
12
+
13
+
{{ define "stepHeader" }}
14
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
15
+
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
16
+
{{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
···
1
+
{{ define "repo/pipelines/fragments/logBlockEnd" }}
2
+
<span
3
+
class="ml-auto text-sm text-gray-500 tabular-nums"
4
+
data-timer="{{ .Id }}"
5
+
data-start="{{ .StartTime.Unix }}"
6
+
data-end="{{ .EndTime.Unix }}"
7
+
hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span>
8
+
{{ end }}
9
+
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
···
12
12
{{ range .Pipelines }}
13
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
14
{{ else }}
15
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
16
-
No pipelines run for this repository.
17
-
</p>
15
+
<div class="py-6 w-fit flex flex-col gap-4 mx-auto">
16
+
<p>
17
+
No pipelines have been run for this repository yet. To get started:
18
+
</p>
19
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
20
+
<p>
21
+
<span class="{{ $bullet }}">1</span>First, choose a spindle in your
22
+
<a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>.
23
+
</p>
24
+
<p>
25
+
<span class="{{ $bullet }}">2</span>Configure your CI/CD
26
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
27
+
</p>
28
+
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
+
</div>
18
30
{{ end }}
19
31
</div>
20
32
</div>
+6
appview/pages/templates/repo/pipelines/workflow.html
+6
appview/pages/templates/repo/pipelines/workflow.html
···
15
15
{{ block "logs" . }} {{ end }}
16
16
</div>
17
17
</section>
18
+
{{ template "fragments/workflow-timers" }}
18
19
{{ end }}
19
20
20
21
{{ define "sidebar" }}
···
58
59
hx-ext="ws"
59
60
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
60
61
<div id="lines" class="flex flex-col gap-2">
62
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded">
63
+
<span class="flex items-center gap-2">
64
+
{{ i "triangle-alert" "size-4" }} No logs for this workflow
65
+
</span>
66
+
</div>
61
67
</div>
62
68
</div>
63
69
{{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
+19
appview/pages/templates/repo/pulls/fragments/og.html
···
1
+
{{ define "repo/pulls/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
3
+
{{ $description := or .Pull.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
24
{{ $isUpToDate := .ResubmitCheck.No }}
25
-
<div class="relative w-fit">
26
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
27
-
<button
28
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
29
-
hx-target="#actions-{{$roundNumber}}"
30
-
hx-swap="outerHtml"
31
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
32
-
{{ i "message-square-plus" "w-4 h-4" }}
33
-
<span>comment</span>
34
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
-
</button>
36
-
{{ if .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 }}
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 }}
62
61
63
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
64
-
{{ $disabled := "" }}
65
-
{{ if $isUpToDate }}
66
-
{{ $disabled = "disabled" }}
62
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
63
+
{{ $disabled := "" }}
64
+
{{ if $isUpToDate }}
65
+
{{ $disabled = "disabled" }}
66
+
{{ end }}
67
+
<button id="resubmitBtn"
68
+
{{ if not .Pull.IsPatchBased }}
69
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
70
+
{{ else }}
71
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
72
+
hx-target="#actions-{{$roundNumber}}"
73
+
hx-swap="outerHtml"
67
74
{{ 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
75
77
-
hx-disabled-elt="#resubmitBtn"
78
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
79
78
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 }}
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 }}
91
90
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 }}
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 }}
102
101
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>
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 }}
114
112
</div>
115
113
{{ end }}
116
114
+12
-10
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+12
-10
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
42
42
{{ if not .Pull.IsPatchBased }}
43
43
from
44
44
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
45
-
{{ if .Pull.IsForkBased }}
46
-
{{ if .Pull.PullSource.Repo }}
47
-
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
-
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
-
{{- else -}}
50
-
<span class="italic">[deleted fork]</span>
51
-
{{- end -}}
52
-
{{- end -}}
53
-
{{- .Pull.PullSource.Branch -}}
45
+
{{ if not .Pull.IsForkBased }}
46
+
{{ $repoPath := .RepoInfo.FullName }}
47
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
48
+
{{ else if .Pull.PullSource.Repo }}
49
+
{{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }}
50
+
<a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>:
51
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
52
+
{{ else }}
53
+
<span class="italic">[deleted fork]</span>:
54
+
{{ .Pull.PullSource.Branch }}
55
+
{{ end }}
54
56
</span>
55
57
{{ end }}
56
58
</span>
···
73
75
"Kind" $kind
74
76
"Count" $reactionData.Count
75
77
"IsReacted" (index $.UserReacted $kind)
76
-
"ThreadAt" $.Pull.PullAt
78
+
"ThreadAt" $.Pull.AtUri
77
79
"Users" $reactionData.Users)
78
80
}}
79
81
{{ end }}
+3
-5
appview/pages/templates/repo/pulls/pull.html
+3
-5
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "extrameta" }}
6
-
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
-
9
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
6
+
{{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }}
10
7
{{ end }}
11
8
12
9
{{ define "repoContentLayout" }}
···
21
18
{{ template "repo/fragments/labelPanel"
22
19
(dict "RepoInfo" $.RepoInfo
23
20
"Defs" $.LabelDefs
24
-
"Subject" $.Pull.PullAt
21
+
"Subject" $.Pull.AtUri
25
22
"State" $.Pull.Labels) }}
26
23
{{ template "repo/fragments/participants" $.Pull.Participants }}
24
+
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
27
25
</div>
28
26
</div>
29
27
{{ end }}
+52
-34
appview/pages/templates/repo/pulls/pulls.html
+52
-34
appview/pages/templates/repo/pulls/pulls.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center">
12
-
<div class="flex gap-4">
13
-
<a
14
-
href="?state=open"
15
-
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
-
>
17
-
{{ i "git-pull-request" "w-4 h-4" }}
18
-
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
19
-
</a>
20
-
<a
21
-
href="?state=merged"
22
-
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
-
>
24
-
{{ i "git-merge" "w-4 h-4" }}
25
-
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
26
-
</a>
27
-
<a
28
-
href="?state=closed"
29
-
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
30
-
>
31
-
{{ i "ban" "w-4 h-4" }}
32
-
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
33
-
</a>
34
-
</div>
35
-
<a
36
-
href="/{{ .RepoInfo.FullName }}/pulls/new"
37
-
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
38
-
>
39
-
{{ i "git-pull-request-create" "w-4 h-4" }}
40
-
<span>new</span>
41
-
</a>
11
+
{{ $active := "closed" }}
12
+
{{ if .FilteringBy.IsOpen }}
13
+
{{ $active = "open" }}
14
+
{{ else if .FilteringBy.IsMerged }}
15
+
{{ $active = "merged" }}
16
+
{{ end }}
17
+
{{ $open :=
18
+
(dict
19
+
"Key" "open"
20
+
"Value" "open"
21
+
"Icon" "git-pull-request"
22
+
"Meta" (string .RepoInfo.Stats.PullCount.Open)) }}
23
+
{{ $merged :=
24
+
(dict
25
+
"Key" "merged"
26
+
"Value" "merged"
27
+
"Icon" "git-merge"
28
+
"Meta" (string .RepoInfo.Stats.PullCount.Merged)) }}
29
+
{{ $closed :=
30
+
(dict
31
+
"Key" "closed"
32
+
"Value" "closed"
33
+
"Icon" "ban"
34
+
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
35
+
{{ $values := list $open $merged $closed }}
36
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
37
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
38
+
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
39
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
40
+
{{ i "search" "w-4 h-4" }}
41
+
</div>
42
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
43
+
<a
44
+
href="?state={{ .FilteringBy.String }}"
45
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
46
+
>
47
+
{{ i "x" "w-4 h-4" }}
48
+
</a>
49
+
</form>
50
+
<div class="sm:row-start-1">
51
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
42
52
</div>
43
-
<div class="error" id="pulls"></div>
53
+
<a
54
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
55
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
56
+
>
57
+
{{ i "git-pull-request-create" "w-4 h-4" }}
58
+
<span>new</span>
59
+
</a>
60
+
</div>
61
+
<div class="error" id="pulls"></div>
44
62
{{ end }}
45
63
46
64
{{ define "repoAfter" }}
···
133
151
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
134
152
</div>
135
153
</summary>
136
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
154
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
137
155
</details>
138
156
{{ end }}
139
157
{{ end }}
···
142
160
</div>
143
161
{{ end }}
144
162
145
-
{{ define "pullList" }}
163
+
{{ define "stackedPullList" }}
146
164
{{ $list := index . 0 }}
147
165
{{ $root := index . 1 }}
148
166
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
+17
-8
appview/pages/templates/repo/settings/access.html
+17
-8
appview/pages/templates/repo/settings/access.html
···
66
66
<div
67
67
id="add-collaborator-modal"
68
68
popover
69
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
69
+
class="
70
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
71
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
72
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
70
73
{{ template "addCollaboratorModal" . }}
71
74
</div>
72
75
{{ end }}
···
82
85
ADD COLLABORATOR
83
86
</label>
84
87
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
-
<input
86
-
type="text"
87
-
id="add-collaborator"
88
-
name="collaborator"
89
-
required
90
-
placeholder="@foo.bsky.social"
91
-
/>
88
+
<actor-typeahead>
89
+
<input
90
+
autocapitalize="none"
91
+
autocorrect="off"
92
+
autocomplete="off"
93
+
type="text"
94
+
id="add-collaborator"
95
+
name="collaborator"
96
+
required
97
+
placeholder="user.tngl.sh"
98
+
class="w-full"
99
+
/>
100
+
</actor-typeahead>
92
101
<div class="flex gap-2 pt-2">
93
102
<button
94
103
type="button"
+47
appview/pages/templates/repo/settings/general.html
+47
appview/pages/templates/repo/settings/general.html
···
6
6
{{ template "repo/settings/fragments/sidebar" . }}
7
7
</div>
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "baseSettings" . }}
9
10
{{ template "branchSettings" . }}
10
11
{{ template "defaultLabelSettings" . }}
11
12
{{ template "customLabelSettings" . }}
···
13
14
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
15
</div>
15
16
</section>
17
+
{{ end }}
18
+
19
+
{{ define "baseSettings" }}
20
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none">
21
+
<fieldset
22
+
class=""
23
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
24
+
>
25
+
<h2 class="text-sm pb-2 uppercase font-bold">Description</h2>
26
+
<textarea
27
+
rows="3"
28
+
class="w-full mb-2"
29
+
id="base-form-description"
30
+
name="description"
31
+
>{{ .RepoInfo.Description }}</textarea>
32
+
<h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2>
33
+
<input
34
+
type="text"
35
+
class="w-full mb-2"
36
+
id="base-form-website"
37
+
name="website"
38
+
value="{{ .RepoInfo.Website }}"
39
+
>
40
+
<h2 class="text-sm pb-2 uppercase font-bold">Topics</h2>
41
+
<p class="text-gray-500 dark:text-gray-400">
42
+
List of topics separated by spaces.
43
+
</p>
44
+
<textarea
45
+
rows="2"
46
+
class="w-full my-2"
47
+
id="base-form-topics"
48
+
name="topics"
49
+
>{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea>
50
+
<div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div>
51
+
<div class="flex justify-end pt-2">
52
+
<button
53
+
type="submit"
54
+
class="btn-create flex items-center gap-2 group"
55
+
>
56
+
{{ i "save" "w-4 h-4" }}
57
+
save
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
</button>
60
+
</div>
61
+
<fieldset>
62
+
</form>
16
63
{{ end }}
17
64
18
65
{{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
+8
appview/pages/templates/repo/tree.html
···
59
59
{{ $icon := "folder" }}
60
60
{{ $iconStyle := "size-4 fill-current" }}
61
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
+
62
68
{{ if .IsFile }}
69
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
63
70
{{ $icon = "file" }}
64
71
{{ $iconStyle = "size-4" }}
65
72
{{ end }}
73
+
66
74
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
75
<div class="flex items-center gap-2">
68
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
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
17
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
+3
-3
appview/pages/templates/strings/string.html
+3
-3
appview/pages/templates/strings/string.html
···
47
47
</span>
48
48
</section>
49
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
50
-
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
50
+
<div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
51
51
<span>
52
52
{{ .String.Filename }}
53
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
···
75
75
</div>
76
76
<div class="overflow-x-auto overflow-y-hidden relative">
77
77
{{ if .ShowRendered }}
78
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
78
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
79
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>
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
81
{{ end }}
82
82
</div>
83
83
{{ template "fragments/multiline-select" }}
+2
-2
appview/pages/templates/timeline/fragments/hero.html
+2
-2
appview/pages/templates/timeline/fragments/hero.html
···
4
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
5
6
6
<p class="text-lg">
7
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
7
+
Tangled is a decentralized Git hosting and collaboration platform.
8
8
</p>
9
9
<p class="text-lg">
10
-
we envision a place where developers have complete ownership of their
10
+
We envision a place where developers have complete ownership of their
11
11
code, open source communities can freely self-govern and most
12
12
importantly, coding can be social and fun again.
13
13
</p>
+11
appview/pages/templates/user/fragments/editBio.html
+11
appview/pages/templates/user/fragments/editBio.html
···
20
20
</div>
21
21
22
22
<div class="flex flex-col gap-1">
23
+
<label class="m-0 p-0" for="pronouns">pronouns</label>
24
+
<div class="flex items-center gap-2 w-full">
25
+
{{ $pronouns := "" }}
26
+
{{ if and .Profile .Profile.Pronouns }}
27
+
{{ $pronouns = .Profile.Pronouns }}
28
+
{{ end }}
29
+
<input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}">
30
+
</div>
31
+
</div>
32
+
33
+
<div class="flex flex-col gap-1">
23
34
<label class="m-0 p-0" for="location">location</label>
24
35
<div class="flex items-center gap-2 w-full">
25
36
{{ $location := "" }}
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
3
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
7
</div>
8
8
9
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+19
-6
appview/pages/templates/user/fragments/profileCard.html
+19
-6
appview/pages/templates/user/fragments/profileCard.html
···
12
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
13
{{ $userIdent }}
14
14
</p>
15
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
15
+
{{ with .Profile }}
16
+
{{ if .Pronouns }}
17
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
+
{{ end }}
19
+
{{ end }}
16
20
</div>
17
21
18
22
<div class="md:hidden">
···
67
71
{{ end }}
68
72
</div>
69
73
{{ end }}
70
-
{{ if ne .FollowStatus.String "IsSelf" }}
71
-
{{ template "user/fragments/follow" . }}
72
-
{{ else }}
74
+
75
+
<div class="flex mt-2 items-center gap-2">
76
+
{{ if ne .FollowStatus.String "IsSelf" }}
77
+
{{ template "user/fragments/follow" . }}
78
+
{{ else }}
73
79
<button id="editBtn"
74
-
class="btn mt-2 w-full flex items-center gap-2 group"
80
+
class="btn w-full flex items-center gap-2 group"
75
81
hx-target="#profile-bio"
76
82
hx-get="/profile/edit-bio"
77
83
hx-swap="innerHTML">
···
79
85
edit
80
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
87
</button>
82
-
{{ end }}
88
+
{{ end }}
89
+
90
+
<a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
91
+
href="/{{ $userIdent }}/feed.atom">
92
+
{{ i "rss" "size-4" }}
93
+
</a>
94
+
</div>
95
+
83
96
</div>
84
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
85
98
</div>
+23
-2
appview/pages/templates/user/login.html
+23
-2
appview/pages/templates/user/login.html
···
13
13
<title>login · tangled</title>
14
14
</head>
15
15
<body class="flex items-center justify-center min-h-screen">
16
-
<main class="max-w-md px-6 -mt-4">
16
+
<main class="max-w-md px-7 mt-4">
17
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
18
18
{{ template "fragments/logotype" }}
19
19
</h1>
···
21
21
tightly-knit social coding.
22
22
</h2>
23
23
<form
24
-
class="mt-4 max-w-sm mx-auto"
24
+
class="mt-4"
25
25
hx-post="/login"
26
26
hx-swap="none"
27
27
hx-disabled-elt="#login-button"
···
29
29
<div class="flex flex-col">
30
30
<label for="handle">handle</label>
31
31
<input
32
+
autocapitalize="none"
33
+
autocorrect="off"
34
+
autocomplete="username"
32
35
type="text"
33
36
id="handle"
34
37
name="handle"
···
53
56
<span>login</span>
54
57
</button>
55
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 }}
56
77
<p class="text-sm text-gray-500">
57
78
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
58
79
</p>
+14
appview/pages/templates/user/settings/notifications.html
+14
appview/pages/templates/user/settings/notifications.html
···
144
144
<div class="flex items-center justify-between p-2">
145
145
<div class="flex items-center gap-2">
146
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">
147
161
<span class="font-bold">Email notifications</span>
148
162
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
163
<span>Receive notifications via email in addition to in-app notifications.</span>
+46
appview/pagination/page.go
+46
appview/pagination/page.go
···
1
1
package pagination
2
2
3
+
import "context"
4
+
3
5
type Page struct {
4
6
Offset int // where to start from
5
7
Limit int // number of items in a page
···
10
12
Offset: 0,
11
13
Limit: 30,
12
14
}
15
+
}
16
+
17
+
type ctxKey struct{}
18
+
19
+
func IntoContext(ctx context.Context, page Page) context.Context {
20
+
return context.WithValue(ctx, ctxKey{}, page)
21
+
}
22
+
23
+
func FromContext(ctx context.Context) Page {
24
+
if ctx == nil {
25
+
return FirstPage()
26
+
}
27
+
v := ctx.Value(ctxKey{})
28
+
if v == nil {
29
+
return FirstPage()
30
+
}
31
+
page, ok := v.(Page)
32
+
if !ok {
33
+
return FirstPage()
34
+
}
35
+
return page
13
36
}
14
37
15
38
func (p Page) Previous() Page {
···
29
52
Limit: p.Limit,
30
53
}
31
54
}
55
+
56
+
func IterateAll[T any](
57
+
fetch func(page Page) ([]T, error),
58
+
handle func(items []T) error,
59
+
) error {
60
+
page := FirstPage()
61
+
for {
62
+
items, err := fetch(page)
63
+
if err != nil {
64
+
return err
65
+
}
66
+
67
+
err = handle(items)
68
+
if err != nil {
69
+
return err
70
+
}
71
+
if len(items) < page.Limit {
72
+
break
73
+
}
74
+
page = page.Next()
75
+
}
76
+
return nil
77
+
}
+34
-13
appview/pipelines/pipelines.go
+34
-13
appview/pipelines/pipelines.go
···
35
35
logger *slog.Logger
36
36
}
37
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
+
38
47
func New(
39
48
oauth *oauth.OAuth,
40
49
repoResolver *reporesolver.RepoResolver,
···
227
236
// start a goroutine to read from spindle
228
237
go readLogs(spindleConn, evChan)
229
238
230
-
stepIdx := 0
239
+
stepStartTimes := make(map[int]time.Time)
231
240
var fragment bytes.Buffer
232
241
for {
233
242
select {
···
259
268
260
269
switch logLine.Kind {
261
270
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
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
+
})
267
293
}
268
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
269
-
Id: stepIdx,
270
-
Name: logLine.Content,
271
-
Command: logLine.StepCommand,
272
-
Collapsed: collapsed,
273
-
})
294
+
274
295
case spindlemodel.LogKindData:
275
296
// data messages simply insert new log lines into current step
276
297
err = p.pages.LogLine(&fragment, pages.LogLineParams{
277
-
Id: stepIdx,
298
+
Id: logLine.StepId,
278
299
Content: logLine.Content,
279
300
})
280
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
-
}
+321
appview/pulls/opengraph.go
+321
appview/pulls/opengraph.go
···
1
+
package pulls
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"log"
11
+
"net/http"
12
+
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/ogcard"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
18
+
)
19
+
20
+
func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) {
21
+
width, height := ogcard.DefaultSize()
22
+
mainCard, err := ogcard.NewCard(width, height)
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
// Split: content area (75%) and status/stats area (25%)
28
+
contentCard, statsArea := mainCard.Split(false, 75)
29
+
30
+
// Add padding to content
31
+
contentCard.SetMargin(50)
32
+
33
+
// Split content horizontally: main content (80%) and avatar area (20%)
34
+
mainContent, avatarArea := contentCard.Split(true, 80)
35
+
36
+
// Add margin to main content
37
+
mainContent.SetMargin(10)
38
+
39
+
// Use full main content area for repo name and title
40
+
bounds := mainContent.Img.Bounds()
41
+
startX := bounds.Min.X + mainContent.Margin
42
+
startY := bounds.Min.Y + mainContent.Margin
43
+
44
+
// Draw full repository name at top (owner/repo format)
45
+
var repoOwner string
46
+
owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did)
47
+
if err != nil {
48
+
repoOwner = repo.Did
49
+
} else {
50
+
repoOwner = "@" + owner.Handle.String()
51
+
}
52
+
53
+
fullRepoName := repoOwner + " / " + repo.Name
54
+
if len(fullRepoName) > 60 {
55
+
fullRepoName = fullRepoName[:60] + "…"
56
+
}
57
+
58
+
grayColor := color.RGBA{88, 96, 105, 255}
59
+
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
60
+
if err != nil {
61
+
return nil, err
62
+
}
63
+
64
+
// Draw pull request title below repo name with wrapping
65
+
titleY := startY + 60
66
+
titleX := startX
67
+
68
+
// Truncate title if too long
69
+
pullTitle := pull.Title
70
+
maxTitleLength := 80
71
+
if len(pullTitle) > maxTitleLength {
72
+
pullTitle = pullTitle[:maxTitleLength] + "…"
73
+
}
74
+
75
+
// Create a temporary card for the title area to enable wrapping
76
+
titleBounds := mainContent.Img.Bounds()
77
+
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
78
+
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID
79
+
80
+
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
81
+
titleCard := &ogcard.Card{
82
+
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
83
+
Font: mainContent.Font,
84
+
Margin: 0,
85
+
}
86
+
87
+
// Draw wrapped title
88
+
lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left)
89
+
if err != nil {
90
+
return nil, err
91
+
}
92
+
93
+
// Calculate where title ends (number of lines * line height)
94
+
lineHeight := 60 // Approximate line height for 54pt font
95
+
titleEndY := titleY + (len(lines) * lineHeight) + 10
96
+
97
+
// Draw pull ID in gray below the title
98
+
pullIdText := fmt.Sprintf("#%d", pull.PullId)
99
+
err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
// Get pull author handle (needed for avatar and metadata)
105
+
var authorHandle string
106
+
author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid)
107
+
if err != nil {
108
+
authorHandle = pull.OwnerDid
109
+
} else {
110
+
authorHandle = "@" + author.Handle.String()
111
+
}
112
+
113
+
// Draw avatar circle on the right side
114
+
avatarBounds := avatarArea.Img.Bounds()
115
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
116
+
if avatarSize > 220 {
117
+
avatarSize = 220
118
+
}
119
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
120
+
avatarY := avatarBounds.Min.Y + 20
121
+
122
+
// Get avatar URL for pull author
123
+
avatarURL := s.pages.AvatarUrl(authorHandle, "256")
124
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
125
+
if err != nil {
126
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
127
+
}
128
+
129
+
// Split stats area: left side for status/stats (80%), right side for dolly (20%)
130
+
statusStatsArea, dollyArea := statsArea.Split(true, 80)
131
+
132
+
// Draw status and stats
133
+
statsBounds := statusStatsArea.Img.Bounds()
134
+
statsX := statsBounds.Min.X + 60 // left padding
135
+
statsY := statsBounds.Min.Y
136
+
137
+
iconColor := color.RGBA{88, 96, 105, 255}
138
+
iconSize := 36
139
+
textSize := 36.0
140
+
labelSize := 28.0
141
+
iconBaselineOffset := int(textSize) / 2
142
+
143
+
// Draw status (open/merged/closed) with colored icon and text
144
+
var statusIcon string
145
+
var statusText string
146
+
var statusColor color.RGBA
147
+
148
+
if pull.State.IsOpen() {
149
+
statusIcon = "git-pull-request"
150
+
statusText = "open"
151
+
statusColor = color.RGBA{34, 139, 34, 255} // green
152
+
} else if pull.State.IsMerged() {
153
+
statusIcon = "git-merge"
154
+
statusText = "merged"
155
+
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
+
} else {
157
+
statusIcon = "git-pull-request-closed"
158
+
statusText = "closed"
159
+
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
+
}
161
+
162
+
statusIconSize := 36
163
+
164
+
// Draw icon with status color
165
+
err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
+
if err != nil {
167
+
log.Printf("failed to draw status icon: %v", err)
168
+
}
169
+
170
+
// Draw text with status color
171
+
textX := statsX + statusIconSize + 12
172
+
statusTextSize := 32.0
173
+
err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
174
+
if err != nil {
175
+
log.Printf("failed to draw status text: %v", err)
176
+
}
177
+
178
+
statusTextWidth := len(statusText) * 20
179
+
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
+
181
+
// Draw comment count
182
+
err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
+
if err != nil {
184
+
log.Printf("failed to draw comment icon: %v", err)
185
+
}
186
+
187
+
currentX += iconSize + 15
188
+
commentText := fmt.Sprintf("%d comments", commentCount)
189
+
if commentCount == 1 {
190
+
commentText = "1 comment"
191
+
}
192
+
err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
193
+
if err != nil {
194
+
log.Printf("failed to draw comment text: %v", err)
195
+
}
196
+
197
+
commentTextWidth := len(commentText) * 20
198
+
currentX += commentTextWidth + 40
199
+
200
+
// Draw files changed
201
+
err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
+
if err != nil {
203
+
log.Printf("failed to draw file diff icon: %v", err)
204
+
}
205
+
206
+
currentX += iconSize + 15
207
+
filesText := fmt.Sprintf("%d files", filesChanged)
208
+
if filesChanged == 1 {
209
+
filesText = "1 file"
210
+
}
211
+
err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
212
+
if err != nil {
213
+
log.Printf("failed to draw files text: %v", err)
214
+
}
215
+
216
+
filesTextWidth := len(filesText) * 20
217
+
currentX += filesTextWidth
218
+
219
+
// Draw additions (green +)
220
+
greenColor := color.RGBA{34, 139, 34, 255}
221
+
additionsText := fmt.Sprintf("+%d", diffStats.Insertions)
222
+
err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
223
+
if err != nil {
224
+
log.Printf("failed to draw additions text: %v", err)
225
+
}
226
+
227
+
additionsTextWidth := len(additionsText) * 20
228
+
currentX += additionsTextWidth + 30
229
+
230
+
// Draw deletions (red -) right next to additions
231
+
redColor := color.RGBA{220, 20, 60, 255}
232
+
deletionsText := fmt.Sprintf("-%d", diffStats.Deletions)
233
+
err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
234
+
if err != nil {
235
+
log.Printf("failed to draw deletions text: %v", err)
236
+
}
237
+
238
+
// Draw dolly logo on the right side
239
+
dollyBounds := dollyArea.Img.Bounds()
240
+
dollySize := 90
241
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
245
+
if err != nil {
246
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
+
}
248
+
249
+
// Draw "opened by @author" and date at the bottom with more spacing
250
+
labelY := statsY + iconSize + 30
251
+
252
+
// Format the opened date
253
+
openedDate := pull.Created.Format("Jan 2, 2006")
254
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
255
+
256
+
err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
257
+
if err != nil {
258
+
log.Printf("failed to draw metadata: %v", err)
259
+
}
260
+
261
+
return mainCard, nil
262
+
}
263
+
264
+
func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
265
+
f, err := s.repoResolver.Resolve(r)
266
+
if err != nil {
267
+
log.Println("failed to get repo and knot", err)
268
+
return
269
+
}
270
+
271
+
pull, ok := r.Context().Value("pull").(*models.Pull)
272
+
if !ok {
273
+
log.Println("pull not found in context")
274
+
http.Error(w, "pull not found", http.StatusNotFound)
275
+
return
276
+
}
277
+
278
+
// Get comment count from database
279
+
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
+
if err != nil {
281
+
log.Printf("failed to get pull comments: %v", err)
282
+
}
283
+
commentCount := len(comments)
284
+
285
+
// Calculate diff stats from latest submission using patchutil
286
+
var diffStats types.DiffStat
287
+
filesChanged := 0
288
+
if len(pull.Submissions) > 0 {
289
+
latestSubmission := pull.Submissions[len(pull.Submissions)-1]
290
+
niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
291
+
diffStats.Insertions = int64(niceDiff.Stat.Insertions)
292
+
diffStats.Deletions = int64(niceDiff.Stat.Deletions)
293
+
filesChanged = niceDiff.Stat.FilesChanged
294
+
}
295
+
296
+
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297
+
if err != nil {
298
+
log.Println("failed to draw pull summary card", err)
299
+
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
300
+
return
301
+
}
302
+
303
+
var imageBuffer bytes.Buffer
304
+
err = png.Encode(&imageBuffer, card.Img)
305
+
if err != nil {
306
+
log.Println("failed to encode pull summary card", err)
307
+
http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
308
+
return
309
+
}
310
+
311
+
imageBytes := imageBuffer.Bytes()
312
+
313
+
w.Header().Set("Content-Type", "image/png")
314
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
315
+
w.WriteHeader(http.StatusOK)
316
+
_, err = w.Write(imageBytes)
317
+
if err != nil {
318
+
log.Println("failed to write pull summary card", err)
319
+
return
320
+
}
321
+
}
+139
-101
appview/pulls/pulls.go
+139
-101
appview/pulls/pulls.go
···
17
17
"tangled.org/core/api/tangled"
18
18
"tangled.org/core/appview/config"
19
19
"tangled.org/core/appview/db"
20
+
pulls_indexer "tangled.org/core/appview/indexer/pulls"
20
21
"tangled.org/core/appview/models"
21
22
"tangled.org/core/appview/notify"
22
23
"tangled.org/core/appview/oauth"
23
24
"tangled.org/core/appview/pages"
24
25
"tangled.org/core/appview/pages/markup"
25
26
"tangled.org/core/appview/reporesolver"
27
+
"tangled.org/core/appview/validator"
26
28
"tangled.org/core/appview/xrpcclient"
27
29
"tangled.org/core/idresolver"
28
30
"tangled.org/core/patchutil"
···
31
33
"tangled.org/core/types"
32
34
33
35
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
+
"github.com/bluesky-social/indigo/atproto/syntax"
34
37
lexutil "github.com/bluesky-social/indigo/lex/util"
35
38
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
36
39
"github.com/go-chi/chi/v5"
···
47
50
notifier notify.Notifier
48
51
enforcer *rbac.Enforcer
49
52
logger *slog.Logger
53
+
validator *validator.Validator
54
+
indexer *pulls_indexer.Indexer
50
55
}
51
56
52
57
func New(
···
58
63
config *config.Config,
59
64
notifier notify.Notifier,
60
65
enforcer *rbac.Enforcer,
66
+
validator *validator.Validator,
67
+
indexer *pulls_indexer.Indexer,
61
68
logger *slog.Logger,
62
69
) *Pulls {
63
70
return &Pulls{
···
70
77
notifier: notifier,
71
78
enforcer: enforcer,
72
79
logger: logger,
80
+
validator: validator,
81
+
indexer: indexer,
73
82
}
74
83
}
75
84
···
145
154
stack, _ := r.Context().Value("stack").(models.Stack)
146
155
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
147
156
148
-
totalIdents := 1
149
-
for _, submission := range pull.Submissions {
150
-
totalIdents += len(submission.Comments)
151
-
}
152
-
153
-
identsToResolve := make([]string, totalIdents)
154
-
155
-
// populate idents
156
-
identsToResolve[0] = pull.OwnerDid
157
-
idx := 1
158
-
for _, submission := range pull.Submissions {
159
-
for _, comment := range submission.Comments {
160
-
identsToResolve[idx] = comment.OwnerDid
161
-
idx += 1
162
-
}
163
-
}
164
-
165
157
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
166
158
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
167
159
resubmitResult := pages.Unknown
···
200
192
m[p.Sha] = p
201
193
}
202
194
203
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
195
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
204
196
if err != nil {
205
197
log.Println("failed to get pull reactions")
206
198
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
208
200
209
201
userReactions := map[models.ReactionKind]bool{}
210
202
if user != nil {
211
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
203
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
212
204
}
213
205
214
206
labelDefs, err := db.GetLabelDefinitions(
···
459
451
return
460
452
}
461
453
462
-
patch := pull.Submissions[roundIdInt].Patch
454
+
patch := pull.Submissions[roundIdInt].CombinedPatch()
463
455
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
464
456
465
457
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
···
510
502
return
511
503
}
512
504
513
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
505
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
514
506
if err != nil {
515
507
log.Println("failed to interdiff; current patch malformed")
516
508
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
517
509
return
518
510
}
519
511
520
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
512
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
521
513
if err != nil {
522
514
log.Println("failed to interdiff; previous patch malformed")
523
515
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
···
557
549
}
558
550
559
551
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
552
+
l := s.logger.With("handler", "RepoPulls")
553
+
560
554
user := s.oauth.GetUser(r)
561
555
params := r.URL.Query()
562
556
···
574
568
return
575
569
}
576
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
+
577
598
pulls, err := db.GetPulls(
578
599
s.db,
579
-
db.FilterEq("repo_at", f.RepoAt()),
580
-
db.FilterEq("state", state),
600
+
db.FilterIn("id", ids),
581
601
)
582
602
if err != nil {
583
603
log.Println("failed to get pulls", err)
···
664
684
Pulls: pulls,
665
685
LabelDefs: defs,
666
686
FilteringBy: state,
687
+
FilterQuery: keyword,
667
688
Stacks: stacks,
668
689
Pipelines: m,
669
690
})
670
691
}
671
692
672
693
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
694
+
l := s.logger.With("handler", "PullComment")
673
695
user := s.oauth.GetUser(r)
674
696
f, err := s.repoResolver.Resolve(r)
675
697
if err != nil {
···
719
741
720
742
createdAt := time.Now().Format(time.RFC3339)
721
743
722
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
723
-
if err != nil {
724
-
log.Println("failed to get pull at", err)
725
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
726
-
return
727
-
}
728
-
729
744
client, err := s.oauth.AuthorizedClient(r)
730
745
if err != nil {
731
746
log.Println("failed to get authorized client", err)
···
738
753
Rkey: tid.TID(),
739
754
Record: &lexutil.LexiconTypeDecoder{
740
755
Val: &tangled.RepoPullComment{
741
-
Pull: string(pullAt),
756
+
Pull: pull.AtUri().String(),
742
757
Body: body,
743
758
CreatedAt: createdAt,
744
759
},
···
774
789
return
775
790
}
776
791
777
-
s.notifier.NewPullComment(r.Context(), comment)
792
+
rawMentions := markup.FindUserMentions(comment.Body)
793
+
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
794
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
795
+
var mentions []syntax.DID
796
+
for _, ident := range idents {
797
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
798
+
mentions = append(mentions, ident.DID)
799
+
}
800
+
}
801
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
778
802
779
803
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
780
804
return
···
986
1010
}
987
1011
988
1012
sourceRev := comparison.Rev2
989
-
patch := comparison.Patch
1013
+
patch := comparison.FormatPatchRaw
1014
+
combined := comparison.CombinedPatchRaw
990
1015
991
-
if !patchutil.IsPatchValid(patch) {
1016
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1017
+
s.logger.Error("failed to validate patch", "err", err)
992
1018
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
993
1019
return
994
1020
}
···
1001
1027
Sha: comparison.Rev2,
1002
1028
}
1003
1029
1004
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1030
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1005
1031
}
1006
1032
1007
1033
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1008
-
if !patchutil.IsPatchValid(patch) {
1034
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1035
+
s.logger.Error("patch validation failed", "err", err)
1009
1036
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1010
1037
return
1011
1038
}
1012
1039
1013
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
1040
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1014
1041
}
1015
1042
1016
1043
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
···
1093
1120
}
1094
1121
1095
1122
sourceRev := comparison.Rev2
1096
-
patch := comparison.Patch
1123
+
patch := comparison.FormatPatchRaw
1124
+
combined := comparison.CombinedPatchRaw
1097
1125
1098
-
if !patchutil.IsPatchValid(patch) {
1126
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1127
+
s.logger.Error("failed to validate patch", "err", err)
1099
1128
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1100
1129
return
1101
1130
}
···
1113
1142
Sha: sourceRev,
1114
1143
}
1115
1144
1116
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1145
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1117
1146
}
1118
1147
1119
1148
func (s *Pulls) createPullRequest(
···
1123
1152
user *oauth.User,
1124
1153
title, body, targetBranch string,
1125
1154
patch string,
1155
+
combined string,
1126
1156
sourceRev string,
1127
1157
pullSource *models.PullSource,
1128
1158
recordPullSource *tangled.RepoPull_Source,
···
1182
1212
rkey := tid.TID()
1183
1213
initialSubmission := models.PullSubmission{
1184
1214
Patch: patch,
1215
+
Combined: combined,
1185
1216
SourceRev: sourceRev,
1186
1217
}
1187
1218
pull := &models.Pull{
···
1357
1388
return
1358
1389
}
1359
1390
1360
-
if patch == "" || !patchutil.IsPatchValid(patch) {
1391
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1392
+
s.logger.Error("faield to validate patch", "err", err)
1361
1393
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1362
1394
return
1363
1395
}
···
1611
1643
1612
1644
patch := r.FormValue("patch")
1613
1645
1614
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1646
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1615
1647
}
1616
1648
1617
1649
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
···
1672
1704
}
1673
1705
1674
1706
sourceRev := comparison.Rev2
1675
-
patch := comparison.Patch
1707
+
patch := comparison.FormatPatchRaw
1708
+
combined := comparison.CombinedPatchRaw
1676
1709
1677
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1710
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1678
1711
}
1679
1712
1680
1713
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
···
1706
1739
return
1707
1740
}
1708
1741
1709
-
// extract patch by performing compare
1710
-
forkScheme := "http"
1711
-
if !s.config.Core.Dev {
1712
-
forkScheme = "https"
1713
-
}
1714
-
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1715
-
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1716
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1717
-
if err != nil {
1718
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1719
-
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1720
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1721
-
return
1722
-
}
1723
-
log.Printf("failed to compare branches: %s", err)
1724
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1725
-
return
1726
-
}
1727
-
1728
-
var forkComparison types.RepoFormatPatchResponse
1729
-
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1730
-
log.Println("failed to decode XRPC compare response for fork", err)
1731
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1732
-
return
1733
-
}
1734
-
1735
1742
// update the hidden tracking branch to latest
1736
1743
client, err := s.oauth.ServiceClient(
1737
1744
r,
···
1763
1770
return
1764
1771
}
1765
1772
1766
-
// Use the fork comparison we already made
1767
-
comparison := forkComparison
1768
-
1769
-
sourceRev := comparison.Rev2
1770
-
patch := comparison.Patch
1771
-
1772
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1773
-
}
1774
-
1775
-
// validate a resubmission against a pull request
1776
-
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1777
-
if patch == "" {
1778
-
return fmt.Errorf("Patch is empty.")
1773
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1774
+
// extract patch by performing compare
1775
+
forkScheme := "http"
1776
+
if !s.config.Core.Dev {
1777
+
forkScheme = "https"
1779
1778
}
1780
-
1781
-
if patch == pull.LatestPatch() {
1782
-
return fmt.Errorf("Patch is identical to previous submission.")
1779
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1780
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1781
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1782
+
if err != nil {
1783
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1784
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1785
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1786
+
return
1787
+
}
1788
+
log.Printf("failed to compare branches: %s", err)
1789
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1790
+
return
1783
1791
}
1784
1792
1785
-
if !patchutil.IsPatchValid(patch) {
1786
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1793
+
var forkComparison types.RepoFormatPatchResponse
1794
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1795
+
log.Println("failed to decode XRPC compare response for fork", err)
1796
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1797
+
return
1787
1798
}
1788
1799
1789
-
return nil
1800
+
// Use the fork comparison we already made
1801
+
comparison := forkComparison
1802
+
1803
+
sourceRev := comparison.Rev2
1804
+
patch := comparison.FormatPatchRaw
1805
+
combined := comparison.CombinedPatchRaw
1806
+
1807
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1790
1808
}
1791
1809
1792
1810
func (s *Pulls) resubmitPullHelper(
···
1796
1814
user *oauth.User,
1797
1815
pull *models.Pull,
1798
1816
patch string,
1817
+
combined string,
1799
1818
sourceRev string,
1800
1819
) {
1801
1820
if pull.IsStacked() {
···
1804
1823
return
1805
1824
}
1806
1825
1807
-
if err := validateResubmittedPatch(pull, patch); err != nil {
1826
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1808
1827
s.pages.Notice(w, "resubmit-error", err.Error())
1809
1828
return
1810
1829
}
1811
1830
1831
+
if patch == pull.LatestPatch() {
1832
+
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1833
+
return
1834
+
}
1835
+
1812
1836
// validate sourceRev if branch/fork based
1813
1837
if pull.IsBranchBased() || pull.IsForkBased() {
1814
1838
if sourceRev == pull.LatestSha() {
···
1825
1849
}
1826
1850
defer tx.Rollback()
1827
1851
1828
-
pull.Submissions = append(pull.Submissions, &models.PullSubmission{
1829
-
Patch: patch,
1830
-
SourceRev: sourceRev,
1831
-
})
1832
-
err = db.ResubmitPull(tx, pull)
1852
+
pullAt := pull.AtUri()
1853
+
newRoundNumber := len(pull.Submissions)
1854
+
newPatch := patch
1855
+
newSourceRev := sourceRev
1856
+
combinedPatch := combined
1857
+
err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1833
1858
if err != nil {
1834
1859
log.Println("failed to create pull request", err)
1835
1860
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
2020
2045
continue
2021
2046
}
2022
2047
2023
-
// resubmit the old pull
2024
-
err := db.ResubmitPull(tx, np)
2025
-
2048
+
// resubmit the new pull
2049
+
pullAt := op.AtUri()
2050
+
newRoundNumber := len(op.Submissions)
2051
+
newPatch := np.LatestPatch()
2052
+
combinedPatch := np.LatestSubmission().Combined
2053
+
newSourceRev := np.LatestSha()
2054
+
err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
2026
2055
if err != nil {
2027
2056
log.Println("failed to update pull", err, op.PullId)
2028
2057
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
···
2088
2117
}
2089
2118
2090
2119
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2120
+
user := s.oauth.GetUser(r)
2091
2121
f, err := s.repoResolver.Resolve(r)
2092
2122
if err != nil {
2093
2123
log.Println("failed to resolve repo:", err)
···
2185
2215
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2186
2216
return
2187
2217
}
2218
+
p.State = models.PullMerged
2188
2219
}
2189
2220
2190
2221
err = tx.Commit()
···
2197
2228
2198
2229
// notify about the pull merge
2199
2230
for _, p := range pullsToMerge {
2200
-
s.notifier.NewPullMerged(r.Context(), p)
2231
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2201
2232
}
2202
2233
2203
2234
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2258
2289
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2259
2290
return
2260
2291
}
2292
+
p.State = models.PullClosed
2261
2293
}
2262
2294
2263
2295
// Commit the transaction
···
2268
2300
}
2269
2301
2270
2302
for _, p := range pullsToClose {
2271
-
s.notifier.NewPullClosed(r.Context(), p)
2303
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2272
2304
}
2273
2305
2274
2306
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2330
2362
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2331
2363
return
2332
2364
}
2365
+
p.State = models.PullOpen
2333
2366
}
2334
2367
2335
2368
// Commit the transaction
···
2337
2370
log.Println("failed to commit transaction", err)
2338
2371
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2339
2372
return
2373
+
}
2374
+
2375
+
for _, p := range pullsToReopen {
2376
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2340
2377
}
2341
2378
2342
2379
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2370
2407
initialSubmission := models.PullSubmission{
2371
2408
Patch: fp.Raw,
2372
2409
SourceRev: fp.SHA,
2410
+
Combined: fp.Raw,
2373
2411
}
2374
2412
pull := models.Pull{
2375
2413
Title: title,
+1
appview/pulls/router.go
+1
appview/pulls/router.go
+49
appview/repo/archive.go
+49
appview/repo/archive.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
9
+
"tangled.org/core/api/tangled"
10
+
xrpcclient "tangled.org/core/appview/xrpcclient"
11
+
12
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/go-chi/chi/v5"
14
+
"github.com/go-git/go-git/v5/plumbing"
15
+
)
16
+
17
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "DownloadArchive")
19
+
ref := chi.URLParam(r, "ref")
20
+
ref, _ = url.PathUnescape(ref)
21
+
f, err := rp.repoResolver.Resolve(r)
22
+
if err != nil {
23
+
l.Error("failed to get repo and knot", "err", err)
24
+
return
25
+
}
26
+
scheme := "http"
27
+
if !rp.config.Core.Dev {
28
+
scheme = "https"
29
+
}
30
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
31
+
xrpcc := &indigoxrpc.Client{
32
+
Host: host,
33
+
}
34
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
35
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
36
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
+
rp.pages.Error503(w)
39
+
return
40
+
}
41
+
// Set headers for file download, just pass along whatever the knot specifies
42
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
43
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
44
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
45
+
w.Header().Set("Content-Type", "application/gzip")
46
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
47
+
// Write the archive data directly
48
+
w.Write(archiveBytes)
49
+
}
+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
···
146
146
return fmt.Sprintf("%s in %s", base, repoName)
147
147
}
148
148
149
-
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
149
+
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
150
150
f, err := rp.repoResolver.Resolve(r)
151
151
if err != nil {
152
152
log.Println("failed to fully resolve repo:", err)
+10
-11
appview/repo/index.go
+10
-11
appview/repo/index.go
···
30
30
"github.com/go-enry/go-enry/v2"
31
31
)
32
32
33
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
34
l := rp.logger.With("handler", "RepoIndex")
35
35
36
36
ref := chi.URLParam(r, "ref")
···
154
154
CommitsTrunc: commitsTrunc,
155
155
TagsTrunc: tagsTrunc,
156
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,
157
+
BranchesTrunc: branchesTrunc,
158
+
EmailToDid: emailToDidMap,
159
+
VerifiedCommits: vc,
160
+
Languages: languageInfo,
161
+
Pipelines: pipelines,
162
162
})
163
163
}
164
164
···
351
351
if treeResp != nil && treeResp.Files != nil {
352
352
for _, file := range treeResp.Files {
353
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,
354
+
Name: file.Name,
355
+
Mode: file.Mode,
356
+
Size: file.Size,
359
357
}
358
+
360
359
if file.Last_commit != nil {
361
360
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
362
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
+
}
-500
appview/repo/ogcard/card.go
-500
appview/repo/ogcard/card.go
···
1
-
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
-
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
-
// SPDX-License-Identifier: MIT
4
-
5
-
package ogcard
6
-
7
-
import (
8
-
"bytes"
9
-
"fmt"
10
-
"image"
11
-
"image/color"
12
-
"io"
13
-
"log"
14
-
"math"
15
-
"net/http"
16
-
"strings"
17
-
"sync"
18
-
"time"
19
-
20
-
"github.com/goki/freetype"
21
-
"github.com/goki/freetype/truetype"
22
-
"github.com/srwiley/oksvg"
23
-
"github.com/srwiley/rasterx"
24
-
"golang.org/x/image/draw"
25
-
"golang.org/x/image/font"
26
-
"tangled.org/core/appview/pages"
27
-
28
-
_ "golang.org/x/image/webp" // for processing webp images
29
-
)
30
-
31
-
type Card struct {
32
-
Img *image.RGBA
33
-
Font *truetype.Font
34
-
Margin int
35
-
Width int
36
-
Height int
37
-
}
38
-
39
-
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
-
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
-
if err != nil {
42
-
return nil, err
43
-
}
44
-
return truetype.Parse(interVar)
45
-
})
46
-
47
-
// DefaultSize returns the default size for a card
48
-
func DefaultSize() (int, int) {
49
-
return 1200, 630
50
-
}
51
-
52
-
// NewCard creates a new card with the given dimensions in pixels
53
-
func NewCard(width, height int) (*Card, error) {
54
-
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
-
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
-
57
-
font, err := fontCache()
58
-
if err != nil {
59
-
return nil, err
60
-
}
61
-
62
-
return &Card{
63
-
Img: img,
64
-
Font: font,
65
-
Margin: 0,
66
-
Width: width,
67
-
Height: height,
68
-
}, nil
69
-
}
70
-
71
-
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
-
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
-
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
-
bounds := c.Img.Bounds()
75
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
-
if vertical {
77
-
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
-
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
-
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
-
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
-
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
-
}
83
-
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
-
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
-
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
-
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
-
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
-
}
89
-
90
-
// SetMargin sets the margins for the card
91
-
func (c *Card) SetMargin(margin int) {
92
-
c.Margin = margin
93
-
}
94
-
95
-
type (
96
-
VAlign int64
97
-
HAlign int64
98
-
)
99
-
100
-
const (
101
-
Top VAlign = iota
102
-
Middle
103
-
Bottom
104
-
)
105
-
106
-
const (
107
-
Left HAlign = iota
108
-
Center
109
-
Right
110
-
)
111
-
112
-
// DrawText draws text within the card, respecting margins and alignment
113
-
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
-
ft := freetype.NewContext()
115
-
ft.SetDPI(72)
116
-
ft.SetFont(c.Font)
117
-
ft.SetFontSize(sizePt)
118
-
ft.SetClip(c.Img.Bounds())
119
-
ft.SetDst(c.Img)
120
-
ft.SetSrc(image.NewUniform(textColor))
121
-
122
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
-
125
-
bounds := c.Img.Bounds()
126
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
-
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
-
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
-
130
-
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
-
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
-
// knowing the total height, which is related to how many lines we'll have.
133
-
lines := make([]string, 0)
134
-
textWords := strings.Split(text, " ")
135
-
currentLine := ""
136
-
heightTotal := 0
137
-
138
-
for {
139
-
if len(textWords) == 0 {
140
-
// Ran out of words.
141
-
if currentLine != "" {
142
-
heightTotal += fontHeight
143
-
lines = append(lines, currentLine)
144
-
}
145
-
break
146
-
}
147
-
148
-
nextWord := textWords[0]
149
-
proposedLine := currentLine
150
-
if proposedLine != "" {
151
-
proposedLine += " "
152
-
}
153
-
proposedLine += nextWord
154
-
155
-
proposedLineWidth := font.MeasureString(face, proposedLine)
156
-
if proposedLineWidth.Ceil() > boxWidth {
157
-
// no, proposed line is too big; we'll use the last "currentLine"
158
-
heightTotal += fontHeight
159
-
if currentLine != "" {
160
-
lines = append(lines, currentLine)
161
-
currentLine = ""
162
-
// leave nextWord in textWords and keep going
163
-
} else {
164
-
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
-
// regardless as a line by itself. It will be clipped by the drawing routine.
166
-
lines = append(lines, nextWord)
167
-
textWords = textWords[1:]
168
-
}
169
-
} else {
170
-
// yes, it will fit
171
-
currentLine = proposedLine
172
-
textWords = textWords[1:]
173
-
}
174
-
}
175
-
176
-
textY := 0
177
-
switch valign {
178
-
case Top:
179
-
textY = fontHeight
180
-
case Bottom:
181
-
textY = boxHeight - heightTotal + fontHeight
182
-
case Middle:
183
-
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
-
}
185
-
186
-
for _, line := range lines {
187
-
lineWidth := font.MeasureString(face, line)
188
-
189
-
textX := 0
190
-
switch halign {
191
-
case Left:
192
-
textX = 0
193
-
case Right:
194
-
textX = boxWidth - lineWidth.Ceil()
195
-
case Center:
196
-
textX = (boxWidth - lineWidth.Ceil()) / 2
197
-
}
198
-
199
-
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
-
_, err := ft.DrawString(line, pt)
201
-
if err != nil {
202
-
return nil, err
203
-
}
204
-
205
-
textY += fontHeight
206
-
}
207
-
208
-
return lines, nil
209
-
}
210
-
211
-
// DrawTextAt draws text at a specific position with the given alignment
212
-
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
-
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
-
return err
215
-
}
216
-
217
-
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
-
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
-
ft := freetype.NewContext()
220
-
ft.SetDPI(72)
221
-
ft.SetFont(c.Font)
222
-
ft.SetFontSize(sizePt)
223
-
ft.SetClip(c.Img.Bounds())
224
-
ft.SetDst(c.Img)
225
-
ft.SetSrc(image.NewUniform(textColor))
226
-
227
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
-
lineWidth := font.MeasureString(face, text)
230
-
textWidth := lineWidth.Ceil()
231
-
232
-
// Adjust position based on alignment
233
-
adjustedX := x
234
-
adjustedY := y
235
-
236
-
switch halign {
237
-
case Left:
238
-
// x is already at the left position
239
-
case Right:
240
-
adjustedX = x - textWidth
241
-
case Center:
242
-
adjustedX = x - textWidth/2
243
-
}
244
-
245
-
switch valign {
246
-
case Top:
247
-
adjustedY = y + fontHeight
248
-
case Bottom:
249
-
adjustedY = y
250
-
case Middle:
251
-
adjustedY = y + fontHeight/2
252
-
}
253
-
254
-
pt := freetype.Pt(adjustedX, adjustedY)
255
-
_, err := ft.DrawString(text, pt)
256
-
return textWidth, err
257
-
}
258
-
259
-
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
-
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
-
// Draw the text multiple times with slight offsets to create bold effect
262
-
offsets := []struct{ dx, dy int }{
263
-
{0, 0}, // original
264
-
{1, 0}, // right
265
-
{0, 1}, // down
266
-
{1, 1}, // diagonal
267
-
}
268
-
269
-
var width int
270
-
for _, offset := range offsets {
271
-
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
-
if err != nil {
273
-
return 0, err
274
-
}
275
-
if width == 0 {
276
-
width = w
277
-
}
278
-
}
279
-
return width, nil
280
-
}
281
-
282
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
-
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
-
svgData, err := pages.Files.ReadFile(svgPath)
285
-
if err != nil {
286
-
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
-
}
288
-
289
-
// Convert color to hex string for SVG
290
-
rgba, isRGBA := iconColor.(color.RGBA)
291
-
if !isRGBA {
292
-
r, g, b, a := iconColor.RGBA()
293
-
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
-
}
295
-
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
-
297
-
// Replace currentColor with our desired color in the SVG
298
-
svgString := string(svgData)
299
-
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
-
301
-
// Make the stroke thicker
302
-
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
-
304
-
// Parse SVG
305
-
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
-
if err != nil {
307
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
-
}
309
-
310
-
// Set the icon size
311
-
w, h := float64(size), float64(size)
312
-
icon.SetTarget(0, 0, w, h)
313
-
314
-
// Create a temporary RGBA image for the icon
315
-
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
-
317
-
// Create scanner and rasterizer
318
-
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
-
raster := rasterx.NewDasher(size, size, scanner)
320
-
321
-
// Draw the icon
322
-
icon.Draw(raster, 1.0)
323
-
324
-
// Draw the icon onto the card at the specified position
325
-
bounds := c.Img.Bounds()
326
-
destRect := image.Rect(x, y, x+size, y+size)
327
-
328
-
// Make sure we don't draw outside the card bounds
329
-
if destRect.Max.X > bounds.Max.X {
330
-
destRect.Max.X = bounds.Max.X
331
-
}
332
-
if destRect.Max.Y > bounds.Max.Y {
333
-
destRect.Max.Y = bounds.Max.Y
334
-
}
335
-
336
-
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
-
338
-
return nil
339
-
}
340
-
341
-
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
-
func (c *Card) DrawImage(img image.Image) {
343
-
bounds := c.Img.Bounds()
344
-
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
-
srcBounds := img.Bounds()
346
-
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
-
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
-
349
-
var scale float64
350
-
if srcAspect > targetAspect {
351
-
// Image is wider than target, scale by width
352
-
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
-
} else {
354
-
// Image is taller or equal, scale by height
355
-
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
-
}
357
-
358
-
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
-
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
-
361
-
// Center the image within the target rectangle
362
-
offsetX := (targetRect.Dx() - newWidth) / 2
363
-
offsetY := (targetRect.Dy() - newHeight) / 2
364
-
365
-
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
-
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
-
}
368
-
369
-
func fallbackImage() image.Image {
370
-
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
-
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
-
img.Set(0, 0, color.White)
373
-
return img
374
-
}
375
-
376
-
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
-
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
-
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
-
// this rendering process to be slowed down
380
-
client := &http.Client{
381
-
Timeout: 1 * time.Second, // 1 second timeout
382
-
}
383
-
384
-
resp, err := client.Get(url)
385
-
if err != nil {
386
-
log.Printf("error when fetching external image from %s: %v", url, err)
387
-
return nil, false
388
-
}
389
-
defer resp.Body.Close()
390
-
391
-
if resp.StatusCode != http.StatusOK {
392
-
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
-
return nil, false
394
-
}
395
-
396
-
contentType := resp.Header.Get("Content-Type")
397
-
// Support content types are in-sync with the allowed custom avatar file types
398
-
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
399
-
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
400
-
return nil, false
401
-
}
402
-
403
-
body := resp.Body
404
-
bodyBytes, err := io.ReadAll(body)
405
-
if err != nil {
406
-
log.Printf("error when fetching external image from %s: %v", url, err)
407
-
return nil, false
408
-
}
409
-
410
-
bodyBuffer := bytes.NewReader(bodyBytes)
411
-
_, imgType, err := image.DecodeConfig(bodyBuffer)
412
-
if err != nil {
413
-
log.Printf("error when decoding external image from %s: %v", url, err)
414
-
return nil, false
415
-
}
416
-
417
-
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
418
-
if (contentType == "image/png" && imgType != "png") ||
419
-
(contentType == "image/jpeg" && imgType != "jpeg") ||
420
-
(contentType == "image/gif" && imgType != "gif") ||
421
-
(contentType == "image/webp" && imgType != "webp") {
422
-
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
423
-
return nil, false
424
-
}
425
-
426
-
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
427
-
if err != nil {
428
-
log.Printf("error w/ bodyBuffer.Seek")
429
-
return nil, false
430
-
}
431
-
img, _, err := image.Decode(bodyBuffer)
432
-
if err != nil {
433
-
log.Printf("error when decoding external image from %s: %v", url, err)
434
-
return nil, false
435
-
}
436
-
437
-
return img, true
438
-
}
439
-
440
-
func (c *Card) DrawExternalImage(url string) {
441
-
image, ok := c.fetchExternalImage(url)
442
-
if !ok {
443
-
image = fallbackImage()
444
-
}
445
-
c.DrawImage(image)
446
-
}
447
-
448
-
// DrawCircularExternalImage draws an external image as a circle at the specified position
449
-
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
450
-
img, ok := c.fetchExternalImage(url)
451
-
if !ok {
452
-
img = fallbackImage()
453
-
}
454
-
455
-
// Create a circular mask
456
-
circle := image.NewRGBA(image.Rect(0, 0, size, size))
457
-
center := size / 2
458
-
radius := float64(size / 2)
459
-
460
-
// Scale the source image to fit the circle
461
-
srcBounds := img.Bounds()
462
-
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
463
-
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
464
-
465
-
// Draw the image with circular clipping
466
-
for cy := 0; cy < size; cy++ {
467
-
for cx := 0; cx < size; cx++ {
468
-
// Calculate distance from center
469
-
dx := float64(cx - center)
470
-
dy := float64(cy - center)
471
-
distance := math.Sqrt(dx*dx + dy*dy)
472
-
473
-
// Only draw pixels within the circle
474
-
if distance <= radius {
475
-
circle.Set(cx, cy, scaledImg.At(cx, cy))
476
-
}
477
-
}
478
-
}
479
-
480
-
// Draw the circle onto the card
481
-
bounds := c.Img.Bounds()
482
-
destRect := image.Rect(x, y, x+size, y+size)
483
-
484
-
// Make sure we don't draw outside the card bounds
485
-
if destRect.Max.X > bounds.Max.X {
486
-
destRect.Max.X = bounds.Max.X
487
-
}
488
-
if destRect.Max.Y > bounds.Max.Y {
489
-
destRect.Max.Y = bounds.Max.Y
490
-
}
491
-
492
-
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
493
-
494
-
return nil
495
-
}
496
-
497
-
// DrawRect draws a rect with the given color
498
-
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
499
-
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
500
-
}
+6
-6
appview/repo/opengraph.go
+6
-6
appview/repo/opengraph.go
···
15
15
"github.com/go-enry/go-enry/v2"
16
16
"tangled.org/core/appview/db"
17
17
"tangled.org/core/appview/models"
18
-
"tangled.org/core/appview/repo/ogcard"
18
+
"tangled.org/core/appview/ogcard"
19
19
"tangled.org/core/types"
20
20
)
21
21
···
158
158
// Draw star icon, count, and label
159
159
// Align icon baseline with text baseline
160
160
iconBaselineOffset := int(textSize) / 2
161
-
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
161
+
err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
162
if err != nil {
163
163
log.Printf("failed to draw star icon: %v", err)
164
164
}
···
185
185
186
186
// Draw issues icon, count, and label
187
187
issueStartX := currentX
188
-
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
188
+
err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
189
if err != nil {
190
190
log.Printf("failed to draw circle-dot icon: %v", err)
191
191
}
···
210
210
211
211
// Draw pull request icon, count, and label
212
212
prStartX := currentX
213
-
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
213
+
err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
214
if err != nil {
215
215
log.Printf("failed to draw git-pull-request icon: %v", err)
216
216
}
···
236
236
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
237
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
238
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
239
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
240
240
if err != nil {
241
241
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
242
}
···
327
327
return nil
328
328
}
329
329
330
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
330
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
331
f, err := rp.repoResolver.Resolve(r)
332
332
if err != nil {
333
333
log.Println("failed to get repo and knot", err)
+2
-1369
appview/repo/repo.go
+2
-1369
appview/repo/repo.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"encoding/json"
7
6
"errors"
8
7
"fmt"
9
-
"io"
10
8
"log/slog"
11
9
"net/http"
12
10
"net/url"
13
-
"path/filepath"
14
11
"slices"
15
-
"strconv"
16
12
"strings"
17
13
"time"
18
14
19
15
"tangled.org/core/api/tangled"
20
-
"tangled.org/core/appview/commitverify"
21
16
"tangled.org/core/appview/config"
22
17
"tangled.org/core/appview/db"
23
18
"tangled.org/core/appview/models"
24
19
"tangled.org/core/appview/notify"
25
20
"tangled.org/core/appview/oauth"
26
21
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
22
"tangled.org/core/appview/reporesolver"
29
23
"tangled.org/core/appview/validator"
30
24
xrpcclient "tangled.org/core/appview/xrpcclient"
31
25
"tangled.org/core/eventconsumer"
32
26
"tangled.org/core/idresolver"
33
-
"tangled.org/core/patchutil"
34
27
"tangled.org/core/rbac"
35
28
"tangled.org/core/tid"
36
-
"tangled.org/core/types"
37
29
"tangled.org/core/xrpc/serviceauth"
38
30
39
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
32
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
33
"github.com/bluesky-social/indigo/atproto/syntax"
42
34
lexutil "github.com/bluesky-social/indigo/lex/util"
43
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44
35
securejoin "github.com/cyphar/filepath-securejoin"
45
36
"github.com/go-chi/chi/v5"
46
-
"github.com/go-git/go-git/v5/plumbing"
47
37
)
48
38
49
39
type Repo struct {
···
88
78
}
89
79
}
90
80
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
-
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
196
-
}
197
-
}
198
-
}
199
-
200
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
201
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
202
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
203
-
rp.pages.Error503(w)
204
-
return
205
-
}
206
-
207
-
if branchBytes != nil {
208
-
var branchResp types.RepoBranchesResponse
209
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
210
-
for _, branch := range branchResp.Branches {
211
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
212
-
}
213
-
}
214
-
}
215
-
216
-
user := rp.oauth.GetUser(r)
217
-
218
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
219
-
if err != nil {
220
-
l.Error("failed to fetch email to did mapping", "err", err)
221
-
}
222
-
223
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
224
-
if err != nil {
225
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
226
-
}
227
-
228
-
repoInfo := f.RepoInfo(user)
229
-
230
-
var shas []string
231
-
for _, c := range xrpcResp.Commits {
232
-
shas = append(shas, c.Hash.String())
233
-
}
234
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
235
-
if err != nil {
236
-
l.Error("failed to getPipelineStatuses", "err", err)
237
-
// non-fatal
238
-
}
239
-
240
-
rp.pages.RepoLog(w, pages.RepoLogParams{
241
-
LoggedInUser: user,
242
-
TagMap: tagMap,
243
-
RepoInfo: repoInfo,
244
-
RepoLogResponse: xrpcResp,
245
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
246
-
VerifiedCommits: vc,
247
-
Pipelines: pipelines,
248
-
})
249
-
}
250
-
251
-
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
252
-
l := rp.logger.With("handler", "RepoDescriptionEdit")
253
-
254
-
f, err := rp.repoResolver.Resolve(r)
255
-
if err != nil {
256
-
l.Error("failed to get repo and knot", "err", err)
257
-
w.WriteHeader(http.StatusBadRequest)
258
-
return
259
-
}
260
-
261
-
user := rp.oauth.GetUser(r)
262
-
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
263
-
RepoInfo: f.RepoInfo(user),
264
-
})
265
-
}
266
-
267
-
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
268
-
l := rp.logger.With("handler", "RepoDescription")
269
-
270
-
f, err := rp.repoResolver.Resolve(r)
271
-
if err != nil {
272
-
l.Error("failed to get repo and knot", "err", err)
273
-
w.WriteHeader(http.StatusBadRequest)
274
-
return
275
-
}
276
-
277
-
repoAt := f.RepoAt()
278
-
rkey := repoAt.RecordKey().String()
279
-
if rkey == "" {
280
-
l.Error("invalid aturi for repo", "err", err)
281
-
w.WriteHeader(http.StatusInternalServerError)
282
-
return
283
-
}
284
-
285
-
user := rp.oauth.GetUser(r)
286
-
287
-
switch r.Method {
288
-
case http.MethodGet:
289
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
290
-
RepoInfo: f.RepoInfo(user),
291
-
})
292
-
return
293
-
case http.MethodPut:
294
-
newDescription := r.FormValue("description")
295
-
client, err := rp.oauth.AuthorizedClient(r)
296
-
if err != nil {
297
-
l.Error("failed to get client")
298
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
299
-
return
300
-
}
301
-
302
-
// optimistic update
303
-
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
304
-
if err != nil {
305
-
l.Error("failed to perform update-description query", "err", err)
306
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
307
-
return
308
-
}
309
-
310
-
newRepo := f.Repo
311
-
newRepo.Description = newDescription
312
-
record := newRepo.AsRecord()
313
-
314
-
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
315
-
//
316
-
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
317
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
318
-
if err != nil {
319
-
// failed to get record
320
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
321
-
return
322
-
}
323
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
324
-
Collection: tangled.RepoNSID,
325
-
Repo: newRepo.Did,
326
-
Rkey: newRepo.Rkey,
327
-
SwapRecord: ex.Cid,
328
-
Record: &lexutil.LexiconTypeDecoder{
329
-
Val: &record,
330
-
},
331
-
})
332
-
333
-
if err != nil {
334
-
l.Error("failed to perferom update-description query", "err", err)
335
-
// failed to get record
336
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
337
-
return
338
-
}
339
-
340
-
newRepoInfo := f.RepoInfo(user)
341
-
newRepoInfo.Description = newDescription
342
-
343
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
344
-
RepoInfo: newRepoInfo,
345
-
})
346
-
return
347
-
}
348
-
}
349
-
350
-
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
351
-
l := rp.logger.With("handler", "RepoCommit")
352
-
353
-
f, err := rp.repoResolver.Resolve(r)
354
-
if err != nil {
355
-
l.Error("failed to fully resolve repo", "err", err)
356
-
return
357
-
}
358
-
ref := chi.URLParam(r, "ref")
359
-
ref, _ = url.PathUnescape(ref)
360
-
361
-
var diffOpts types.DiffOpts
362
-
if d := r.URL.Query().Get("diff"); d == "split" {
363
-
diffOpts.Split = true
364
-
}
365
-
366
-
if !plumbing.IsHash(ref) {
367
-
rp.pages.Error404(w)
368
-
return
369
-
}
370
-
371
-
scheme := "http"
372
-
if !rp.config.Core.Dev {
373
-
scheme = "https"
374
-
}
375
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
376
-
xrpcc := &indigoxrpc.Client{
377
-
Host: host,
378
-
}
379
-
380
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
381
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
382
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
383
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
384
-
rp.pages.Error503(w)
385
-
return
386
-
}
387
-
388
-
var result types.RepoCommitResponse
389
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
390
-
l.Error("failed to decode XRPC response", "err", err)
391
-
rp.pages.Error503(w)
392
-
return
393
-
}
394
-
395
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
396
-
if err != nil {
397
-
l.Error("failed to get email to did mapping", "err", err)
398
-
}
399
-
400
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
401
-
if err != nil {
402
-
l.Error("failed to GetVerifiedCommits", "err", err)
403
-
}
404
-
405
-
user := rp.oauth.GetUser(r)
406
-
repoInfo := f.RepoInfo(user)
407
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
408
-
if err != nil {
409
-
l.Error("failed to getPipelineStatuses", "err", err)
410
-
// non-fatal
411
-
}
412
-
var pipeline *models.Pipeline
413
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
414
-
pipeline = &p
415
-
}
416
-
417
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
418
-
LoggedInUser: user,
419
-
RepoInfo: f.RepoInfo(user),
420
-
RepoCommitResponse: result,
421
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
422
-
VerifiedCommit: vc,
423
-
Pipeline: pipeline,
424
-
DiffOpts: diffOpts,
425
-
})
426
-
}
427
-
428
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
429
-
l := rp.logger.With("handler", "RepoTree")
430
-
431
-
f, err := rp.repoResolver.Resolve(r)
432
-
if err != nil {
433
-
l.Error("failed to fully resolve repo", "err", err)
434
-
return
435
-
}
436
-
437
-
ref := chi.URLParam(r, "ref")
438
-
ref, _ = url.PathUnescape(ref)
439
-
440
-
// if the tree path has a trailing slash, let's strip it
441
-
// so we don't 404
442
-
treePath := chi.URLParam(r, "*")
443
-
treePath, _ = url.PathUnescape(treePath)
444
-
treePath = strings.TrimSuffix(treePath, "/")
445
-
446
-
scheme := "http"
447
-
if !rp.config.Core.Dev {
448
-
scheme = "https"
449
-
}
450
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
451
-
xrpcc := &indigoxrpc.Client{
452
-
Host: host,
453
-
}
454
-
455
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
456
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
457
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
458
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
459
-
rp.pages.Error503(w)
460
-
return
461
-
}
462
-
463
-
// Convert XRPC response to internal types.RepoTreeResponse
464
-
files := make([]types.NiceTree, len(xrpcResp.Files))
465
-
for i, xrpcFile := range xrpcResp.Files {
466
-
file := types.NiceTree{
467
-
Name: xrpcFile.Name,
468
-
Mode: xrpcFile.Mode,
469
-
Size: int64(xrpcFile.Size),
470
-
IsFile: xrpcFile.Is_file,
471
-
IsSubtree: xrpcFile.Is_subtree,
472
-
}
473
-
474
-
// Convert last commit info if present
475
-
if xrpcFile.Last_commit != nil {
476
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
477
-
file.LastCommit = &types.LastCommitInfo{
478
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
479
-
Message: xrpcFile.Last_commit.Message,
480
-
When: commitWhen,
481
-
}
482
-
}
483
-
484
-
files[i] = file
485
-
}
486
-
487
-
result := types.RepoTreeResponse{
488
-
Ref: xrpcResp.Ref,
489
-
Files: files,
490
-
}
491
-
492
-
if xrpcResp.Parent != nil {
493
-
result.Parent = *xrpcResp.Parent
494
-
}
495
-
if xrpcResp.Dotdot != nil {
496
-
result.DotDot = *xrpcResp.Dotdot
497
-
}
498
-
if xrpcResp.Readme != nil {
499
-
result.ReadmeFileName = xrpcResp.Readme.Filename
500
-
result.Readme = xrpcResp.Readme.Contents
501
-
}
502
-
503
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
504
-
// so we can safely redirect to the "parent" (which is the same file).
505
-
if len(result.Files) == 0 && result.Parent == treePath {
506
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
507
-
http.Redirect(w, r, redirectTo, http.StatusFound)
508
-
return
509
-
}
510
-
511
-
user := rp.oauth.GetUser(r)
512
-
513
-
var breadcrumbs [][]string
514
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
515
-
if treePath != "" {
516
-
for idx, elem := range strings.Split(treePath, "/") {
517
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
518
-
}
519
-
}
520
-
521
-
sortFiles(result.Files)
522
-
523
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
524
-
LoggedInUser: user,
525
-
BreadCrumbs: breadcrumbs,
526
-
TreePath: treePath,
527
-
RepoInfo: f.RepoInfo(user),
528
-
RepoTreeResponse: result,
529
-
})
530
-
}
531
-
532
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
533
-
l := rp.logger.With("handler", "RepoTags")
534
-
535
-
f, err := rp.repoResolver.Resolve(r)
536
-
if err != nil {
537
-
l.Error("failed to get repo and knot", "err", err)
538
-
return
539
-
}
540
-
541
-
scheme := "http"
542
-
if !rp.config.Core.Dev {
543
-
scheme = "https"
544
-
}
545
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
546
-
xrpcc := &indigoxrpc.Client{
547
-
Host: host,
548
-
}
549
-
550
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
551
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
552
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
553
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
554
-
rp.pages.Error503(w)
555
-
return
556
-
}
557
-
558
-
var result types.RepoTagsResponse
559
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
560
-
l.Error("failed to decode XRPC response", "err", err)
561
-
rp.pages.Error503(w)
562
-
return
563
-
}
564
-
565
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
566
-
if err != nil {
567
-
l.Error("failed grab artifacts", "err", err)
568
-
return
569
-
}
570
-
571
-
// convert artifacts to map for easy UI building
572
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
573
-
for _, a := range artifacts {
574
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
575
-
}
576
-
577
-
var danglingArtifacts []models.Artifact
578
-
for _, a := range artifacts {
579
-
found := false
580
-
for _, t := range result.Tags {
581
-
if t.Tag != nil {
582
-
if t.Tag.Hash == a.Tag {
583
-
found = true
584
-
}
585
-
}
586
-
}
587
-
588
-
if !found {
589
-
danglingArtifacts = append(danglingArtifacts, a)
590
-
}
591
-
}
592
-
593
-
user := rp.oauth.GetUser(r)
594
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
595
-
LoggedInUser: user,
596
-
RepoInfo: f.RepoInfo(user),
597
-
RepoTagsResponse: result,
598
-
ArtifactMap: artifactMap,
599
-
DanglingArtifacts: danglingArtifacts,
600
-
})
601
-
}
602
-
603
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
604
-
l := rp.logger.With("handler", "RepoBranches")
605
-
606
-
f, err := rp.repoResolver.Resolve(r)
607
-
if err != nil {
608
-
l.Error("failed to get repo and knot", "err", err)
609
-
return
610
-
}
611
-
612
-
scheme := "http"
613
-
if !rp.config.Core.Dev {
614
-
scheme = "https"
615
-
}
616
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
617
-
xrpcc := &indigoxrpc.Client{
618
-
Host: host,
619
-
}
620
-
621
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
622
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
623
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
624
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
625
-
rp.pages.Error503(w)
626
-
return
627
-
}
628
-
629
-
var result types.RepoBranchesResponse
630
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
631
-
l.Error("failed to decode XRPC response", "err", err)
632
-
rp.pages.Error503(w)
633
-
return
634
-
}
635
-
636
-
sortBranches(result.Branches)
637
-
638
-
user := rp.oauth.GetUser(r)
639
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
640
-
LoggedInUser: user,
641
-
RepoInfo: f.RepoInfo(user),
642
-
RepoBranchesResponse: result,
643
-
})
644
-
}
645
-
646
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
647
-
l := rp.logger.With("handler", "DeleteBranch")
648
-
649
-
f, err := rp.repoResolver.Resolve(r)
650
-
if err != nil {
651
-
l.Error("failed to get repo and knot", "err", err)
652
-
return
653
-
}
654
-
655
-
noticeId := "delete-branch-error"
656
-
fail := func(msg string, err error) {
657
-
l.Error(msg, "err", err)
658
-
rp.pages.Notice(w, noticeId, msg)
659
-
}
660
-
661
-
branch := r.FormValue("branch")
662
-
if branch == "" {
663
-
fail("No branch provided.", nil)
664
-
return
665
-
}
666
-
667
-
client, err := rp.oauth.ServiceClient(
668
-
r,
669
-
oauth.WithService(f.Knot),
670
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
671
-
oauth.WithDev(rp.config.Core.Dev),
672
-
)
673
-
if err != nil {
674
-
fail("Failed to connect to knotserver", nil)
675
-
return
676
-
}
677
-
678
-
err = tangled.RepoDeleteBranch(
679
-
r.Context(),
680
-
client,
681
-
&tangled.RepoDeleteBranch_Input{
682
-
Branch: branch,
683
-
Repo: f.RepoAt().String(),
684
-
},
685
-
)
686
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
687
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
688
-
return
689
-
}
690
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
691
-
692
-
rp.pages.HxRefresh(w)
693
-
}
694
-
695
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
696
-
l := rp.logger.With("handler", "RepoBlob")
697
-
698
-
f, err := rp.repoResolver.Resolve(r)
699
-
if err != nil {
700
-
l.Error("failed to get repo and knot", "err", err)
701
-
return
702
-
}
703
-
704
-
ref := chi.URLParam(r, "ref")
705
-
ref, _ = url.PathUnescape(ref)
706
-
707
-
filePath := chi.URLParam(r, "*")
708
-
filePath, _ = url.PathUnescape(filePath)
709
-
710
-
scheme := "http"
711
-
if !rp.config.Core.Dev {
712
-
scheme = "https"
713
-
}
714
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
715
-
xrpcc := &indigoxrpc.Client{
716
-
Host: host,
717
-
}
718
-
719
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
720
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
721
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
722
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
723
-
rp.pages.Error503(w)
724
-
return
725
-
}
726
-
727
-
// Use XRPC response directly instead of converting to internal types
728
-
729
-
var breadcrumbs [][]string
730
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
731
-
if filePath != "" {
732
-
for idx, elem := range strings.Split(filePath, "/") {
733
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
734
-
}
735
-
}
736
-
737
-
showRendered := false
738
-
renderToggle := false
739
-
740
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
741
-
renderToggle = true
742
-
showRendered = r.URL.Query().Get("code") != "true"
743
-
}
744
-
745
-
var unsupported bool
746
-
var isImage bool
747
-
var isVideo bool
748
-
var contentSrc string
749
-
750
-
if resp.IsBinary != nil && *resp.IsBinary {
751
-
ext := strings.ToLower(filepath.Ext(resp.Path))
752
-
switch ext {
753
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
754
-
isImage = true
755
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
756
-
isVideo = true
757
-
default:
758
-
unsupported = true
759
-
}
760
-
761
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
762
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
763
-
764
-
baseURL := &url.URL{
765
-
Scheme: scheme,
766
-
Host: f.Knot,
767
-
Path: "/xrpc/sh.tangled.repo.blob",
768
-
}
769
-
query := baseURL.Query()
770
-
query.Set("repo", repoName)
771
-
query.Set("ref", ref)
772
-
query.Set("path", filePath)
773
-
query.Set("raw", "true")
774
-
baseURL.RawQuery = query.Encode()
775
-
blobURL := baseURL.String()
776
-
777
-
contentSrc = blobURL
778
-
if !rp.config.Core.Dev {
779
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
780
-
}
781
-
}
782
-
783
-
lines := 0
784
-
if resp.IsBinary == nil || !*resp.IsBinary {
785
-
lines = strings.Count(resp.Content, "\n") + 1
786
-
}
787
-
788
-
var sizeHint uint64
789
-
if resp.Size != nil {
790
-
sizeHint = uint64(*resp.Size)
791
-
} else {
792
-
sizeHint = uint64(len(resp.Content))
793
-
}
794
-
795
-
user := rp.oauth.GetUser(r)
796
-
797
-
// Determine if content is binary (dereference pointer)
798
-
isBinary := false
799
-
if resp.IsBinary != nil {
800
-
isBinary = *resp.IsBinary
801
-
}
802
-
803
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
804
-
LoggedInUser: user,
805
-
RepoInfo: f.RepoInfo(user),
806
-
BreadCrumbs: breadcrumbs,
807
-
ShowRendered: showRendered,
808
-
RenderToggle: renderToggle,
809
-
Unsupported: unsupported,
810
-
IsImage: isImage,
811
-
IsVideo: isVideo,
812
-
ContentSrc: contentSrc,
813
-
RepoBlob_Output: resp,
814
-
Contents: resp.Content,
815
-
Lines: lines,
816
-
SizeHint: sizeHint,
817
-
IsBinary: isBinary,
818
-
})
819
-
}
820
-
821
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
822
-
l := rp.logger.With("handler", "RepoBlobRaw")
823
-
824
-
f, err := rp.repoResolver.Resolve(r)
825
-
if err != nil {
826
-
l.Error("failed to get repo and knot", "err", err)
827
-
w.WriteHeader(http.StatusBadRequest)
828
-
return
829
-
}
830
-
831
-
ref := chi.URLParam(r, "ref")
832
-
ref, _ = url.PathUnescape(ref)
833
-
834
-
filePath := chi.URLParam(r, "*")
835
-
filePath, _ = url.PathUnescape(filePath)
836
-
837
-
scheme := "http"
838
-
if !rp.config.Core.Dev {
839
-
scheme = "https"
840
-
}
841
-
842
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
843
-
baseURL := &url.URL{
844
-
Scheme: scheme,
845
-
Host: f.Knot,
846
-
Path: "/xrpc/sh.tangled.repo.blob",
847
-
}
848
-
query := baseURL.Query()
849
-
query.Set("repo", repo)
850
-
query.Set("ref", ref)
851
-
query.Set("path", filePath)
852
-
query.Set("raw", "true")
853
-
baseURL.RawQuery = query.Encode()
854
-
blobURL := baseURL.String()
855
-
856
-
req, err := http.NewRequest("GET", blobURL, nil)
857
-
if err != nil {
858
-
l.Error("failed to create request", "err", err)
859
-
return
860
-
}
861
-
862
-
// forward the If-None-Match header
863
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
864
-
req.Header.Set("If-None-Match", clientETag)
865
-
}
866
-
867
-
client := &http.Client{}
868
-
resp, err := client.Do(req)
869
-
if err != nil {
870
-
l.Error("failed to reach knotserver", "err", err)
871
-
rp.pages.Error503(w)
872
-
return
873
-
}
874
-
defer resp.Body.Close()
875
-
876
-
// forward 304 not modified
877
-
if resp.StatusCode == http.StatusNotModified {
878
-
w.WriteHeader(http.StatusNotModified)
879
-
return
880
-
}
881
-
882
-
if resp.StatusCode != http.StatusOK {
883
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
884
-
w.WriteHeader(resp.StatusCode)
885
-
_, _ = io.Copy(w, resp.Body)
886
-
return
887
-
}
888
-
889
-
contentType := resp.Header.Get("Content-Type")
890
-
body, err := io.ReadAll(resp.Body)
891
-
if err != nil {
892
-
l.Error("error reading response body from knotserver", "err", err)
893
-
w.WriteHeader(http.StatusInternalServerError)
894
-
return
895
-
}
896
-
897
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
898
-
// serve all textual content as text/plain
899
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
900
-
w.Write(body)
901
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
902
-
// serve images and videos with their original content type
903
-
w.Header().Set("Content-Type", contentType)
904
-
w.Write(body)
905
-
} else {
906
-
w.WriteHeader(http.StatusUnsupportedMediaType)
907
-
w.Write([]byte("unsupported content type"))
908
-
return
909
-
}
910
-
}
911
-
912
-
// isTextualMimeType returns true if the MIME type represents textual content
913
-
// that should be served as text/plain
914
-
func isTextualMimeType(mimeType string) bool {
915
-
textualTypes := []string{
916
-
"application/json",
917
-
"application/xml",
918
-
"application/yaml",
919
-
"application/x-yaml",
920
-
"application/toml",
921
-
"application/javascript",
922
-
"application/ecmascript",
923
-
"message/",
924
-
}
925
-
926
-
return slices.Contains(textualTypes, mimeType)
927
-
}
928
-
929
81
// modify the spindle configured for this repo
930
82
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
931
83
user := rp.oauth.GetUser(r)
···
1781
933
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1782
934
}
1783
935
1784
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1785
-
l := rp.logger.With("handler", "SetDefaultBranch")
1786
-
1787
-
f, err := rp.repoResolver.Resolve(r)
1788
-
if err != nil {
1789
-
l.Error("failed to get repo and knot", "err", err)
1790
-
return
1791
-
}
1792
-
1793
-
noticeId := "operation-error"
1794
-
branch := r.FormValue("branch")
1795
-
if branch == "" {
1796
-
http.Error(w, "malformed form", http.StatusBadRequest)
1797
-
return
1798
-
}
1799
-
1800
-
client, err := rp.oauth.ServiceClient(
1801
-
r,
1802
-
oauth.WithService(f.Knot),
1803
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1804
-
oauth.WithDev(rp.config.Core.Dev),
1805
-
)
1806
-
if err != nil {
1807
-
l.Error("failed to connect to knot server", "err", err)
1808
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1809
-
return
1810
-
}
1811
-
1812
-
xe := tangled.RepoSetDefaultBranch(
1813
-
r.Context(),
1814
-
client,
1815
-
&tangled.RepoSetDefaultBranch_Input{
1816
-
Repo: f.RepoAt().String(),
1817
-
DefaultBranch: branch,
1818
-
},
1819
-
)
1820
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1821
-
l.Error("xrpc failed", "err", xe)
1822
-
rp.pages.Notice(w, noticeId, err.Error())
1823
-
return
1824
-
}
1825
-
1826
-
rp.pages.HxRefresh(w)
1827
-
}
1828
-
1829
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1830
-
user := rp.oauth.GetUser(r)
1831
-
l := rp.logger.With("handler", "Secrets")
1832
-
l = l.With("did", user.Did)
1833
-
1834
-
f, err := rp.repoResolver.Resolve(r)
1835
-
if err != nil {
1836
-
l.Error("failed to get repo and knot", "err", err)
1837
-
return
1838
-
}
1839
-
1840
-
if f.Spindle == "" {
1841
-
l.Error("empty spindle cannot add/rm secret", "err", err)
1842
-
return
1843
-
}
1844
-
1845
-
lxm := tangled.RepoAddSecretNSID
1846
-
if r.Method == http.MethodDelete {
1847
-
lxm = tangled.RepoRemoveSecretNSID
1848
-
}
1849
-
1850
-
spindleClient, err := rp.oauth.ServiceClient(
1851
-
r,
1852
-
oauth.WithService(f.Spindle),
1853
-
oauth.WithLxm(lxm),
1854
-
oauth.WithExp(60),
1855
-
oauth.WithDev(rp.config.Core.Dev),
1856
-
)
1857
-
if err != nil {
1858
-
l.Error("failed to create spindle client", "err", err)
1859
-
return
1860
-
}
1861
-
1862
-
key := r.FormValue("key")
1863
-
if key == "" {
1864
-
w.WriteHeader(http.StatusBadRequest)
1865
-
return
1866
-
}
1867
-
1868
-
switch r.Method {
1869
-
case http.MethodPut:
1870
-
errorId := "add-secret-error"
1871
-
1872
-
value := r.FormValue("value")
1873
-
if value == "" {
1874
-
w.WriteHeader(http.StatusBadRequest)
1875
-
return
1876
-
}
1877
-
1878
-
err = tangled.RepoAddSecret(
1879
-
r.Context(),
1880
-
spindleClient,
1881
-
&tangled.RepoAddSecret_Input{
1882
-
Repo: f.RepoAt().String(),
1883
-
Key: key,
1884
-
Value: value,
1885
-
},
1886
-
)
1887
-
if err != nil {
1888
-
l.Error("Failed to add secret.", "err", err)
1889
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
1890
-
return
1891
-
}
1892
-
1893
-
case http.MethodDelete:
1894
-
errorId := "operation-error"
1895
-
1896
-
err = tangled.RepoRemoveSecret(
1897
-
r.Context(),
1898
-
spindleClient,
1899
-
&tangled.RepoRemoveSecret_Input{
1900
-
Repo: f.RepoAt().String(),
1901
-
Key: key,
1902
-
},
1903
-
)
1904
-
if err != nil {
1905
-
l.Error("Failed to delete secret.", "err", err)
1906
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1907
-
return
1908
-
}
1909
-
}
1910
-
1911
-
rp.pages.HxRefresh(w)
1912
-
}
1913
-
1914
-
type tab = map[string]any
1915
-
1916
-
var (
1917
-
// would be great to have ordered maps right about now
1918
-
settingsTabs []tab = []tab{
1919
-
{"Name": "general", "Icon": "sliders-horizontal"},
1920
-
{"Name": "access", "Icon": "users"},
1921
-
{"Name": "pipelines", "Icon": "layers-2"},
1922
-
}
1923
-
)
1924
-
1925
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1926
-
tabVal := r.URL.Query().Get("tab")
1927
-
if tabVal == "" {
1928
-
tabVal = "general"
1929
-
}
1930
-
1931
-
switch tabVal {
1932
-
case "general":
1933
-
rp.generalSettings(w, r)
1934
-
1935
-
case "access":
1936
-
rp.accessSettings(w, r)
1937
-
1938
-
case "pipelines":
1939
-
rp.pipelineSettings(w, r)
1940
-
}
1941
-
}
1942
-
1943
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1944
-
l := rp.logger.With("handler", "generalSettings")
1945
-
1946
-
f, err := rp.repoResolver.Resolve(r)
1947
-
user := rp.oauth.GetUser(r)
1948
-
1949
-
scheme := "http"
1950
-
if !rp.config.Core.Dev {
1951
-
scheme = "https"
1952
-
}
1953
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1954
-
xrpcc := &indigoxrpc.Client{
1955
-
Host: host,
1956
-
}
1957
-
1958
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1959
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1960
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1961
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1962
-
rp.pages.Error503(w)
1963
-
return
1964
-
}
1965
-
1966
-
var result types.RepoBranchesResponse
1967
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1968
-
l.Error("failed to decode XRPC response", "err", err)
1969
-
rp.pages.Error503(w)
1970
-
return
1971
-
}
1972
-
1973
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1974
-
if err != nil {
1975
-
l.Error("failed to fetch labels", "err", err)
1976
-
rp.pages.Error503(w)
1977
-
return
1978
-
}
1979
-
1980
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1981
-
if err != nil {
1982
-
l.Error("failed to fetch labels", "err", err)
1983
-
rp.pages.Error503(w)
1984
-
return
1985
-
}
1986
-
// remove default labels from the labels list, if present
1987
-
defaultLabelMap := make(map[string]bool)
1988
-
for _, dl := range defaultLabels {
1989
-
defaultLabelMap[dl.AtUri().String()] = true
1990
-
}
1991
-
n := 0
1992
-
for _, l := range labels {
1993
-
if !defaultLabelMap[l.AtUri().String()] {
1994
-
labels[n] = l
1995
-
n++
1996
-
}
1997
-
}
1998
-
labels = labels[:n]
1999
-
2000
-
subscribedLabels := make(map[string]struct{})
2001
-
for _, l := range f.Repo.Labels {
2002
-
subscribedLabels[l] = struct{}{}
2003
-
}
2004
-
2005
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
2006
-
// if all default labels are subbed, show the "unsubscribe all" button
2007
-
shouldSubscribeAll := false
2008
-
for _, dl := range defaultLabels {
2009
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
2010
-
// one of the default labels is not subscribed to
2011
-
shouldSubscribeAll = true
2012
-
break
2013
-
}
2014
-
}
2015
-
2016
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
2017
-
LoggedInUser: user,
2018
-
RepoInfo: f.RepoInfo(user),
2019
-
Branches: result.Branches,
2020
-
Labels: labels,
2021
-
DefaultLabels: defaultLabels,
2022
-
SubscribedLabels: subscribedLabels,
2023
-
ShouldSubscribeAll: shouldSubscribeAll,
2024
-
Tabs: settingsTabs,
2025
-
Tab: "general",
2026
-
})
2027
-
}
2028
-
2029
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2030
-
l := rp.logger.With("handler", "accessSettings")
2031
-
2032
-
f, err := rp.repoResolver.Resolve(r)
2033
-
user := rp.oauth.GetUser(r)
2034
-
2035
-
repoCollaborators, err := f.Collaborators(r.Context())
2036
-
if err != nil {
2037
-
l.Error("failed to get collaborators", "err", err)
2038
-
}
2039
-
2040
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
2041
-
LoggedInUser: user,
2042
-
RepoInfo: f.RepoInfo(user),
2043
-
Tabs: settingsTabs,
2044
-
Tab: "access",
2045
-
Collaborators: repoCollaborators,
2046
-
})
2047
-
}
2048
-
2049
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2050
-
l := rp.logger.With("handler", "pipelineSettings")
2051
-
2052
-
f, err := rp.repoResolver.Resolve(r)
2053
-
user := rp.oauth.GetUser(r)
2054
-
2055
-
// all spindles that the repo owner is a member of
2056
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2057
-
if err != nil {
2058
-
l.Error("failed to fetch spindles", "err", err)
2059
-
return
2060
-
}
2061
-
2062
-
var secrets []*tangled.RepoListSecrets_Secret
2063
-
if f.Spindle != "" {
2064
-
if spindleClient, err := rp.oauth.ServiceClient(
2065
-
r,
2066
-
oauth.WithService(f.Spindle),
2067
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
2068
-
oauth.WithExp(60),
2069
-
oauth.WithDev(rp.config.Core.Dev),
2070
-
); err != nil {
2071
-
l.Error("failed to create spindle client", "err", err)
2072
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2073
-
l.Error("failed to fetch secrets", "err", err)
2074
-
} else {
2075
-
secrets = resp.Secrets
2076
-
}
2077
-
}
2078
-
2079
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2080
-
return strings.Compare(a.Key, b.Key)
2081
-
})
2082
-
2083
-
var dids []string
2084
-
for _, s := range secrets {
2085
-
dids = append(dids, s.CreatedBy)
2086
-
}
2087
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2088
-
2089
-
// convert to a more manageable form
2090
-
var niceSecret []map[string]any
2091
-
for id, s := range secrets {
2092
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2093
-
niceSecret = append(niceSecret, map[string]any{
2094
-
"Id": id,
2095
-
"Key": s.Key,
2096
-
"CreatedAt": when,
2097
-
"CreatedBy": resolvedIdents[id].Handle.String(),
2098
-
})
2099
-
}
2100
-
2101
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2102
-
LoggedInUser: user,
2103
-
RepoInfo: f.RepoInfo(user),
2104
-
Tabs: settingsTabs,
2105
-
Tab: "pipelines",
2106
-
Spindles: spindles,
2107
-
CurrentSpindle: f.Spindle,
2108
-
Secrets: niceSecret,
2109
-
})
2110
-
}
2111
-
2112
936
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2113
937
l := rp.logger.With("handler", "SyncRepoFork")
2114
938
···
2249
1073
Source: sourceAt,
2250
1074
Description: f.Repo.Description,
2251
1075
Created: time.Now(),
2252
-
Labels: models.DefaultLabelDefs(),
1076
+
Labels: rp.config.Label.DefaultLabelDefs,
2253
1077
}
2254
1078
record := repo.AsRecord()
2255
1079
···
2365
1189
aturi = ""
2366
1190
2367
1191
rp.notifier.NewRepo(r.Context(), repo)
2368
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
1192
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
2369
1193
}
2370
1194
}
2371
1195
···
2390
1214
})
2391
1215
return err
2392
1216
}
2393
-
2394
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2395
-
l := rp.logger.With("handler", "RepoCompareNew")
2396
-
2397
-
user := rp.oauth.GetUser(r)
2398
-
f, err := rp.repoResolver.Resolve(r)
2399
-
if err != nil {
2400
-
l.Error("failed to get repo and knot", "err", err)
2401
-
return
2402
-
}
2403
-
2404
-
scheme := "http"
2405
-
if !rp.config.Core.Dev {
2406
-
scheme = "https"
2407
-
}
2408
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2409
-
xrpcc := &indigoxrpc.Client{
2410
-
Host: host,
2411
-
}
2412
-
2413
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2414
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2415
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2416
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2417
-
rp.pages.Error503(w)
2418
-
return
2419
-
}
2420
-
2421
-
var branchResult types.RepoBranchesResponse
2422
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2423
-
l.Error("failed to decode XRPC branches response", "err", err)
2424
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2425
-
return
2426
-
}
2427
-
branches := branchResult.Branches
2428
-
2429
-
sortBranches(branches)
2430
-
2431
-
var defaultBranch string
2432
-
for _, b := range branches {
2433
-
if b.IsDefault {
2434
-
defaultBranch = b.Name
2435
-
}
2436
-
}
2437
-
2438
-
base := defaultBranch
2439
-
head := defaultBranch
2440
-
2441
-
params := r.URL.Query()
2442
-
queryBase := params.Get("base")
2443
-
queryHead := params.Get("head")
2444
-
if queryBase != "" {
2445
-
base = queryBase
2446
-
}
2447
-
if queryHead != "" {
2448
-
head = queryHead
2449
-
}
2450
-
2451
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2452
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2453
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2454
-
rp.pages.Error503(w)
2455
-
return
2456
-
}
2457
-
2458
-
var tags types.RepoTagsResponse
2459
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2460
-
l.Error("failed to decode XRPC tags response", "err", err)
2461
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2462
-
return
2463
-
}
2464
-
2465
-
repoinfo := f.RepoInfo(user)
2466
-
2467
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2468
-
LoggedInUser: user,
2469
-
RepoInfo: repoinfo,
2470
-
Branches: branches,
2471
-
Tags: tags.Tags,
2472
-
Base: base,
2473
-
Head: head,
2474
-
})
2475
-
}
2476
-
2477
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2478
-
l := rp.logger.With("handler", "RepoCompare")
2479
-
2480
-
user := rp.oauth.GetUser(r)
2481
-
f, err := rp.repoResolver.Resolve(r)
2482
-
if err != nil {
2483
-
l.Error("failed to get repo and knot", "err", err)
2484
-
return
2485
-
}
2486
-
2487
-
var diffOpts types.DiffOpts
2488
-
if d := r.URL.Query().Get("diff"); d == "split" {
2489
-
diffOpts.Split = true
2490
-
}
2491
-
2492
-
// if user is navigating to one of
2493
-
// /compare/{base}/{head}
2494
-
// /compare/{base}...{head}
2495
-
base := chi.URLParam(r, "base")
2496
-
head := chi.URLParam(r, "head")
2497
-
if base == "" && head == "" {
2498
-
rest := chi.URLParam(r, "*") // master...feature/xyz
2499
-
parts := strings.SplitN(rest, "...", 2)
2500
-
if len(parts) == 2 {
2501
-
base = parts[0]
2502
-
head = parts[1]
2503
-
}
2504
-
}
2505
-
2506
-
base, _ = url.PathUnescape(base)
2507
-
head, _ = url.PathUnescape(head)
2508
-
2509
-
if base == "" || head == "" {
2510
-
l.Error("invalid comparison")
2511
-
rp.pages.Error404(w)
2512
-
return
2513
-
}
2514
-
2515
-
scheme := "http"
2516
-
if !rp.config.Core.Dev {
2517
-
scheme = "https"
2518
-
}
2519
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2520
-
xrpcc := &indigoxrpc.Client{
2521
-
Host: host,
2522
-
}
2523
-
2524
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2525
-
2526
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2527
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2528
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2529
-
rp.pages.Error503(w)
2530
-
return
2531
-
}
2532
-
2533
-
var branches types.RepoBranchesResponse
2534
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2535
-
l.Error("failed to decode XRPC branches response", "err", err)
2536
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2537
-
return
2538
-
}
2539
-
2540
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2541
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2542
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2543
-
rp.pages.Error503(w)
2544
-
return
2545
-
}
2546
-
2547
-
var tags types.RepoTagsResponse
2548
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2549
-
l.Error("failed to decode XRPC tags response", "err", err)
2550
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2551
-
return
2552
-
}
2553
-
2554
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2555
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2556
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2557
-
rp.pages.Error503(w)
2558
-
return
2559
-
}
2560
-
2561
-
var formatPatch types.RepoFormatPatchResponse
2562
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2563
-
l.Error("failed to decode XRPC compare response", "err", err)
2564
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2565
-
return
2566
-
}
2567
-
2568
-
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2569
-
2570
-
repoinfo := f.RepoInfo(user)
2571
-
2572
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2573
-
LoggedInUser: user,
2574
-
RepoInfo: repoinfo,
2575
-
Branches: branches.Branches,
2576
-
Tags: tags.Tags,
2577
-
Base: base,
2578
-
Head: head,
2579
-
Diff: &diff,
2580
-
DiffOpts: diffOpts,
2581
-
})
2582
-
2583
-
}
+2
-37
appview/repo/repo_util.go
+2
-37
appview/repo/repo_util.go
···
1
1
package repo
2
2
3
3
import (
4
-
"context"
5
4
"crypto/rand"
6
-
"fmt"
7
5
"math/big"
8
6
"slices"
9
7
"sort"
···
19
17
20
18
func sortFiles(files []types.NiceTree) {
21
19
sort.Slice(files, func(i, j int) bool {
22
-
iIsFile := files[i].IsFile
23
-
jIsFile := files[j].IsFile
20
+
iIsFile := files[i].IsFile()
21
+
jIsFile := files[j].IsFile()
24
22
if iIsFile != jIsFile {
25
23
return !iIsFile
26
24
}
···
90
88
}
91
89
92
90
return
93
-
}
94
-
95
-
// emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid
96
-
// and resolves all dids to handles and returns a new map[string]string
97
-
func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string {
98
-
if emailToDidMap == nil {
99
-
return nil
100
-
}
101
-
102
-
var dids []string
103
-
for _, v := range emailToDidMap {
104
-
dids = append(dids, v)
105
-
}
106
-
resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids)
107
-
108
-
didHandleMap := make(map[string]string)
109
-
for _, identity := range resolvedIdents {
110
-
if !identity.Handle.IsInvalidHandle() {
111
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
112
-
} else {
113
-
didHandleMap[identity.DID.String()] = identity.DID.String()
114
-
}
115
-
}
116
-
117
-
// Create map of email to didOrHandle for commit display
118
-
emailToDidOrHandle := make(map[string]string)
119
-
for email, did := range emailToDidMap {
120
-
if didOrHandle, ok := didHandleMap[did]; ok {
121
-
emailToDidOrHandle[email] = didOrHandle
122
-
}
123
-
}
124
-
125
-
return emailToDidOrHandle
126
91
}
127
92
128
93
func randomString(n int) string {
+15
-20
appview/repo/router.go
+15
-20
appview/repo/router.go
···
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
-
r.Get("/", rp.RepoIndex)
13
-
r.Get("/opengraph", rp.RepoOpenGraphSummary)
14
-
r.Get("/feed.atom", rp.RepoAtomFeed)
15
-
r.Get("/commits/{ref}", rp.RepoLog)
12
+
r.Get("/", rp.Index)
13
+
r.Get("/opengraph", rp.Opengraph)
14
+
r.Get("/feed.atom", rp.AtomFeed)
15
+
r.Get("/commits/{ref}", rp.Log)
16
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
-
r.Get("/", rp.RepoIndex)
18
-
r.Get("/*", rp.RepoTree)
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
19
19
})
20
-
r.Get("/commit/{ref}", rp.RepoCommit)
21
-
r.Get("/branches", rp.RepoBranches)
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
22
r.Delete("/branches", rp.DeleteBranch)
23
23
r.Route("/tags", func(r chi.Router) {
24
-
r.Get("/", rp.RepoTags)
24
+
r.Get("/", rp.Tags)
25
25
r.Route("/{tag}", func(r chi.Router) {
26
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
27
···
37
37
})
38
38
})
39
39
})
40
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
40
+
r.Get("/blob/{ref}/*", rp.Blob)
41
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
42
43
43
// intentionally doesn't use /* as this isn't
···
54
54
})
55
55
56
56
r.Route("/compare", func(r chi.Router) {
57
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
57
+
r.Get("/", rp.CompareNew) // start an new comparison
58
58
59
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
60
// /compare/{ref1}...{ref2}
61
61
// for example:
62
62
// /compare/master...some/feature
63
63
// /compare/master...example.com:another/feature <- this is a fork
64
-
r.Get("/{base}/{head}", rp.RepoCompare)
65
-
r.Get("/*", rp.RepoCompare)
64
+
r.Get("/{base}/{head}", rp.Compare)
65
+
r.Get("/*", rp.Compare)
66
66
})
67
67
68
68
// label panel in issues/pulls/discussions/tasks
···
74
74
// settings routes, needs auth
75
75
r.Group(func(r chi.Router) {
76
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
77
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
84
-
r.Get("/", rp.RepoSettings)
78
+
r.Get("/", rp.Settings)
79
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
85
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
86
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
87
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
+4
-2
appview/settings/settings.go
+4
-2
appview/settings/settings.go
···
22
22
"tangled.org/core/tid"
23
23
24
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
26
lexutil "github.com/bluesky-social/indigo/lex/util"
26
27
"github.com/gliderlabs/ssh"
27
28
"github.com/google/uuid"
···
91
92
user := s.OAuth.GetUser(r)
92
93
did := s.OAuth.GetDid(r)
93
94
94
-
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
prefs, err := db.GetNotificationPreference(s.Db, did)
95
96
if err != nil {
96
97
log.Printf("failed to get notification preferences: %s", err)
97
98
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
···
110
111
did := s.OAuth.GetDid(r)
111
112
112
113
prefs := &models.NotificationPreferences{
113
-
UserDid: did,
114
+
UserDid: syntax.DID(did),
114
115
RepoStarred: r.FormValue("repo_starred") == "on",
115
116
IssueCreated: r.FormValue("issue_created") == "on",
116
117
IssueCommented: r.FormValue("issue_commented") == "on",
···
119
120
PullCommented: r.FormValue("pull_commented") == "on",
120
121
PullMerged: r.FormValue("pull_merged") == "on",
121
122
Followed: r.FormValue("followed") == "on",
123
+
UserMentioned: r.FormValue("user_mentioned") == "on",
122
124
EmailNotifications: r.FormValue("email_notifications") == "on",
123
125
}
124
126
+18
appview/signup/requests.go
+18
appview/signup/requests.go
···
102
102
103
103
return result.DID, nil
104
104
}
105
+
106
+
func (s *Signup) deleteAccountRequest(did string) error {
107
+
body := map[string]string{
108
+
"did": did,
109
+
}
110
+
111
+
resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true)
112
+
if err != nil {
113
+
return err
114
+
}
115
+
defer resp.Body.Close()
116
+
117
+
if resp.StatusCode != http.StatusOK {
118
+
return s.handlePdsError(resp, "delete account")
119
+
}
120
+
121
+
return nil
122
+
}
+93
-36
appview/signup/signup.go
+93
-36
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"context"
5
6
"encoding/json"
6
7
"errors"
7
8
"fmt"
···
216
217
return
217
218
}
218
219
219
-
did, err := s.createAccountRequest(username, password, email, code)
220
-
if err != nil {
221
-
s.l.Error("failed to create account", "error", err)
222
-
s.pages.Notice(w, "signup-error", err.Error())
223
-
return
224
-
}
225
-
226
220
if s.cf == nil {
227
221
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
228
222
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
229
223
return
230
224
}
231
225
232
-
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
233
-
Type: "TXT",
234
-
Name: "_atproto." + username,
235
-
Content: fmt.Sprintf(`"did=%s"`, did),
236
-
TTL: 6400,
237
-
Proxied: false,
238
-
})
226
+
// Execute signup transactionally with rollback capability
227
+
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
239
228
if err != nil {
240
-
s.l.Error("failed to create DNS record", "error", err)
241
-
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
// Error already logged and notice already sent
242
230
return
243
231
}
232
+
}
233
+
}
244
234
245
-
err = db.AddEmail(s.db, models.Email{
246
-
Did: did,
247
-
Address: email,
248
-
Verified: true,
249
-
Primary: true,
250
-
})
251
-
if err != nil {
252
-
s.l.Error("failed to add email", "error", err)
253
-
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
254
-
return
255
-
}
235
+
// executeSignupTransaction performs the signup process transactionally with rollback
236
+
func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
237
+
var recordID string
238
+
var did string
239
+
var emailAdded bool
256
240
257
-
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
258
-
<a class="underline text-black dark:text-white" href="/login">login</a>
259
-
with <code>%s.tngl.sh</code>.`, username))
241
+
success := false
242
+
defer func() {
243
+
if !success {
244
+
s.l.Info("rolling back signup transaction", "username", username, "did", did)
260
245
261
-
go func() {
262
-
err := db.DeleteInflightSignup(s.db, email)
263
-
if err != nil {
264
-
s.l.Error("failed to delete inflight signup", "error", err)
246
+
// Rollback DNS record
247
+
if recordID != "" {
248
+
if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
249
+
s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
250
+
} else {
251
+
s.l.Info("successfully rolled back DNS record", "recordID", recordID)
252
+
}
265
253
}
266
-
}()
267
-
return
254
+
255
+
// Rollback PDS account
256
+
if did != "" {
257
+
if err := s.deleteAccountRequest(did); err != nil {
258
+
s.l.Error("failed to rollback PDS account", "error", err, "did", did)
259
+
} else {
260
+
s.l.Info("successfully rolled back PDS account", "did", did)
261
+
}
262
+
}
263
+
264
+
// Rollback email from database
265
+
if emailAdded {
266
+
if err := db.DeleteEmail(s.db, did, email); err != nil {
267
+
s.l.Error("failed to rollback email from database", "error", err, "email", email)
268
+
} else {
269
+
s.l.Info("successfully rolled back email from database", "email", email)
270
+
}
271
+
}
272
+
}
273
+
}()
274
+
275
+
// step 1: create account in PDS
276
+
did, err := s.createAccountRequest(username, password, email, code)
277
+
if err != nil {
278
+
s.l.Error("failed to create account", "error", err)
279
+
s.pages.Notice(w, "signup-error", err.Error())
280
+
return err
268
281
}
282
+
283
+
// step 2: create DNS record with actual DID
284
+
recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{
285
+
Type: "TXT",
286
+
Name: "_atproto." + username,
287
+
Content: fmt.Sprintf(`"did=%s"`, did),
288
+
TTL: 6400,
289
+
Proxied: false,
290
+
})
291
+
if err != nil {
292
+
s.l.Error("failed to create DNS record", "error", err)
293
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
294
+
return err
295
+
}
296
+
297
+
// step 3: add email to database
298
+
err = db.AddEmail(s.db, models.Email{
299
+
Did: did,
300
+
Address: email,
301
+
Verified: true,
302
+
Primary: true,
303
+
})
304
+
if err != nil {
305
+
s.l.Error("failed to add email", "error", err)
306
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
307
+
return err
308
+
}
309
+
emailAdded = true
310
+
311
+
// if we get here, we've successfully created the account and added the email
312
+
success = true
313
+
314
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
315
+
<a class="underline text-black dark:text-white" href="/login">login</a>
316
+
with <code>%s.tngl.sh</code>.`, username))
317
+
318
+
// clean up inflight signup asynchronously
319
+
go func() {
320
+
if err := db.DeleteInflightSignup(s.db, email); err != nil {
321
+
s.l.Error("failed to delete inflight signup", "error", err)
322
+
}
323
+
}()
324
+
325
+
return nil
269
326
}
270
327
271
328
type turnstileResponse struct {
+9
appview/spindles/spindles.go
+9
appview/spindles/spindles.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
146
147
}
147
148
148
149
instance := r.FormValue("instance")
150
+
// Strip protocol, trailing slashes, and whitespace
151
+
// Rkey cannot contain slashes
152
+
instance = strings.TrimSpace(instance)
153
+
instance = strings.TrimPrefix(instance, "https://")
154
+
instance = strings.TrimPrefix(instance, "http://")
155
+
instance = strings.TrimSuffix(instance, "/")
149
156
if instance == "" {
150
157
s.Pages.Notice(w, noticeId, "Incomplete form.")
151
158
return
···
484
491
}
485
492
486
493
member := r.FormValue("member")
494
+
member = strings.TrimPrefix(member, "@")
487
495
if member == "" {
488
496
l.Error("empty member")
489
497
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
613
621
}
614
622
615
623
member := r.FormValue("member")
624
+
member = strings.TrimPrefix(member, "@")
616
625
if member == "" {
617
626
l.Error("empty member")
618
627
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1
appview/state/follow.go
+1
appview/state/follow.go
+11
-8
appview/state/gfi.go
+11
-8
appview/state/gfi.go
···
1
1
package state
2
2
3
3
import (
4
-
"fmt"
5
4
"log"
6
5
"net/http"
7
6
"sort"
8
7
9
8
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.org/core/api/tangled"
11
9
"tangled.org/core/appview/db"
12
10
"tangled.org/core/appview/models"
13
11
"tangled.org/core/appview/pages"
···
18
16
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
17
user := s.oauth.GetUser(r)
20
18
21
-
page, ok := r.Context().Value("page").(pagination.Page)
22
-
if !ok {
23
-
page = pagination.FirstPage()
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
24
28
}
25
-
26
-
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
29
28
30
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
29
31
if err != nil {
···
38
40
RepoGroups: []*models.RepoGroup{},
39
41
LabelDefs: make(map[string]*models.LabelDefinition),
40
42
Page: page,
43
+
GfiLabel: gfiLabelDef,
41
44
})
42
45
return
43
46
}
···
146
149
RepoGroups: paginatedGroups,
147
150
LabelDefs: labelDefsMap,
148
151
Page: page,
149
-
GfiLabel: labelDefsMap[goodFirstIssueLabel],
152
+
GfiLabel: gfiLabelDef,
150
153
})
151
154
}
+3
appview/state/login.go
+3
appview/state/login.go
···
14
14
switch r.Method {
15
15
case http.MethodGet:
16
16
returnURL := r.URL.Query().Get("return_url")
17
+
errorCode := r.URL.Query().Get("error")
17
18
s.pages.Login(w, pages.LoginParams{
18
19
ReturnUrl: returnURL,
20
+
ErrorCode: errorCode,
19
21
})
20
22
case http.MethodPost:
21
23
handle := r.FormValue("handle")
···
44
46
45
47
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
46
48
if err != nil {
49
+
l.Error("failed to start auth", "err", err)
47
50
http.Error(w, err.Error(), http.StatusInternalServerError)
48
51
return
49
52
}
+2
appview/state/profile.go
+2
appview/state/profile.go
···
538
538
profile.Description = r.FormValue("description")
539
539
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
540
540
profile.Location = r.FormValue("location")
541
+
profile.Pronouns = r.FormValue("pronouns")
541
542
542
543
var links [5]string
543
544
for i := range 5 {
···
652
653
Location: &profile.Location,
653
654
PinnedRepositories: pinnedRepoStrings,
654
655
Stats: vanityStats[:],
656
+
Pronouns: &profile.Pronouns,
655
657
}},
656
658
SwapRecord: cid,
657
659
})
+44
-36
appview/state/router.go
+44
-36
appview/state/router.go
···
42
42
43
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
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
-
}
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
68
77
}
69
-
standardRouter.ServeHTTP(w, r)
78
+
70
79
}
80
+
81
+
standardRouter.ServeHTTP(w, r)
71
82
})
72
83
73
84
return router
···
80
91
r.Get("/", s.Profile)
81
92
r.Get("/feed.atom", s.AtomFeedPage)
82
93
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
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
90
95
r.Use(mw.GoImport())
91
96
r.Mount("/", s.RepoRouter(mw))
92
97
r.Mount("/issues", s.IssuesRouter(mw))
93
98
r.Mount("/pulls", s.PullsRouter(mw))
94
-
r.Mount("/pipelines", s.PipelinesRouter(mw))
95
-
r.Mount("/labels", s.LabelsRouter(mw))
99
+
r.Mount("/pipelines", s.PipelinesRouter())
100
+
r.Mount("/labels", s.LabelsRouter())
96
101
97
102
// These routes get proxied to the knot
98
103
r.Get("/info/refs", s.InfoRefs)
···
262
267
s.config,
263
268
s.notifier,
264
269
s.validator,
270
+
s.indexer.Issues,
265
271
log.SubLogger(s.logger, "issues"),
266
272
)
267
273
return issues.Router(mw)
···
277
283
s.config,
278
284
s.notifier,
279
285
s.enforcer,
286
+
s.validator,
287
+
s.indexer.Pulls,
280
288
log.SubLogger(s.logger, "pulls"),
281
289
)
282
290
return pulls.Router(mw)
···
299
307
return repo.Router(mw)
300
308
}
301
309
302
-
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
310
+
func (s *State) PipelinesRouter() http.Handler {
303
311
pipes := pipelines.New(
304
312
s.oauth,
305
313
s.repoResolver,
···
311
319
s.enforcer,
312
320
log.SubLogger(s.logger, "pipelines"),
313
321
)
314
-
return pipes.Router(mw)
322
+
return pipes.Router()
315
323
}
316
324
317
-
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
325
+
func (s *State) LabelsRouter() http.Handler {
318
326
ls := labels.New(
319
327
s.oauth,
320
328
s.pages,
···
323
331
s.enforcer,
324
332
log.SubLogger(s.logger, "labels"),
325
333
)
326
-
return ls.Router(mw)
334
+
return ls.Router()
327
335
}
328
336
329
337
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
+22
-12
appview/state/state.go
+22
-12
appview/state/state.go
···
14
14
"tangled.org/core/appview"
15
15
"tangled.org/core/appview/config"
16
16
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/indexer"
17
18
"tangled.org/core/appview/models"
18
19
"tangled.org/core/appview/notify"
19
20
dbnotify "tangled.org/core/appview/notify/db"
···
43
44
type State struct {
44
45
db *db.DB
45
46
notifier notify.Notifier
47
+
indexer *indexer.Indexer
46
48
oauth *oauth.OAuth
47
49
enforcer *rbac.Enforcer
48
50
pages *pages.Pages
···
65
67
return nil, fmt.Errorf("failed to create db: %w", err)
66
68
}
67
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
+
68
76
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
69
77
if err != nil {
70
78
return nil, fmt.Errorf("failed to create enforcer: %w", err)
71
79
}
72
80
73
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
81
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
74
82
if err != nil {
75
83
logger.Error("failed to create redis resolver", "err", err)
76
-
res = idresolver.DefaultResolver()
84
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
77
85
}
78
86
79
87
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
121
129
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
122
130
}
123
131
124
-
if err := BackfillDefaultDefs(d, res); err != nil {
132
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
125
133
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
126
134
}
127
135
···
159
167
if !config.Core.Dev {
160
168
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
161
169
}
162
-
notifier := notify.NewMergedNotifier(notifiers...)
170
+
notifiers = append(notifiers, indexer)
171
+
notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify"))
163
172
164
173
state := &State{
165
174
d,
166
175
notifier,
176
+
indexer,
167
177
oauth,
168
178
enforcer,
169
179
pages,
···
284
294
return
285
295
}
286
296
287
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
297
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
288
298
if err != nil {
289
299
// non-fatal
290
300
}
···
376
386
377
387
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
378
388
if err != nil {
379
-
w.WriteHeader(http.StatusNotFound)
389
+
s.logger.Error("failed to get public keys", "err", err)
390
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
380
391
return
381
392
}
382
393
383
394
if len(pubKeys) == 0 {
384
-
w.WriteHeader(http.StatusNotFound)
395
+
w.WriteHeader(http.StatusNoContent)
385
396
return
386
397
}
387
398
···
506
517
Rkey: rkey,
507
518
Description: description,
508
519
Created: time.Now(),
509
-
Labels: models.DefaultLabelDefs(),
520
+
Labels: s.config.Label.DefaultLabelDefs,
510
521
}
511
522
record := repo.AsRecord()
512
523
···
622
633
aturi = ""
623
634
624
635
s.notifier.NewRepo(r.Context(), repo)
625
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
636
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
626
637
}
627
638
}
628
639
···
648
659
return err
649
660
}
650
661
651
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
652
-
defaults := models.DefaultLabelDefs()
662
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
653
663
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
654
664
if err != nil {
655
665
return err
···
659
669
return nil
660
670
}
661
671
662
-
labelDefs, err := models.FetchDefaultDefs(r)
672
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
663
673
if err != nil {
664
674
return err
665
675
}
+6
-6
appview/state/userutil/userutil.go
+6
-6
appview/state/userutil/userutil.go
···
10
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
11
)
12
12
13
-
func IsHandleNoAt(s string) bool {
13
+
func IsHandle(s string) bool {
14
14
// ref: https://atproto.com/specs/handle
15
15
return handleRegex.MatchString(s)
16
+
}
17
+
18
+
// IsDid checks if the given string is a standard DID.
19
+
func IsDid(s string) bool {
20
+
return didRegex.MatchString(s)
16
21
}
17
22
18
23
func UnflattenDid(s string) string {
···
45
50
return strings.Replace(s, ":", "-", 2)
46
51
}
47
52
return s
48
-
}
49
-
50
-
// IsDid checks if the given string is a standard DID.
51
-
func IsDid(s string) bool {
52
-
return didRegex.MatchString(s)
53
53
}
54
54
55
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+25
appview/validator/patch.go
+25
appview/validator/patch.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.org/core/patchutil"
8
+
)
9
+
10
+
func (v *Validator) ValidatePatch(patch *string) error {
11
+
if patch == nil || *patch == "" {
12
+
return fmt.Errorf("patch is empty")
13
+
}
14
+
15
+
// add newline if not present to diff style patches
16
+
if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") {
17
+
*patch = *patch + "\n"
18
+
}
19
+
20
+
if err := patchutil.IsPatchValid(*patch); err != nil {
21
+
return err
22
+
}
23
+
24
+
return nil
25
+
}
+53
appview/validator/repo_topics.go
+53
appview/validator/repo_topics.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"maps"
6
+
"regexp"
7
+
"slices"
8
+
"strings"
9
+
)
10
+
11
+
const (
12
+
maxTopicLen = 50
13
+
maxTopics = 20
14
+
)
15
+
16
+
var (
17
+
topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`)
18
+
)
19
+
20
+
// ValidateRepoTopicStr parses and validates whitespace-separated topic string.
21
+
//
22
+
// Rules:
23
+
// - topics are separated by whitespace
24
+
// - each topic may contain lowercase letters, digits, and hyphens only
25
+
// - each topic must be <= 50 characters long
26
+
// - no more than 20 topics allowed
27
+
// - duplicates are removed
28
+
func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) {
29
+
topicsStr = strings.TrimSpace(topicsStr)
30
+
if topicsStr == "" {
31
+
return nil, nil
32
+
}
33
+
parts := strings.Fields(topicsStr)
34
+
if len(parts) > maxTopics {
35
+
return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics)
36
+
}
37
+
38
+
topicSet := make(map[string]struct{})
39
+
40
+
for _, t := range parts {
41
+
if _, exists := topicSet[t]; exists {
42
+
continue
43
+
}
44
+
if len(t) > maxTopicLen {
45
+
return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics)
46
+
}
47
+
if !topicRE.MatchString(t) {
48
+
return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t)
49
+
}
50
+
topicSet[t] = struct{}{}
51
+
}
52
+
return slices.Collect(maps.Keys(topicSet)), nil
53
+
}
+17
appview/validator/uri.go
+17
appview/validator/uri.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
func (v *Validator) ValidateURI(uri string) error {
9
+
parsed, err := url.Parse(uri)
10
+
if err != nil {
11
+
return fmt.Errorf("invalid uri format")
12
+
}
13
+
if parsed.Scheme == "" {
14
+
return fmt.Errorf("uri scheme missing")
15
+
}
16
+
return nil
17
+
}
-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
37
38
38
```
39
39
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_JWKS
41
-
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
40
+
echo $TANGLED_OAUTH_CLIENT_SECRET
41
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
+
43
+
echo $TANGLED_OAUTH_CLIENT_KID
44
+
1761667908
42
45
43
46
# if not, you can set it up yourself:
44
-
go build -o genjwks.out ./cmd/genjwks
45
-
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
47
+
goat key generate -t P-256
48
+
Key Type: P-256 / secp256r1 / ES256 private key
49
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
52
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
+
54
+
# the secret key from above
55
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
46
56
47
57
# run redis in at a new shell to store oauth sessions
48
58
redis-server
···
158
168
159
169
If for any reason you wish to disable either one of the
160
170
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161
-
`services.tangled-spindle.enable` (or
162
-
`services.tangled-knot.enable`) to `false`.
171
+
`services.tangled.spindle.enable` (or
172
+
`services.tangled.knot.enable`) to `false`.
+2
-1
docs/knot-hosting.md
+2
-1
docs/knot-hosting.md
···
39
39
```
40
40
41
41
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/knot` is a good choice:
42
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
43
44
44
```
45
45
sudo mv knot /usr/local/bin/knot
46
+
sudo chown root:root /usr/local/bin/knot
46
47
```
47
48
48
49
This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1
-1
docs/migrations.md
+1
-1
docs/migrations.md
+19
-1
docs/spindle/pipeline.md
+19
-1
docs/spindle/pipeline.md
···
19
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
21
- `manual`: The workflow can be triggered manually.
22
-
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
22
+
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
+
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
24
24
25
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
···
29
30
branch: ["main", "develop"]
30
31
- event: ["pull_request"]
31
32
branch: ["main"]
33
+
```
34
+
35
+
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
+
37
+
```yaml
38
+
when:
39
+
- event: ["push"]
40
+
tag: ["v*"]
41
+
```
42
+
43
+
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
+
45
+
```yaml
46
+
when:
47
+
- event: ["push"]
48
+
branch: ["main", "release-*"]
49
+
tag: ["v*", "stable"]
32
50
```
33
51
34
52
## Engine
+17
flake.lock
+17
flake.lock
···
1
1
{
2
2
"nodes": {
3
+
"actor-typeahead-src": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1762835797,
7
+
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
8
+
"ref": "refs/heads/main",
9
+
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
10
+
"revCount": 6,
11
+
"type": "git",
12
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
13
+
},
14
+
"original": {
15
+
"type": "git",
16
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
17
+
}
18
+
},
3
19
"flake-compat": {
4
20
"flake": false,
5
21
"locked": {
···
150
166
},
151
167
"root": {
152
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
153
170
"flake-compat": "flake-compat",
154
171
"gomod2nix": "gomod2nix",
155
172
"htmx-src": "htmx-src",
+18
-8
flake.nix
+18
-8
flake.nix
···
33
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
34
flake = false;
35
35
};
36
+
actor-typeahead-src = {
37
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
+
flake = false;
39
+
};
36
40
ibm-plex-mono-src = {
37
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
42
flake = false;
···
54
58
inter-fonts-src,
55
59
sqlite-lib-src,
56
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
57
62
...
58
63
}: let
59
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
78
83
inherit (pkgs) gcc;
79
84
inherit sqlite-lib-src;
80
85
};
81
-
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
82
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
87
+
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
88
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
84
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
85
90
};
86
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
90
95
});
91
96
in {
92
97
overlays.default = final: prev: {
93
-
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
98
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
94
99
};
95
100
96
101
packages = forAllSystems (system: let
···
99
104
staticPackages = mkPackageSet pkgs.pkgsStatic;
100
105
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
101
106
in {
102
-
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
107
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
103
108
104
109
pkgsStatic-appview = staticPackages.appview;
105
110
pkgsStatic-knot = staticPackages.knot;
···
167
172
mkdir -p appview/pages/static
168
173
# no preserve is needed because watch-tailwind will want to be able to overwrite
169
174
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
170
-
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
175
+
export TANGLED_OAUTH_CLIENT_KID="$(date +%s)"
176
+
export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')"
171
177
'';
172
178
env.CGO_ENABLED = 1;
173
179
};
···
206
212
watch-knot = {
207
213
type = "app";
208
214
program = ''${air-watcher "knot" "server"}/bin/run'';
215
+
};
216
+
watch-spindle = {
217
+
type = "app";
218
+
program = ''${air-watcher "spindle" ""}/bin/run'';
209
219
};
210
220
watch-tailwind = {
211
221
type = "app";
···
278
288
}: {
279
289
imports = [./nix/modules/appview.nix];
280
290
281
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
291
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
282
292
};
283
293
nixosModules.knot = {
284
294
lib,
···
287
297
}: {
288
298
imports = [./nix/modules/knot.nix];
289
299
290
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
300
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
291
301
};
292
302
nixosModules.spindle = {
293
303
lib,
···
296
306
}: {
297
307
imports = [./nix/modules/spindle.nix];
298
308
299
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
309
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
300
310
};
301
311
};
302
312
}
+32
-13
go.mod
+32
-13
go.mod
···
7
7
github.com/alecthomas/assert/v2 v2.11.0
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
+
github.com/blevesearch/bleve/v2 v2.5.3
10
11
github.com/bluekeyes/go-gitdiff v0.8.1
11
12
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
13
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
14
+
github.com/bmatcuk/doublestar/v4 v4.9.1
13
15
github.com/carlmjohnson/versioninfo v0.22.5
14
16
github.com/casbin/casbin/v2 v2.103.0
17
+
github.com/charmbracelet/log v0.4.2
15
18
github.com/cloudflare/cloudflare-go v0.115.0
16
19
github.com/cyphar/filepath-securejoin v0.4.1
17
20
github.com/dgraph-io/ristretto v0.2.0
···
29
32
github.com/hiddeco/sshsig v0.2.0
30
33
github.com/hpcloud/tail v1.0.0
31
34
github.com/ipfs/go-cid v0.5.0
32
-
github.com/lestrrat-go/jwx/v2 v2.1.6
33
35
github.com/mattn/go-sqlite3 v1.14.24
34
36
github.com/microcosm-cc/bluemonday v1.0.27
35
37
github.com/openbao/openbao/api/v2 v2.3.0
···
42
44
github.com/stretchr/testify v1.10.0
43
45
github.com/urfave/cli/v3 v3.3.3
44
46
github.com/whyrusleeping/cbor-gen v0.3.1
45
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
47
+
github.com/wyatt915/goldmark-treeblood v0.0.1
46
48
github.com/yuin/goldmark v1.7.13
47
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
48
51
golang.org/x/crypto v0.40.0
49
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
53
golang.org/x/image v0.31.0
···
58
61
dario.cat/mergo v1.0.1 // indirect
59
62
github.com/Microsoft/go-winio v0.6.2 // indirect
60
63
github.com/ProtonMail/go-crypto v1.3.0 // indirect
64
+
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
61
65
github.com/alecthomas/repr v0.4.0 // indirect
62
66
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
63
67
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
64
68
github.com/aymerick/douceur v0.2.0 // indirect
65
69
github.com/beorn7/perks v1.0.1 // indirect
66
-
github.com/bmatcuk/doublestar/v4 v4.7.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
67
88
github.com/casbin/govaluate v1.3.0 // indirect
68
89
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
69
90
github.com/cespare/xxhash/v2 v2.3.0 // indirect
70
91
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
71
92
github.com/charmbracelet/lipgloss v1.1.0 // indirect
72
-
github.com/charmbracelet/log v0.4.2 // indirect
73
93
github.com/charmbracelet/x/ansi v0.8.0 // indirect
74
94
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
75
95
github.com/charmbracelet/x/term v0.2.1 // indirect
···
78
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
79
99
github.com/containerd/log v0.1.0 // indirect
80
100
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
101
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
83
102
github.com/distribution/reference v0.6.0 // indirect
84
103
github.com/dlclark/regexp2 v1.11.5 // indirect
···
101
120
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
102
121
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
103
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
104
125
github.com/google/go-querystring v1.1.0 // indirect
105
126
github.com/gorilla/css v1.0.1 // indirect
106
127
github.com/gorilla/securecookie v1.1.2 // indirect
···
126
147
github.com/ipfs/go-log v1.0.5 // indirect
127
148
github.com/ipfs/go-log/v2 v2.6.0 // indirect
128
149
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
150
+
github.com/json-iterator/go v1.1.12 // indirect
129
151
github.com/kevinburke/ssh_config v1.2.0 // indirect
130
152
github.com/klauspost/compress v1.18.0 // indirect
131
153
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
154
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
138
155
github.com/mattn/go-isatty v0.0.20 // indirect
139
156
github.com/mattn/go-runewidth v0.0.16 // indirect
···
142
159
github.com/moby/docker-image-spec v1.3.1 // indirect
143
160
github.com/moby/sys/atomicwriter v0.1.0 // indirect
144
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
145
164
github.com/morikuni/aec v1.0.0 // indirect
146
165
github.com/mr-tron/base58 v1.2.0 // indirect
166
+
github.com/mschoch/smat v0.2.0 // indirect
147
167
github.com/muesli/termenv v0.16.0 // indirect
148
168
github.com/multiformats/go-base32 v0.1.0 // indirect
149
169
github.com/multiformats/go-base36 v0.2.0 // indirect
···
165
185
github.com/prometheus/procfs v0.16.1 // indirect
166
186
github.com/rivo/uniseg v0.4.7 // indirect
167
187
github.com/ryanuber/go-glob v1.0.0 // indirect
168
-
github.com/segmentio/asm v1.2.0 // indirect
169
188
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
170
189
github.com/spaolacci/murmur3 v1.1.0 // indirect
171
190
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
172
191
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
173
192
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
174
-
github.com/wyatt915/treeblood v0.1.15 // indirect
193
+
github.com/wyatt915/treeblood v0.1.16 // indirect
175
194
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
176
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
177
195
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
178
196
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
197
+
go.etcd.io/bbolt v1.4.0 // indirect
179
198
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
180
199
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
181
200
go.opentelemetry.io/otel v1.37.0 // indirect
+64
-21
go.sum
+64
-21
go.sum
···
9
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
10
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
11
11
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
+
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
13
+
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
12
14
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
15
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
16
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
25
27
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
26
28
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
27
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=
28
69
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
29
70
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
30
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
31
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
32
73
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
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=
35
77
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
36
78
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
37
79
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
83
125
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
84
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
85
127
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
128
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
89
129
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
90
130
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
170
210
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
171
211
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
172
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=
173
217
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
174
218
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
175
219
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
···
181
225
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
182
226
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
183
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=
184
229
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
185
230
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
186
231
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
261
306
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
262
307
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
263
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=
264
311
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
265
312
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
266
313
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
280
327
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
281
328
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
282
329
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
330
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
296
331
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
297
332
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
316
351
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
317
352
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
318
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=
319
359
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
320
360
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
321
361
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
322
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=
323
365
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
324
366
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
325
367
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
···
409
451
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
410
452
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
411
453
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
454
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
415
455
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
416
456
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
455
495
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
456
496
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
457
497
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=
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=
462
502
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
463
503
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
464
504
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
477
517
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
478
518
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
479
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=
480
522
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
481
523
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
482
524
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
···
680
722
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
681
723
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
682
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=
683
726
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
684
727
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
685
728
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+36
-61
guard/guard.go
+36
-61
guard/guard.go
···
12
12
"os/exec"
13
13
"strings"
14
14
15
-
"github.com/bluesky-social/indigo/atproto/identity"
16
15
securejoin "github.com/cyphar/filepath-securejoin"
17
16
"github.com/urfave/cli/v3"
18
-
"tangled.org/core/idresolver"
19
17
"tangled.org/core/log"
20
18
)
21
19
···
93
91
"command", sshCommand,
94
92
"client", clientIP)
95
93
94
+
// TODO: greet user with their resolved handle instead of did
96
95
if sshCommand == "" {
97
96
l.Info("access denied: no interactive shells", "user", incomingUser)
98
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
107
106
}
108
107
109
108
gitCommand := cmdParts[0]
110
-
111
-
// did:foo/repo-name or
112
-
// handle/repo-name or
113
-
// any of the above with a leading slash (/)
114
-
115
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
116
-
l.Info("command components", "components", components)
117
-
118
-
if len(components) != 2 {
119
-
l.Error("invalid repo format", "components", components)
120
-
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
121
-
os.Exit(-1)
122
-
}
123
-
124
-
didOrHandle := components[0]
125
-
identity := resolveIdentity(ctx, l, didOrHandle)
126
-
did := identity.DID.String()
127
-
repoName := components[1]
128
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
109
+
repoPath := cmdParts[1]
129
110
130
111
validCommands := map[string]bool{
131
112
"git-receive-pack": true,
···
138
119
return fmt.Errorf("access denied: invalid git command")
139
120
}
140
121
141
-
if gitCommand != "git-upload-pack" {
142
-
if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
143
-
l.Error("access denied: user not allowed",
144
-
"did", incomingUser,
145
-
"reponame", qualifiedRepoName)
146
-
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
147
-
os.Exit(-1)
148
-
}
122
+
// qualify repo path from internal server which holds the knot config
123
+
qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
124
+
if err != nil {
125
+
l.Error("failed to run guard", "err", err)
126
+
fmt.Fprintln(os.Stderr, err)
127
+
os.Exit(1)
149
128
}
150
129
151
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
152
131
153
132
l.Info("processing command",
154
133
"user", incomingUser,
155
134
"command", gitCommand,
156
-
"repo", repoName,
135
+
"repo", repoPath,
157
136
"fullPath", fullPath,
158
137
"client", clientIP)
159
138
···
177
156
gitCmd.Stdin = os.Stdin
178
157
gitCmd.Env = append(os.Environ(),
179
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
181
159
)
182
160
183
161
if err := gitCmd.Run(); err != nil {
···
189
167
l.Info("command completed",
190
168
"user", incomingUser,
191
169
"command", gitCommand,
192
-
"repo", repoName,
170
+
"repo", repoPath,
193
171
"success", true)
194
172
195
173
return nil
196
174
}
197
175
198
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
-
resolver := idresolver.DefaultResolver()
200
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
176
+
// runs guardAndQualifyRepo logic
177
+
func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
178
+
u, _ := url.Parse(endpoint + "/guard")
179
+
q := u.Query()
180
+
q.Add("user", incomingUser)
181
+
q.Add("repo", repo)
182
+
q.Add("gitCmd", gitCommand)
183
+
u.RawQuery = q.Encode()
184
+
185
+
resp, err := http.Get(u.String())
201
186
if err != nil {
202
-
l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
203
-
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
204
-
os.Exit(1)
205
-
}
206
-
if ident.Handle.IsInvalidHandle() {
207
-
l.Error("Error resolving handle", "invalid handle", didOrHandle)
208
-
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
209
-
os.Exit(1)
187
+
return "", err
210
188
}
211
-
return ident
212
-
}
189
+
defer resp.Body.Close()
213
190
214
-
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
215
-
u, _ := url.Parse(endpoint + "/push-allowed")
216
-
q := u.Query()
217
-
q.Add("user", user)
218
-
q.Add("repo", qualifiedRepoName)
219
-
u.RawQuery = q.Encode()
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
220
192
221
-
req, err := http.Get(u.String())
193
+
body, err := io.ReadAll(resp.Body)
222
194
if err != nil {
223
-
l.Error("Error verifying permissions", "error", err)
224
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
225
-
os.Exit(1)
195
+
return "", err
226
196
}
227
-
228
-
l.Info("Checking push permission",
229
-
"url", u.String(),
230
-
"status", req.Status)
197
+
text := string(body)
231
198
232
-
return req.StatusCode == http.StatusNoContent
199
+
switch resp.StatusCode {
200
+
case http.StatusOK:
201
+
return text, nil
202
+
case http.StatusForbidden:
203
+
l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
204
+
return text, errors.New("access denied: user not allowed")
205
+
default:
206
+
return "", errors.New(text)
207
+
}
233
208
}
+17
-8
idresolver/resolver.go
+17
-8
idresolver/resolver.go
···
17
17
directory identity.Directory
18
18
}
19
19
20
-
func BaseDirectory() identity.Directory {
20
+
func BaseDirectory(plcUrl string) identity.Directory {
21
21
base := identity.BaseDirectory{
22
-
PLCURL: identity.DefaultPLCURL,
22
+
PLCURL: plcUrl,
23
23
HTTPClient: http.Client{
24
24
Timeout: time.Second * 10,
25
25
Transport: &http.Transport{
···
42
42
return &base
43
43
}
44
44
45
-
func RedisDirectory(url string) (identity.Directory, error) {
45
+
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
46
46
hitTTL := time.Hour * 24
47
47
errTTL := time.Second * 30
48
48
invalidHandleTTL := time.Minute * 5
49
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
49
+
return redisdir.NewRedisDirectory(
50
+
BaseDirectory(plcUrl),
51
+
url,
52
+
hitTTL,
53
+
errTTL,
54
+
invalidHandleTTL,
55
+
10000,
56
+
)
50
57
}
51
58
52
-
func DefaultResolver() *Resolver {
59
+
func DefaultResolver(plcUrl string) *Resolver {
60
+
base := BaseDirectory(plcUrl)
61
+
cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5)
53
62
return &Resolver{
54
-
directory: identity.DefaultDirectory(),
63
+
directory: &cached,
55
64
}
56
65
}
57
66
58
-
func RedisResolver(redisUrl string) (*Resolver, error) {
59
-
directory, err := RedisDirectory(redisUrl)
67
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
+
directory, err := RedisDirectory(redisUrl, plcUrl)
60
69
if err != nil {
61
70
return nil, err
62
71
}
+38
input.css
+38
input.css
···
161
161
@apply no-underline;
162
162
}
163
163
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
166
+
}
167
+
164
168
.prose li {
165
169
@apply my-0 py-0;
166
170
}
···
241
245
details[data-callout] > summary::-webkit-details-marker {
242
246
display: none;
243
247
}
248
+
244
249
}
245
250
@layer utilities {
246
251
.error {
···
924
929
text-decoration: underline;
925
930
}
926
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
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
21
Hostname string `env:"HOSTNAME, required"`
22
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
22
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
24
Owner string `env:"OWNER, required"`
24
25
LogDids bool `env:"LOG_DIDS, default=true"`
+60
-2
knotserver/git/git.go
+60
-2
knotserver/git/git.go
···
3
3
import (
4
4
"archive/tar"
5
5
"bytes"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"io/fs"
···
12
13
"time"
13
14
14
15
"github.com/go-git/go-git/v5"
16
+
"github.com/go-git/go-git/v5/config"
15
17
"github.com/go-git/go-git/v5/plumbing"
16
18
"github.com/go-git/go-git/v5/plumbing/object"
17
19
)
18
20
19
21
var (
20
-
ErrBinaryFile = fmt.Errorf("binary file")
21
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
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")
22
27
)
23
28
24
29
type GitRepo struct {
···
188
193
defer reader.Close()
189
194
190
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
191
249
}
192
250
193
251
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4
-13
knotserver/git/tree.go
+4
-13
knotserver/git/tree.go
···
7
7
"path"
8
8
"time"
9
9
10
+
"github.com/go-git/go-git/v5/plumbing/filemode"
10
11
"github.com/go-git/go-git/v5/plumbing/object"
11
12
"tangled.org/core/types"
12
13
)
···
53
54
}
54
55
55
56
for _, e := range subtree.Entries {
56
-
mode, _ := e.Mode.ToOSFileMode()
57
57
sz, _ := subtree.Size(e.Name)
58
-
59
58
fpath := path.Join(parent, e.Name)
60
59
61
60
var lastCommit *types.LastCommitInfo
···
69
68
70
69
nts = append(nts, types.NiceTree{
71
70
Name: e.Name,
72
-
Mode: mode.String(),
73
-
IsFile: e.Mode.IsFile(),
71
+
Mode: e.Mode.String(),
74
72
Size: sz,
75
73
LastCommit: lastCommit,
76
74
})
···
126
124
default:
127
125
}
128
126
129
-
mode, err := e.Mode.ToOSFileMode()
130
-
if err != nil {
131
-
// TODO: log this
132
-
continue
133
-
}
134
-
135
127
if e.Mode.IsFile() {
136
-
err = cb(e, currentTree, root)
137
-
if errors.Is(err, TerminateWalk) {
128
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
138
129
return err
139
130
}
140
131
}
141
132
142
133
// e is a directory
143
-
if mode.IsDir() {
134
+
if e.Mode == filemode.Dir {
144
135
subtree, err := currentTree.Tree(e.Name)
145
136
if err != nil {
146
137
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+4
-8
knotserver/ingester.go
+4
-8
knotserver/ingester.go
···
16
16
"github.com/bluesky-social/jetstream/pkg/models"
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
18
"tangled.org/core/api/tangled"
19
-
"tangled.org/core/idresolver"
20
19
"tangled.org/core/knotserver/db"
21
20
"tangled.org/core/knotserver/git"
22
21
"tangled.org/core/log"
···
120
119
}
121
120
122
121
// resolve this aturi to extract the repo record
123
-
resolver := idresolver.DefaultResolver()
124
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
122
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
125
123
if err != nil || ident.Handle.IsInvalidHandle() {
126
124
return fmt.Errorf("failed to resolve handle: %w", err)
127
125
}
···
163
161
164
162
var pipeline workflow.RawPipeline
165
163
for _, e := range workflowDir {
166
-
if !e.IsFile {
164
+
if !e.IsFile() {
167
165
continue
168
166
}
169
167
···
233
231
return err
234
232
}
235
233
236
-
resolver := idresolver.DefaultResolver()
237
-
238
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
234
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
239
235
if err != nil || subjectId.Handle.IsInvalidHandle() {
240
236
return err
241
237
}
242
238
243
239
// TODO: fix this for good, we need to fetch the record here unfortunately
244
240
// resolve this aturi to extract the repo record
245
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
241
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
246
242
if err != nil || owner.Handle.IsInvalidHandle() {
247
243
return fmt.Errorf("failed to resolve handle: %w", err)
248
244
}
+146
-49
knotserver/internal.go
+146
-49
knotserver/internal.go
···
27
27
)
28
28
29
29
type InternalHandle struct {
30
-
db *db.DB
31
-
c *config.Config
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
30
+
db *db.DB
31
+
c *config.Config
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
res *idresolver.Resolver
35
36
}
36
37
37
38
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
67
68
writeJSON(w, data)
68
69
}
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
+
70
131
type PushOptions struct {
71
132
skipCi bool
72
133
verboseCi bool
···
121
182
// non-fatal
122
183
}
123
184
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
-
}
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
134
189
}
135
190
136
191
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
143
198
writeJSON(w, resp)
144
199
}
145
200
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
201
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
179
202
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
180
203
if err != nil {
···
220
243
return errors.Join(errs, h.db.InsertEvent(event, h.n))
221
244
}
222
245
223
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
246
+
func (h *InternalHandle) triggerPipeline(
247
+
clientMsgs *[]string,
248
+
line git.PostReceiveLine,
249
+
gitUserDid string,
250
+
repoDid string,
251
+
repoName string,
252
+
pushOptions PushOptions,
253
+
) error {
224
254
if pushOptions.skipCi {
225
255
return nil
226
256
}
···
247
277
248
278
var pipeline workflow.RawPipeline
249
279
for _, e := range workflowDir {
250
-
if !e.IsFile {
280
+
if !e.IsFile() {
251
281
continue
252
282
}
253
283
···
315
345
return h.db.InsertEvent(event, h.n)
316
346
}
317
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
+
318
412
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
319
413
r := chi.NewRouter()
320
414
l := log.FromContext(ctx)
321
415
l = log.SubLogger(l, "internal")
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
322
417
323
418
h := InternalHandle{
324
419
db,
···
326
421
e,
327
422
l,
328
423
n,
424
+
res,
329
425
}
330
426
331
427
r.Get("/push-allowed", h.PushAllowed)
332
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
333
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
334
431
r.Mount("/debug", middleware.Profiler())
335
432
+18
knotserver/middleware.go
+18
knotserver/middleware.go
···
33
33
)
34
34
})
35
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
36
l: log.FromContext(ctx),
37
37
jc: jc,
38
38
n: n,
39
-
resolver: idresolver.DefaultResolver(),
39
+
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
40
40
}
41
41
42
42
err := e.AddKnot(rbac.ThisServer)
···
71
71
func (h *Knot) Router() http.Handler {
72
72
r := chi.NewRouter()
73
73
74
+
r.Use(h.CORS)
74
75
r.Use(h.RequestLogger)
75
76
76
77
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+2
knotserver/xrpc/merge_check.go
+2
knotserver/xrpc/merge_check.go
+21
-2
knotserver/xrpc/repo_blob.go
+21
-2
knotserver/xrpc/repo_blob.go
···
42
42
return
43
43
}
44
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
+
45
63
contents, err := gr.RawContent(treePath)
46
64
if err != nil {
47
65
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
101
119
var encoding string
102
120
103
121
isBinary := !isTextual(mimeType)
122
+
size := int64(len(contents))
104
123
105
124
if isBinary {
106
125
content = base64.StdEncoding.EncodeToString(contents)
···
113
132
response := tangled.RepoBlob_Output{
114
133
Ref: ref,
115
134
Path: treePath,
116
-
Content: content,
135
+
Content: &content,
117
136
Encoding: &encoding,
118
-
Size: &[]int64{int64(len(contents))}[0],
137
+
Size: &size,
119
138
IsBinary: &isBinary,
120
139
}
121
140
+20
-4
knotserver/xrpc/repo_compare.go
+20
-4
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
7
8
"tangled.org/core/knotserver/git"
8
9
"tangled.org/core/types"
9
10
xrpcerr "tangled.org/core/xrpc/errors"
···
71
72
return
72
73
}
73
74
75
+
var combinedPatch []*gitdiff.File
76
+
var combinedPatchRaw string
77
+
// we need the combined patch
78
+
if len(formatPatch) >= 2 {
79
+
diffTree, err := gr.DiffTree(commit1, commit2)
80
+
if err != nil {
81
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
82
+
} else {
83
+
combinedPatch = diffTree.Diff
84
+
combinedPatchRaw = diffTree.Patch
85
+
}
86
+
}
87
+
74
88
response := types.RepoFormatPatchResponse{
75
-
Rev1: commit1.Hash.String(),
76
-
Rev2: commit2.Hash.String(),
77
-
FormatPatch: formatPatch,
78
-
Patch: rawPatch,
89
+
Rev1: commit1.Hash.String(),
90
+
Rev2: commit2.Hash.String(),
91
+
FormatPatch: formatPatch,
92
+
FormatPatchRaw: rawPatch,
93
+
CombinedPatch: combinedPatch,
94
+
CombinedPatchRaw: combinedPatchRaw,
79
95
}
80
96
81
97
writeJson(w, response)
+3
-5
knotserver/xrpc/repo_tree.go
+3
-5
knotserver/xrpc/repo_tree.go
···
67
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
68
for i, file := range files {
69
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,
70
+
Name: file.Name,
71
+
Mode: file.Mode,
72
+
Size: file.Size,
75
73
}
76
74
77
75
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
6
"type": "query",
7
7
"parameters": {
8
8
"type": "params",
9
-
"required": ["repo", "ref", "path"],
9
+
"required": [
10
+
"repo",
11
+
"ref",
12
+
"path"
13
+
],
10
14
"properties": {
11
15
"repo": {
12
16
"type": "string",
···
31
35
"encoding": "application/json",
32
36
"schema": {
33
37
"type": "object",
34
-
"required": ["ref", "path", "content"],
38
+
"required": [
39
+
"ref",
40
+
"path"
41
+
],
35
42
"properties": {
36
43
"ref": {
37
44
"type": "string",
···
48
55
"encoding": {
49
56
"type": "string",
50
57
"description": "Content encoding",
51
-
"enum": ["utf-8", "base64"]
58
+
"enum": [
59
+
"utf-8",
60
+
"base64"
61
+
]
52
62
},
53
63
"size": {
54
64
"type": "integer",
···
61
71
"mimeType": {
62
72
"type": "string",
63
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"
64
79
},
65
80
"lastCommit": {
66
81
"type": "ref",
···
90
105
},
91
106
"lastCommit": {
92
107
"type": "object",
93
-
"required": ["hash", "message", "when"],
108
+
"required": [
109
+
"hash",
110
+
"message",
111
+
"when"
112
+
],
94
113
"properties": {
95
114
"hash": {
96
115
"type": "string",
···
117
136
},
118
137
"signature": {
119
138
"type": "object",
120
-
"required": ["name", "email", "when"],
139
+
"required": [
140
+
"name",
141
+
"email",
142
+
"when"
143
+
],
121
144
"properties": {
122
145
"name": {
123
146
"type": "string",
···
131
154
"type": "string",
132
155
"format": "datetime",
133
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"
134
178
}
135
179
}
136
180
}
+15
lexicons/repo/repo.json
+15
lexicons/repo/repo.json
···
32
32
"minGraphemes": 1,
33
33
"maxGraphemes": 140
34
34
},
35
+
"website": {
36
+
"type": "string",
37
+
"format": "uri",
38
+
"description": "Any URI related to the repo"
39
+
},
40
+
"topics": {
41
+
"type": "array",
42
+
"description": "Topics related to the repo",
43
+
"items": {
44
+
"type": "string",
45
+
"minLength": 1,
46
+
"maxLength": 50
47
+
},
48
+
"maxLength": 50
49
+
},
35
50
"source": {
36
51
"type": "string",
37
52
"format": "uri",
+1
-9
lexicons/repo/tree.json
+1
-9
lexicons/repo/tree.json
···
91
91
},
92
92
"treeEntry": {
93
93
"type": "object",
94
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
94
+
"required": ["name", "mode", "size"],
95
95
"properties": {
96
96
"name": {
97
97
"type": "string",
···
104
104
"size": {
105
105
"type": "integer",
106
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
107
},
116
108
"last_commit": {
117
109
"type": "ref",
+87
-6
nix/gomod2nix.toml
+87
-6
nix/gomod2nix.toml
···
13
13
[mod."github.com/ProtonMail/go-crypto"]
14
14
version = "v1.3.0"
15
15
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/RoaringBitmap/roaring/v2"]
17
+
version = "v2.4.5"
18
+
hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA="
16
19
[mod."github.com/alecthomas/assert/v2"]
17
20
version = "v2.11.0"
18
21
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
···
38
41
[mod."github.com/beorn7/perks"]
39
42
version = "v1.0.1"
40
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="
41
101
[mod."github.com/bluekeyes/go-gitdiff"]
42
102
version = "v0.8.2"
43
103
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
···
49
109
version = "v0.0.0-20241210005130-ea96859b93d1"
50
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
51
111
[mod."github.com/bmatcuk/doublestar/v4"]
52
-
version = "v4.7.1"
53
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
112
+
version = "v4.9.1"
113
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
54
114
[mod."github.com/carlmjohnson/versioninfo"]
55
115
version = "v0.22.5"
56
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
199
259
[mod."github.com/golang/mock"]
200
260
version = "v1.6.0"
201
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="
202
268
[mod."github.com/google/go-querystring"]
203
269
version = "v1.1.0"
204
270
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
···
295
361
[mod."github.com/ipfs/go-metrics-interface"]
296
362
version = "v0.3.0"
297
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="
298
367
[mod."github.com/kevinburke/ssh_config"]
299
368
version = "v1.2.0"
300
369
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
···
352
421
[mod."github.com/moby/term"]
353
422
version = "v0.5.2"
354
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="
355
430
[mod."github.com/morikuni/aec"]
356
431
version = "v1.0.0"
357
432
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
358
433
[mod."github.com/mr-tron/base58"]
359
434
version = "v1.2.0"
360
435
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
436
+
[mod."github.com/mschoch/smat"]
437
+
version = "v0.2.0"
438
+
hash = "sha256-DZvUJXjIcta3U+zxzgU3wpoGn/V4lpBY7Xme8aQUi+E="
361
439
[mod."github.com/muesli/termenv"]
362
440
version = "v0.16.0"
363
441
hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI="
···
471
549
version = "v0.3.1"
472
550
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
473
551
[mod."github.com/wyatt915/goldmark-treeblood"]
474
-
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
475
-
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
552
+
version = "v0.0.1"
553
+
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
476
554
[mod."github.com/wyatt915/treeblood"]
477
-
version = "v0.1.15"
478
-
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
555
+
version = "v0.1.16"
556
+
hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw="
479
557
[mod."github.com/xo/terminfo"]
480
558
version = "v0.0.0-20220910002029-abceb7e1c41e"
481
559
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
···
494
572
[mod."gitlab.com/yawning/tuplehash"]
495
573
version = "v0.0.0-20230713102510-df83abbf9a02"
496
574
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
575
+
[mod."go.etcd.io/bbolt"]
576
+
version = "v1.4.0"
577
+
hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
497
578
[mod."go.opentelemetry.io/auto/sdk"]
498
579
version = "v1.1.0"
499
580
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
+285
-18
nix/modules/appview.nix
+285
-18
nix/modules/appview.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled-appview;
6
+
cfg = config.services.tangled.appview;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled-appview = {
10
+
services.tangled.appview = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
14
14
description = "Enable tangled appview";
15
15
};
16
+
16
17
package = mkOption {
17
18
type = types.package;
18
19
description = "Package to use for the appview";
19
20
};
21
+
22
+
# core configuration
20
23
port = mkOption {
21
-
type = types.int;
24
+
type = types.port;
22
25
default = 3000;
23
26
description = "Port to run the appview on";
24
27
};
25
-
cookie_secret = mkOption {
28
+
29
+
listenAddr = mkOption {
30
+
type = types.str;
31
+
default = "0.0.0.0:${toString cfg.port}";
32
+
description = "Listen address for the appview service";
33
+
};
34
+
35
+
dbPath = mkOption {
26
36
type = types.str;
27
-
default = "00000000000000000000000000000000";
28
-
description = "Cookie secret";
37
+
default = "/var/lib/appview/appview.db";
38
+
description = "Path to the SQLite database file";
39
+
};
40
+
41
+
appviewHost = mkOption {
42
+
type = types.str;
43
+
default = "https://tangled.org";
44
+
example = "https://example.com";
45
+
description = "Public host URL for the appview instance";
46
+
};
47
+
48
+
appviewName = mkOption {
49
+
type = types.str;
50
+
default = "Tangled";
51
+
description = "Display name for the appview instance";
52
+
};
53
+
54
+
dev = mkOption {
55
+
type = types.bool;
56
+
default = false;
57
+
description = "Enable development mode";
58
+
};
59
+
60
+
disallowedNicknamesFile = mkOption {
61
+
type = types.nullOr types.path;
62
+
default = null;
63
+
description = "Path to file containing disallowed nicknames";
64
+
};
65
+
66
+
# redis configuration
67
+
redis = {
68
+
addr = mkOption {
69
+
type = types.str;
70
+
default = "localhost:6379";
71
+
description = "Redis server address";
72
+
};
73
+
74
+
db = mkOption {
75
+
type = types.int;
76
+
default = 0;
77
+
description = "Redis database number";
78
+
};
79
+
};
80
+
81
+
# jetstream configuration
82
+
jetstream = {
83
+
endpoint = mkOption {
84
+
type = types.str;
85
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
86
+
description = "Jetstream WebSocket endpoint";
87
+
};
88
+
};
89
+
90
+
# knotstream consumer configuration
91
+
knotstream = {
92
+
retryInterval = mkOption {
93
+
type = types.str;
94
+
default = "60s";
95
+
description = "Initial retry interval for knotstream consumer";
96
+
};
97
+
98
+
maxRetryInterval = mkOption {
99
+
type = types.str;
100
+
default = "120m";
101
+
description = "Maximum retry interval for knotstream consumer";
102
+
};
103
+
104
+
connectionTimeout = mkOption {
105
+
type = types.str;
106
+
default = "5s";
107
+
description = "Connection timeout for knotstream consumer";
108
+
};
109
+
110
+
workerCount = mkOption {
111
+
type = types.int;
112
+
default = 64;
113
+
description = "Number of workers for knotstream consumer";
114
+
};
115
+
116
+
queueSize = mkOption {
117
+
type = types.int;
118
+
default = 100;
119
+
description = "Queue size for knotstream consumer";
120
+
};
121
+
};
122
+
123
+
# spindlestream consumer configuration
124
+
spindlestream = {
125
+
retryInterval = mkOption {
126
+
type = types.str;
127
+
default = "60s";
128
+
description = "Initial retry interval for spindlestream consumer";
129
+
};
130
+
131
+
maxRetryInterval = mkOption {
132
+
type = types.str;
133
+
default = "120m";
134
+
description = "Maximum retry interval for spindlestream consumer";
135
+
};
136
+
137
+
connectionTimeout = mkOption {
138
+
type = types.str;
139
+
default = "5s";
140
+
description = "Connection timeout for spindlestream consumer";
141
+
};
142
+
143
+
workerCount = mkOption {
144
+
type = types.int;
145
+
default = 64;
146
+
description = "Number of workers for spindlestream consumer";
147
+
};
148
+
149
+
queueSize = mkOption {
150
+
type = types.int;
151
+
default = 100;
152
+
description = "Queue size for spindlestream consumer";
153
+
};
154
+
};
155
+
156
+
# resend configuration
157
+
resend = {
158
+
sentFrom = mkOption {
159
+
type = types.str;
160
+
default = "noreply@notifs.tangled.sh";
161
+
description = "Email address to send notifications from";
162
+
};
163
+
};
164
+
165
+
# posthog configuration
166
+
posthog = {
167
+
endpoint = mkOption {
168
+
type = types.str;
169
+
default = "https://eu.i.posthog.com";
170
+
description = "PostHog API endpoint";
171
+
};
172
+
};
173
+
174
+
# camo configuration
175
+
camo = {
176
+
host = mkOption {
177
+
type = types.str;
178
+
default = "https://camo.tangled.sh";
179
+
description = "Camo proxy host URL";
180
+
};
29
181
};
182
+
183
+
# avatar configuration
184
+
avatar = {
185
+
host = mkOption {
186
+
type = types.str;
187
+
default = "https://avatar.tangled.sh";
188
+
description = "Avatar service host URL";
189
+
};
190
+
};
191
+
192
+
plc = {
193
+
url = mkOption {
194
+
type = types.str;
195
+
default = "https://plc.directory";
196
+
description = "PLC directory URL";
197
+
};
198
+
};
199
+
200
+
pds = {
201
+
host = mkOption {
202
+
type = types.str;
203
+
default = "https://tngl.sh";
204
+
description = "PDS host URL";
205
+
};
206
+
};
207
+
208
+
label = {
209
+
defaults = mkOption {
210
+
type = types.listOf types.str;
211
+
default = [
212
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
213
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
214
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
215
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
216
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
217
+
];
218
+
description = "Default label definitions";
219
+
};
220
+
221
+
goodFirstIssue = mkOption {
222
+
type = types.str;
223
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
224
+
description = "Good first issue label definition";
225
+
};
226
+
};
227
+
30
228
environmentFile = mkOption {
31
229
type = with types; nullOr path;
32
230
default = null;
33
-
example = "/etc/tangled-appview.env";
231
+
example = "/etc/appview.env";
34
232
description = ''
35
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
234
37
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
-
passed to the service without makeing them world readable in the
39
-
nix store.
40
-
235
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
236
+
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
237
+
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
238
+
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
239
+
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
240
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
241
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
242
+
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
243
+
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
244
+
without making them world readable in the nix store.
41
245
'';
42
246
};
43
247
};
44
248
};
45
249
46
250
config = mkIf cfg.enable {
47
-
systemd.services.tangled-appview = {
251
+
services.redis.servers.appview = {
252
+
enable = true;
253
+
port = 6379;
254
+
};
255
+
256
+
systemd.services.appview = {
48
257
description = "tangled appview service";
49
258
wantedBy = ["multi-user.target"];
259
+
after = ["redis-appview.service" "network-online.target"];
260
+
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
50
262
51
263
serviceConfig = {
52
-
ListenStream = "0.0.0.0:${toString cfg.port}";
264
+
Type = "simple";
53
265
ExecStart = "${cfg.package}/bin/appview";
54
266
Restart = "always";
55
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
267
+
RestartSec = "10s";
268
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
269
+
270
+
# state directory
271
+
StateDirectory = "appview";
272
+
WorkingDirectory = "/var/lib/appview";
273
+
274
+
# security hardening
275
+
NoNewPrivileges = true;
276
+
PrivateTmp = true;
277
+
ProtectSystem = "strict";
278
+
ProtectHome = true;
279
+
ReadWritePaths = ["/var/lib/appview"];
56
280
};
57
281
58
-
environment = {
59
-
TANGLED_DB_PATH = "appview.db";
60
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
-
};
282
+
environment =
283
+
{
284
+
TANGLED_DB_PATH = cfg.dbPath;
285
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
286
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
287
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
288
+
TANGLED_DEV =
289
+
if cfg.dev
290
+
then "true"
291
+
else "false";
292
+
}
293
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
294
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
295
+
}
296
+
// {
297
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
298
+
TANGLED_REDIS_DB = toString cfg.redis.db;
299
+
300
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
301
+
302
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
303
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
304
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
305
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
306
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
307
+
308
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
309
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
310
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
311
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
312
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
313
+
314
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
315
+
316
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
317
+
318
+
TANGLED_CAMO_HOST = cfg.camo.host;
319
+
320
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
321
+
322
+
TANGLED_PLC_URL = cfg.plc.url;
323
+
324
+
TANGLED_PDS_HOST = cfg.pds.host;
325
+
326
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
327
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
328
+
};
62
329
};
63
330
};
64
331
}
+76
-6
nix/modules/knot.nix
+76
-6
nix/modules/knot.nix
···
4
4
lib,
5
5
...
6
6
}: let
7
-
cfg = config.services.tangled-knot;
7
+
cfg = config.services.tangled.knot;
8
8
in
9
9
with lib; {
10
10
options = {
11
-
services.tangled-knot = {
11
+
services.tangled.knot = {
12
12
enable = mkOption {
13
13
type = types.bool;
14
14
default = false;
···
22
22
23
23
appviewEndpoint = mkOption {
24
24
type = types.str;
25
-
default = "https://tangled.sh";
25
+
default = "https://tangled.org";
26
26
description = "Appview endpoint";
27
27
};
28
28
···
51
51
description = "Path where repositories are scanned from";
52
52
};
53
53
54
+
readme = mkOption {
55
+
type = types.listOf types.str;
56
+
default = [
57
+
"README.md"
58
+
"readme.md"
59
+
"README"
60
+
"readme"
61
+
"README.markdown"
62
+
"readme.markdown"
63
+
"README.txt"
64
+
"readme.txt"
65
+
"README.rst"
66
+
"readme.rst"
67
+
"README.org"
68
+
"readme.org"
69
+
"README.asciidoc"
70
+
"readme.asciidoc"
71
+
];
72
+
description = "List of README filenames to look for (in priority order)";
73
+
};
74
+
54
75
mainBranch = mkOption {
55
76
type = types.str;
56
77
default = "main";
57
78
description = "Default branch name for repositories";
79
+
};
80
+
};
81
+
82
+
git = {
83
+
userName = mkOption {
84
+
type = types.str;
85
+
default = "Tangled";
86
+
description = "Git user name used as committer";
87
+
};
88
+
89
+
userEmail = mkOption {
90
+
type = types.str;
91
+
default = "noreply@tangled.org";
92
+
description = "Git user email used as committer";
58
93
};
59
94
};
60
95
···
107
142
108
143
hostname = mkOption {
109
144
type = types.str;
110
-
example = "knot.tangled.sh";
145
+
example = "my.knot.com";
111
146
description = "Hostname for the server (required)";
147
+
};
148
+
149
+
plcUrl = mkOption {
150
+
type = types.str;
151
+
default = "https://plc.directory";
152
+
description = "atproto PLC directory";
153
+
};
154
+
155
+
jetstreamEndpoint = mkOption {
156
+
type = types.str;
157
+
default = "wss://jetstream1.us-west.bsky.network/subscribe";
158
+
description = "Jetstream endpoint to subscribe to";
159
+
};
160
+
161
+
logDids = mkOption {
162
+
type = types.bool;
163
+
default = true;
164
+
description = "Enable logging of DIDs";
112
165
};
113
166
114
167
dev = mkOption {
···
178
231
mkdir -p "${cfg.stateDir}/.config/git"
179
232
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
233
[user]
181
-
name = Git User
182
-
email = git@example.com
234
+
name = ${cfg.git.userName}
235
+
email = ${cfg.git.userEmail}
183
236
[receive]
184
237
advertisePushOptions = true
238
+
[uploadpack]
239
+
allowFilter = true
185
240
EOF
186
241
${setMotd}
187
242
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
193
248
WorkingDirectory = cfg.stateDir;
194
249
Environment = [
195
250
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
196
252
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
197
255
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
198
256
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
199
257
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
258
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
259
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
260
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
261
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
202
262
"KNOT_SERVER_OWNER=${cfg.server.owner}"
263
+
"KNOT_SERVER_LOG_DIDS=${
264
+
if cfg.server.logDids
265
+
then "true"
266
+
else "false"
267
+
}"
268
+
"KNOT_SERVER_DEV=${
269
+
if cfg.server.dev
270
+
then "true"
271
+
else "false"
272
+
}"
203
273
];
204
274
ExecStart = "${cfg.package}/bin/knot server";
205
275
Restart = "always";
+12
-5
nix/modules/spindle.nix
+12
-5
nix/modules/spindle.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled-spindle;
6
+
cfg = config.services.tangled.spindle;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled-spindle = {
10
+
services.tangled.spindle = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
···
33
33
34
34
hostname = mkOption {
35
35
type = types.str;
36
-
example = "spindle.tangled.sh";
36
+
example = "my.spindle.com";
37
37
description = "Hostname for the server (required)";
38
38
};
39
39
40
+
plcUrl = mkOption {
41
+
type = types.str;
42
+
default = "https://plc.directory";
43
+
description = "atproto PLC directory";
44
+
};
45
+
40
46
jetstreamEndpoint = mkOption {
41
47
type = types.str;
42
48
default = "wss://jetstream1.us-west.bsky.network/subscribe";
···
92
98
pipelines = {
93
99
nixery = mkOption {
94
100
type = types.str;
95
-
default = "nixery.tangled.sh";
101
+
default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
96
102
description = "Nixery instance to use";
97
103
};
98
104
···
119
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
120
126
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
121
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
122
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
128
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
123
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
124
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
132
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
+2
nix/pkgs/appview-static-files.nix
···
5
5
lucide-src,
6
6
inter-fonts-src,
7
7
ibm-plex-mono-src,
8
+
actor-typeahead-src,
8
9
sqlite-lib,
9
10
tailwindcss,
10
11
src,
···
24
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
27
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
30
# for whatever reason (produces broken css), so we are doing this instead
29
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
10
if var == ""
11
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
12
else var;
13
+
envVarOr = name: default: let
14
+
var = builtins.getEnv name;
15
+
in
16
+
if var != ""
17
+
then var
18
+
else default;
19
+
20
+
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
21
+
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
13
22
in
14
23
nixpkgs.lib.nixosSystem {
15
24
inherit system;
···
73
82
time.timeZone = "Europe/London";
74
83
services.getty.autologinUser = "root";
75
84
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
-
services.tangled-knot = {
85
+
services.tangled.knot = {
77
86
enable = true;
78
87
motd = "Welcome to the development knot!\n";
79
88
server = {
80
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
81
-
hostname = "localhost:6000";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
+
plcUrl = plcUrl;
92
+
jetstreamEndpoint = jetstream;
82
93
listenAddr = "0.0.0.0:6000";
83
94
};
84
95
};
85
-
services.tangled-spindle = {
96
+
services.tangled.spindle = {
86
97
enable = true;
87
98
server = {
88
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
89
-
hostname = "localhost:6555";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
+
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
90
103
listenAddr = "0.0.0.0:6555";
91
104
dev = true;
92
105
queueSize = 100;
···
99
112
users = {
100
113
# So we don't have to deal with permission clashing between
101
114
# blank disk VMs and existing state
102
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
115
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
104
117
105
118
# TODO: separate spindle user
106
119
};
···
120
133
serviceConfig.PermissionsStartOnly = true;
121
134
};
122
135
in {
123
-
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
124
-
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
136
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
137
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
125
138
};
126
139
})
127
140
];
+18
-7
patchutil/patchutil.go
+18
-7
patchutil/patchutil.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
6
"log"
6
7
"os"
···
42
43
// IsPatchValid checks if the given patch string is valid.
43
44
// It performs very basic sniffing for either git-diff or git-format-patch
44
45
// header lines. For format patches, it attempts to extract and validate each one.
45
-
func IsPatchValid(patch string) bool {
46
+
var (
47
+
EmptyPatchError error = errors.New("patch is empty")
48
+
GenericPatchError error = errors.New("patch is invalid")
49
+
FormatPatchError error = errors.New("patch is not a valid format-patch")
50
+
)
51
+
52
+
func IsPatchValid(patch string) error {
46
53
if len(patch) == 0 {
47
-
return false
54
+
return EmptyPatchError
48
55
}
49
56
50
57
lines := strings.Split(patch, "\n")
51
58
if len(lines) < 2 {
52
-
return false
59
+
return EmptyPatchError
53
60
}
54
61
55
62
firstLine := strings.TrimSpace(lines[0])
···
60
67
strings.HasPrefix(firstLine, "Index: ") ||
61
68
strings.HasPrefix(firstLine, "+++ ") ||
62
69
strings.HasPrefix(firstLine, "@@ ") {
63
-
return true
70
+
return nil
64
71
}
65
72
66
73
// check if it's format-patch
···
70
77
// it's safe to say it's broken.
71
78
patches, err := ExtractPatches(patch)
72
79
if err != nil {
73
-
return false
80
+
return fmt.Errorf("%w: %w", FormatPatchError, err)
74
81
}
75
-
return len(patches) > 0
82
+
if len(patches) == 0 {
83
+
return EmptyPatchError
84
+
}
85
+
86
+
return nil
76
87
}
77
88
78
-
return false
89
+
return GenericPatchError
79
90
}
80
91
81
92
func IsFormatPatch(patch string) bool {
+13
-12
patchutil/patchutil_test.go
+13
-12
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"reflect"
5
6
"testing"
6
7
)
···
9
10
tests := []struct {
10
11
name string
11
12
patch string
12
-
expected bool
13
+
expected error
13
14
}{
14
15
{
15
16
name: `empty patch`,
16
17
patch: ``,
17
-
expected: false,
18
+
expected: EmptyPatchError,
18
19
},
19
20
{
20
21
name: `single line patch`,
21
22
patch: `single line`,
22
-
expected: false,
23
+
expected: EmptyPatchError,
23
24
},
24
25
{
25
26
name: `valid diff patch`,
···
31
32
-old line
32
33
+new line
33
34
context`,
34
-
expected: true,
35
+
expected: nil,
35
36
},
36
37
{
37
38
name: `valid patch starting with ---`,
···
41
42
-old line
42
43
+new line
43
44
context`,
44
-
expected: true,
45
+
expected: nil,
45
46
},
46
47
{
47
48
name: `valid patch starting with Index`,
···
53
54
-old line
54
55
+new line
55
56
context`,
56
-
expected: true,
57
+
expected: nil,
57
58
},
58
59
{
59
60
name: `valid patch starting with +++`,
···
63
64
-old line
64
65
+new line
65
66
context`,
66
-
expected: true,
67
+
expected: nil,
67
68
},
68
69
{
69
70
name: `valid patch starting with @@`,
···
72
73
+new line
73
74
context
74
75
`,
75
-
expected: true,
76
+
expected: nil,
76
77
},
77
78
{
78
79
name: `valid format patch`,
···
90
91
+new content
91
92
--
92
93
2.48.1`,
93
-
expected: true,
94
+
expected: nil,
94
95
},
95
96
{
96
97
name: `invalid format patch`,
97
98
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
99
From: Author <author@example.com>
99
100
This is not a valid patch format`,
100
-
expected: false,
101
+
expected: FormatPatchError,
101
102
},
102
103
{
103
104
name: `not a patch at all`,
···
105
106
just some
106
107
random text
107
108
that isn't a patch`,
108
-
expected: false,
109
+
expected: GenericPatchError,
109
110
},
110
111
}
111
112
112
113
for _, tt := range tests {
113
114
t.Run(tt.name, func(t *testing.T) {
114
115
result := IsPatchValid(tt.patch)
115
-
if result != tt.expected {
116
+
if !errors.Is(result, tt.expected) {
116
117
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
118
}
118
119
})
-26
scripts/appview.sh
-26
scripts/appview.sh
···
1
-
#!/bin/bash
2
-
3
-
# Variables
4
-
BINARY_NAME="appview"
5
-
BINARY_PATH=".bin/app"
6
-
SERVER="95.111.206.63"
7
-
USER="appview"
8
-
9
-
# SCP the binary to root's home directory
10
-
scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME"
11
-
12
-
# SSH into the server and perform the necessary operations
13
-
ssh root@$SERVER <<EOF
14
-
set -e # Exit on error
15
-
16
-
# Move binary to /usr/local/bin and set executable permissions
17
-
mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME
18
-
chmod +x /usr/local/bin/$BINARY_NAME
19
-
20
-
su appview
21
-
cd ~
22
-
./reset.sh
23
-
EOF
24
-
25
-
echo "Deployment complete."
26
-
-5
scripts/generate-jwks.sh
-5
scripts/generate-jwks.sh
+1
spindle/config/config.go
+1
spindle/config/config.go
···
13
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
14
Hostname string `env:"HOSTNAME, required"`
15
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
16
17
Dev bool `env:"DEV, default=false"`
17
18
Owner string `env:"OWNER, required"`
18
19
Secrets Secrets `env:",prefix=SECRETS_"`
+13
-3
spindle/engine/engine.go
+13
-3
spindle/engine/engine.go
···
79
79
defer cancel()
80
80
81
81
for stepIdx, step := range w.Steps {
82
+
// log start of step
82
83
if wfLogger != nil {
83
-
ctl := wfLogger.ControlWriter(stepIdx, step)
84
-
ctl.Write([]byte(step.Name()))
84
+
wfLogger.
85
+
ControlWriter(stepIdx, step, models.StepStatusStart).
86
+
Write([]byte{0})
85
87
}
86
88
87
89
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
90
+
91
+
// log end of step
92
+
if wfLogger != nil {
93
+
wfLogger.
94
+
ControlWriter(stepIdx, step, models.StepStatusEnd).
95
+
Write([]byte{0})
96
+
}
97
+
88
98
if err != nil {
89
99
if errors.Is(err, ErrTimedOut) {
90
100
dbErr := db.StatusTimeout(wid, n)
···
115
125
if err := eg.Wait(); err != nil {
116
126
l.Error("failed to run one or more workflows", "err", err)
117
127
} else {
118
-
l.Error("successfully ran full pipeline")
128
+
l.Info("successfully ran full pipeline")
119
129
}
120
130
}
+3
-3
spindle/engines/nixery/engine.go
+3
-3
spindle/engines/nixery/engine.go
···
222
222
},
223
223
ReadonlyRootfs: false,
224
224
CapDrop: []string{"ALL"},
225
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
226
226
SecurityOpt: []string{"no-new-privileges"},
227
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
228
}, nil, nil, "")
···
381
381
defer logs.Close()
382
382
383
383
_, err = stdcopy.StdCopy(
384
-
wfLogger.DataWriter("stdout"),
385
-
wfLogger.DataWriter("stderr"),
384
+
wfLogger.DataWriter(stepIdx, "stdout"),
385
+
wfLogger.DataWriter(stepIdx, "stderr"),
386
386
logs.Reader,
387
387
)
388
388
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3
-7
spindle/ingester.go
+3
-7
spindle/ingester.go
···
9
9
10
10
"tangled.org/core/api/tangled"
11
11
"tangled.org/core/eventconsumer"
12
-
"tangled.org/core/idresolver"
13
12
"tangled.org/core/rbac"
14
13
"tangled.org/core/spindle/db"
15
14
···
142
141
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
143
142
var err error
144
143
did := e.Did
145
-
resolver := idresolver.DefaultResolver()
146
144
147
145
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
146
···
190
188
}
191
189
192
190
// add collaborators to rbac
193
-
owner, err := resolver.ResolveIdent(ctx, did)
191
+
owner, err := s.res.ResolveIdent(ctx, did)
194
192
if err != nil || owner.Handle.IsInvalidHandle() {
195
193
return err
196
194
}
···
225
223
return err
226
224
}
227
225
228
-
resolver := idresolver.DefaultResolver()
229
-
230
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
231
227
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
228
return err
233
229
}
···
240
236
241
237
// TODO: get rid of this entirely
242
238
// resolve this aturi to extract the repo record
243
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
244
240
if err != nil || owner.Handle.IsInvalidHandle() {
245
241
return fmt.Errorf("failed to resolve handle: %w", err)
246
242
}
+14
-11
spindle/models/logger.go
+14
-11
spindle/models/logger.go
···
37
37
return l.file.Close()
38
38
}
39
39
40
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
41
-
// TODO: emit stream
40
+
func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer {
42
41
return &dataWriter{
43
42
logger: l,
43
+
idx: idx,
44
44
stream: stream,
45
45
}
46
46
}
47
47
48
-
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer {
49
49
return &controlWriter{
50
-
logger: l,
51
-
idx: idx,
52
-
step: step,
50
+
logger: l,
51
+
idx: idx,
52
+
step: step,
53
+
stepStatus: stepStatus,
53
54
}
54
55
}
55
56
56
57
type dataWriter struct {
57
58
logger *WorkflowLogger
59
+
idx int
58
60
stream string
59
61
}
60
62
61
63
func (w *dataWriter) Write(p []byte) (int, error) {
62
64
line := strings.TrimRight(string(p), "\r\n")
63
-
entry := NewDataLogLine(line, w.stream)
65
+
entry := NewDataLogLine(w.idx, line, w.stream)
64
66
if err := w.logger.encoder.Encode(entry); err != nil {
65
67
return 0, err
66
68
}
···
68
70
}
69
71
70
72
type controlWriter struct {
71
-
logger *WorkflowLogger
72
-
idx int
73
-
step Step
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step Step
76
+
stepStatus StepStatus
74
77
}
75
78
76
79
func (w *controlWriter) Write(_ []byte) (int, error) {
77
-
entry := NewControlLogLine(w.idx, w.step)
80
+
entry := NewControlLogLine(w.idx, w.step, w.stepStatus)
78
81
if err := w.logger.encoder.Encode(entry); err != nil {
79
82
return 0, err
80
83
}
+23
-8
spindle/models/models.go
+23
-8
spindle/models/models.go
···
4
4
"fmt"
5
5
"regexp"
6
6
"slices"
7
+
"time"
7
8
8
9
"tangled.org/core/api/tangled"
9
10
···
76
77
var (
77
78
// step log data
78
79
LogKindData LogKind = "data"
79
-
// indicates start/end of a step
80
+
// indicates status of a step
80
81
LogKindControl LogKind = "control"
81
82
)
82
83
84
+
// step status indicator in control log lines
85
+
type StepStatus string
86
+
87
+
var (
88
+
StepStatusStart StepStatus = "start"
89
+
StepStatusEnd StepStatus = "end"
90
+
)
91
+
83
92
type LogLine struct {
84
-
Kind LogKind `json:"kind"`
85
-
Content string `json:"content"`
93
+
Kind LogKind `json:"kind"`
94
+
Content string `json:"content"`
95
+
Time time.Time `json:"time"`
96
+
StepId int `json:"step_id"`
86
97
87
98
// fields if kind is "data"
88
99
Stream string `json:"stream,omitempty"`
89
100
90
101
// fields if kind is "control"
91
-
StepId int `json:"step_id,omitempty"`
92
-
StepKind StepKind `json:"step_kind,omitempty"`
93
-
StepCommand string `json:"step_command,omitempty"`
102
+
StepStatus StepStatus `json:"step_status,omitempty"`
103
+
StepKind StepKind `json:"step_kind,omitempty"`
104
+
StepCommand string `json:"step_command,omitempty"`
94
105
}
95
106
96
-
func NewDataLogLine(content, stream string) LogLine {
107
+
func NewDataLogLine(idx int, content, stream string) LogLine {
97
108
return LogLine{
98
109
Kind: LogKindData,
110
+
Time: time.Now(),
99
111
Content: content,
112
+
StepId: idx,
100
113
Stream: stream,
101
114
}
102
115
}
103
116
104
-
func NewControlLogLine(idx int, step Step) LogLine {
117
+
func NewControlLogLine(idx int, step Step, status StepStatus) LogLine {
105
118
return LogLine{
106
119
Kind: LogKindControl,
120
+
Time: time.Now(),
107
121
Content: step.Name(),
108
122
StepId: idx,
123
+
StepStatus: status,
109
124
StepKind: step.Kind(),
110
125
StepCommand: step.Command(),
111
126
}
+86
-41
spindle/server.go
+86
-41
spindle/server.go
···
49
49
vault secrets.Manager
50
50
}
51
51
52
-
func Run(ctx context.Context) error {
52
+
// New creates a new Spindle server with the provided configuration and engines.
53
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
53
54
logger := log.FromContext(ctx)
54
-
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
55
60
56
d, err := db.Make(cfg.Server.DBPath)
61
57
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
58
+
return nil, fmt.Errorf("failed to setup db: %w", err)
63
59
}
64
60
65
61
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
62
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
63
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
64
}
69
65
e.E.EnableAutoSave(true)
70
66
···
74
70
switch cfg.Server.Secrets.Provider {
75
71
case "openbao":
76
72
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
73
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
74
}
79
75
vault, err = secrets.NewOpenBaoManager(
80
76
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
78
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
79
)
84
80
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
81
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
82
}
87
83
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
84
case "sqlite", "":
89
85
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
86
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
87
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
88
}
93
89
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
90
default:
95
-
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
96
-
}
97
-
98
-
nixeryEng, err := nixery.New(ctx, cfg)
99
-
if err != nil {
100
-
return err
91
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
101
92
}
102
93
103
94
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
110
101
}
111
102
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
103
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
104
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
114
105
}
115
106
jc.AddDid(cfg.Server.Owner)
116
107
117
108
// Check if the spindle knows about any Dids;
118
109
dids, err := d.GetAllDids()
119
110
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
111
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
121
112
}
122
113
for _, d := range dids {
123
114
jc.AddDid(d)
124
115
}
125
116
126
-
resolver := idresolver.DefaultResolver()
117
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
127
118
128
-
spindle := Spindle{
119
+
spindle := &Spindle{
129
120
jc: jc,
130
121
e: e,
131
122
db: d,
132
123
l: logger,
133
124
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
125
+
engs: engines,
135
126
jq: jq,
136
127
cfg: cfg,
137
128
res: resolver,
···
140
131
141
132
err = e.AddSpindle(rbacDomain)
142
133
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
134
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
144
135
}
145
136
err = spindle.configureOwner()
146
137
if err != nil {
147
-
return err
138
+
return nil, err
148
139
}
149
140
logger.Info("owner set", "did", cfg.Server.Owner)
150
-
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
141
160
142
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
143
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
144
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
145
}
164
146
165
147
err = jc.StartJetstream(ctx, spindle.ingest())
166
148
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
149
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
168
150
}
169
151
170
152
// for each incoming sh.tangled.pipeline, we execute
···
177
159
ccfg.CursorStore = cursorStore
178
160
knownKnots, err := d.Knots()
179
161
if err != nil {
180
-
return err
162
+
return nil, err
181
163
}
182
164
for _, knot := range knownKnots {
183
165
logger.Info("adding source start", "knot", knot)
···
185
167
}
186
168
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
169
170
+
return spindle, nil
171
+
}
172
+
173
+
// DB returns the database instance.
174
+
func (s *Spindle) DB() *db.DB {
175
+
return s.db
176
+
}
177
+
178
+
// Queue returns the job queue instance.
179
+
func (s *Spindle) Queue() *queue.Queue {
180
+
return s.jq
181
+
}
182
+
183
+
// Engines returns the map of available engines.
184
+
func (s *Spindle) Engines() map[string]models.Engine {
185
+
return s.engs
186
+
}
187
+
188
+
// Vault returns the secrets manager instance.
189
+
func (s *Spindle) Vault() secrets.Manager {
190
+
return s.vault
191
+
}
192
+
193
+
// Notifier returns the notifier instance.
194
+
func (s *Spindle) Notifier() *notifier.Notifier {
195
+
return s.n
196
+
}
197
+
198
+
// Enforcer returns the RBAC enforcer instance.
199
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
200
+
return s.e
201
+
}
202
+
203
+
// Start starts the Spindle server (blocking).
204
+
func (s *Spindle) Start(ctx context.Context) error {
205
+
// starts a job queue runner in the background
206
+
s.jq.Start()
207
+
defer s.jq.Stop()
208
+
209
+
// Stop vault token renewal if it implements Stopper
210
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
211
+
defer stopper.Stop()
212
+
}
213
+
188
214
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
215
+
s.l.Info("starting knot event consumer")
216
+
s.ks.Start(ctx)
191
217
}()
192
218
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
219
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
220
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
221
+
}
222
+
223
+
func Run(ctx context.Context) error {
224
+
cfg, err := config.Load(ctx)
225
+
if err != nil {
226
+
return fmt.Errorf("failed to load config: %w", err)
227
+
}
228
+
229
+
nixeryEng, err := nixery.New(ctx, cfg)
230
+
if err != nil {
231
+
return err
232
+
}
233
+
234
+
s, err := New(ctx, cfg, map[string]models.Engine{
235
+
"nixery": nixeryEng,
236
+
})
237
+
if err != nil {
238
+
return err
239
+
}
195
240
196
-
return nil
241
+
return s.Start(ctx)
197
242
}
198
243
199
244
func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
+5
spindle/stream.go
···
213
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
214
214
return fmt.Errorf("failed to write to websocket: %w", err)
215
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
+
}
216
221
}
217
222
}
218
223
}
+8
-6
types/repo.go
+8
-6
types/repo.go
···
1
1
package types
2
2
3
3
import (
4
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
4
5
"github.com/go-git/go-git/v5/plumbing/object"
5
6
)
6
7
···
33
34
}
34
35
35
36
type RepoFormatPatchResponse struct {
36
-
Rev1 string `json:"rev1,omitempty"`
37
-
Rev2 string `json:"rev2,omitempty"`
38
-
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
39
-
MergeBase string `json:"merge_base,omitempty"` // deprecated
40
-
Patch string `json:"patch,omitempty"`
37
+
Rev1 string `json:"rev1,omitempty"`
38
+
Rev2 string `json:"rev2,omitempty"`
39
+
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
40
+
FormatPatchRaw string `json:"patch,omitempty"`
41
+
CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"`
42
+
CombinedPatchRaw string `json:"combined_patch_raw,omitempty"`
41
43
}
42
44
43
45
type RepoTreeResponse struct {
···
64
66
type Branch struct {
65
67
Reference `json:"reference"`
66
68
Commit *object.Commit `json:"commit,omitempty"`
67
-
IsDefault bool `json:"is_deafult,omitempty"`
69
+
IsDefault bool `json:"is_default,omitempty"`
68
70
}
69
71
70
72
type RepoTagsResponse struct {
+28
-5
types/tree.go
+28
-5
types/tree.go
···
4
4
"time"
5
5
6
6
"github.com/go-git/go-git/v5/plumbing"
7
+
"github.com/go-git/go-git/v5/plumbing/filemode"
7
8
)
8
9
9
10
// A nicer git tree representation.
10
11
type NiceTree struct {
11
12
// 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"`
13
+
Name string `json:"name"`
14
+
Mode string `json:"mode"`
15
+
Size int64 `json:"size"`
17
16
18
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
19
42
}
20
43
21
44
type LastCommitInfo struct {
+9
-1
workflow/compile.go
+9
-1
workflow/compile.go
···
113
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
114
cw := &tangled.Pipeline_Workflow{}
115
115
116
-
if !w.Match(compiler.Trigger) {
116
+
matched, err := w.Match(compiler.Trigger)
117
+
if err != nil {
118
+
compiler.Diagnostics.AddError(
119
+
w.Name,
120
+
fmt.Errorf("failed to execute workflow: %w", err),
121
+
)
122
+
return nil
123
+
}
124
+
if !matched {
117
125
compiler.Diagnostics.AddWarning(
118
126
w.Name,
119
127
WorkflowSkipped,
+125
workflow/compile_test.go
+125
workflow/compile_test.go
···
95
95
assert.Len(t, c.Diagnostics.Errors, 1)
96
96
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
97
97
}
98
+
99
+
func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) {
100
+
wf := Workflow{
101
+
Name: ".tangled/workflows/branch_and_tag.yml",
102
+
When: []Constraint{
103
+
{
104
+
Event: []string{"push"},
105
+
Branch: []string{"main", "develop"},
106
+
Tag: []string{"v*"},
107
+
},
108
+
},
109
+
Engine: "nixery",
110
+
}
111
+
112
+
tests := []struct {
113
+
name string
114
+
trigger tangled.Pipeline_TriggerMetadata
115
+
shouldMatch bool
116
+
expectedCount int
117
+
}{
118
+
{
119
+
name: "matches main branch",
120
+
trigger: tangled.Pipeline_TriggerMetadata{
121
+
Kind: string(TriggerKindPush),
122
+
Push: &tangled.Pipeline_PushTriggerData{
123
+
Ref: "refs/heads/main",
124
+
OldSha: strings.Repeat("0", 40),
125
+
NewSha: strings.Repeat("f", 40),
126
+
},
127
+
},
128
+
shouldMatch: true,
129
+
expectedCount: 1,
130
+
},
131
+
{
132
+
name: "matches develop branch",
133
+
trigger: tangled.Pipeline_TriggerMetadata{
134
+
Kind: string(TriggerKindPush),
135
+
Push: &tangled.Pipeline_PushTriggerData{
136
+
Ref: "refs/heads/develop",
137
+
OldSha: strings.Repeat("0", 40),
138
+
NewSha: strings.Repeat("f", 40),
139
+
},
140
+
},
141
+
shouldMatch: true,
142
+
expectedCount: 1,
143
+
},
144
+
{
145
+
name: "matches v* tag pattern",
146
+
trigger: tangled.Pipeline_TriggerMetadata{
147
+
Kind: string(TriggerKindPush),
148
+
Push: &tangled.Pipeline_PushTriggerData{
149
+
Ref: "refs/tags/v1.0.0",
150
+
OldSha: strings.Repeat("0", 40),
151
+
NewSha: strings.Repeat("f", 40),
152
+
},
153
+
},
154
+
shouldMatch: true,
155
+
expectedCount: 1,
156
+
},
157
+
{
158
+
name: "matches v* tag pattern with different version",
159
+
trigger: tangled.Pipeline_TriggerMetadata{
160
+
Kind: string(TriggerKindPush),
161
+
Push: &tangled.Pipeline_PushTriggerData{
162
+
Ref: "refs/tags/v2.5.3",
163
+
OldSha: strings.Repeat("0", 40),
164
+
NewSha: strings.Repeat("f", 40),
165
+
},
166
+
},
167
+
shouldMatch: true,
168
+
expectedCount: 1,
169
+
},
170
+
{
171
+
name: "does not match master branch",
172
+
trigger: tangled.Pipeline_TriggerMetadata{
173
+
Kind: string(TriggerKindPush),
174
+
Push: &tangled.Pipeline_PushTriggerData{
175
+
Ref: "refs/heads/master",
176
+
OldSha: strings.Repeat("0", 40),
177
+
NewSha: strings.Repeat("f", 40),
178
+
},
179
+
},
180
+
shouldMatch: false,
181
+
expectedCount: 0,
182
+
},
183
+
{
184
+
name: "does not match non-v tag",
185
+
trigger: tangled.Pipeline_TriggerMetadata{
186
+
Kind: string(TriggerKindPush),
187
+
Push: &tangled.Pipeline_PushTriggerData{
188
+
Ref: "refs/tags/release-1.0",
189
+
OldSha: strings.Repeat("0", 40),
190
+
NewSha: strings.Repeat("f", 40),
191
+
},
192
+
},
193
+
shouldMatch: false,
194
+
expectedCount: 0,
195
+
},
196
+
{
197
+
name: "does not match feature branch",
198
+
trigger: tangled.Pipeline_TriggerMetadata{
199
+
Kind: string(TriggerKindPush),
200
+
Push: &tangled.Pipeline_PushTriggerData{
201
+
Ref: "refs/heads/feature/new-feature",
202
+
OldSha: strings.Repeat("0", 40),
203
+
NewSha: strings.Repeat("f", 40),
204
+
},
205
+
},
206
+
shouldMatch: false,
207
+
expectedCount: 0,
208
+
},
209
+
}
210
+
211
+
for _, tt := range tests {
212
+
t.Run(tt.name, func(t *testing.T) {
213
+
c := Compiler{Trigger: tt.trigger}
214
+
cp := c.Compile([]Workflow{wf})
215
+
216
+
assert.Len(t, cp.Workflows, tt.expectedCount)
217
+
if tt.shouldMatch {
218
+
assert.Equal(t, wf.Name, cp.Workflows[0].Name)
219
+
}
220
+
})
221
+
}
222
+
}
+61
-19
workflow/def.go
+61
-19
workflow/def.go
···
8
8
9
9
"tangled.org/core/api/tangled"
10
10
11
+
"github.com/bmatcuk/doublestar/v4"
11
12
"github.com/go-git/go-git/v5/plumbing"
12
13
"gopkg.in/yaml.v3"
13
14
)
···
33
34
34
35
Constraint struct {
35
36
Event StringList `yaml:"event"`
36
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
37
+
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
38
+
Tag StringList `yaml:"tag"` // optional; only applies to push events
37
39
}
38
40
39
41
CloneOpts struct {
···
59
61
return strings.ReplaceAll(string(t), "_", " ")
60
62
}
61
63
64
+
// matchesPattern checks if a name matches any of the given patterns.
65
+
// Patterns can be exact matches or glob patterns using * and **.
66
+
// * matches any sequence of non-separator characters
67
+
// ** matches any sequence of characters including separators
68
+
func matchesPattern(name string, patterns []string) (bool, error) {
69
+
for _, pattern := range patterns {
70
+
matched, err := doublestar.Match(pattern, name)
71
+
if err != nil {
72
+
return false, err
73
+
}
74
+
if matched {
75
+
return true, nil
76
+
}
77
+
}
78
+
return false, nil
79
+
}
80
+
62
81
func FromFile(name string, contents []byte) (Workflow, error) {
63
82
var wf Workflow
64
83
···
74
93
}
75
94
76
95
// if any of the constraints on a workflow is true, return true
77
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
96
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
78
97
// manual triggers always run the workflow
79
98
if trigger.Manual != nil {
80
-
return true
99
+
return true, nil
81
100
}
82
101
83
102
// if not manual, run through the constraint list and see if any one matches
84
103
for _, c := range w.When {
85
-
if c.Match(trigger) {
86
-
return true
104
+
matched, err := c.Match(trigger)
105
+
if err != nil {
106
+
return false, err
107
+
}
108
+
if matched {
109
+
return true, nil
87
110
}
88
111
}
89
112
90
113
// no constraints, always run this workflow
91
114
if len(w.When) == 0 {
92
-
return true
115
+
return true, nil
93
116
}
94
117
95
-
return false
118
+
return false, nil
96
119
}
97
120
98
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
121
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
99
122
match := true
100
123
101
124
// manual triggers always pass this constraint
102
125
if trigger.Manual != nil {
103
-
return true
126
+
return true, nil
104
127
}
105
128
106
129
// apply event constraints
···
108
131
109
132
// apply branch constraints for PRs
110
133
if trigger.PullRequest != nil {
111
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
134
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
+
if err != nil {
136
+
return false, err
137
+
}
138
+
match = match && matched
112
139
}
113
140
114
141
// apply ref constraints for pushes
115
142
if trigger.Push != nil {
116
-
match = match && c.MatchRef(trigger.Push.Ref)
143
+
matched, err := c.MatchRef(trigger.Push.Ref)
144
+
if err != nil {
145
+
return false, err
146
+
}
147
+
match = match && matched
117
148
}
118
149
119
-
return match
120
-
}
121
-
122
-
func (c *Constraint) MatchBranch(branch string) bool {
123
-
return slices.Contains(c.Branch, branch)
150
+
return match, nil
124
151
}
125
152
126
-
func (c *Constraint) MatchRef(ref string) bool {
153
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
127
154
refName := plumbing.ReferenceName(ref)
155
+
shortName := refName.Short()
156
+
128
157
if refName.IsBranch() {
129
-
return slices.Contains(c.Branch, refName.Short())
158
+
return c.MatchBranch(shortName)
130
159
}
131
-
return false
160
+
161
+
if refName.IsTag() {
162
+
return c.MatchTag(shortName)
163
+
}
164
+
165
+
return false, nil
166
+
}
167
+
168
+
func (c *Constraint) MatchBranch(branch string) (bool, error) {
169
+
return matchesPattern(branch, c.Branch)
170
+
}
171
+
172
+
func (c *Constraint) MatchTag(tag string) (bool, error) {
173
+
return matchesPattern(tag, c.Tag)
132
174
}
133
175
134
176
func (c *Constraint) MatchEvent(event string) bool {
+284
-1
workflow/def_test.go
+284
-1
workflow/def_test.go
···
6
6
"github.com/stretchr/testify/assert"
7
7
)
8
8
9
-
func TestUnmarshalWorkflow(t *testing.T) {
9
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
10
10
yamlData := `
11
11
when:
12
12
- event: ["push", "pull_request"]
···
38
38
39
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
40
40
}
41
+
42
+
func TestUnmarshalWorkflowWithTags(t *testing.T) {
43
+
yamlData := `
44
+
when:
45
+
- event: ["push"]
46
+
tag: ["v*", "release-*"]`
47
+
48
+
wf, err := FromFile("test.yml", []byte(yamlData))
49
+
assert.NoError(t, err, "YAML should unmarshal without error")
50
+
51
+
assert.Len(t, wf.When, 1, "Should have one constraint")
52
+
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
53
+
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
54
+
}
55
+
56
+
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
57
+
yamlData := `
58
+
when:
59
+
- event: ["push"]
60
+
branch: ["main", "develop"]
61
+
tag: ["v*"]`
62
+
63
+
wf, err := FromFile("test.yml", []byte(yamlData))
64
+
assert.NoError(t, err, "YAML should unmarshal without error")
65
+
66
+
assert.Len(t, wf.When, 1, "Should have one constraint")
67
+
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
68
+
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
69
+
}
70
+
71
+
func TestMatchesPattern(t *testing.T) {
72
+
tests := []struct {
73
+
name string
74
+
input string
75
+
patterns []string
76
+
expected bool
77
+
}{
78
+
{"exact match", "main", []string{"main"}, true},
79
+
{"exact match in list", "develop", []string{"main", "develop"}, true},
80
+
{"no match", "feature", []string{"main", "develop"}, false},
81
+
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
82
+
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
83
+
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
84
+
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
85
+
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
86
+
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
87
+
{"double star no match", "feature/test", []string{"release/**"}, false},
88
+
{"no patterns matches nothing", "anything", []string{}, false},
89
+
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
90
+
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
91
+
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
92
+
}
93
+
94
+
for _, tt := range tests {
95
+
t.Run(tt.name, func(t *testing.T) {
96
+
result, _ := matchesPattern(tt.input, tt.patterns)
97
+
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
98
+
})
99
+
}
100
+
}
101
+
102
+
func TestConstraintMatchRef_Branches(t *testing.T) {
103
+
tests := []struct {
104
+
name string
105
+
constraint Constraint
106
+
ref string
107
+
expected bool
108
+
}{
109
+
{
110
+
name: "exact branch match",
111
+
constraint: Constraint{Branch: []string{"main"}},
112
+
ref: "refs/heads/main",
113
+
expected: true,
114
+
},
115
+
{
116
+
name: "branch glob match",
117
+
constraint: Constraint{Branch: []string{"feature-*"}},
118
+
ref: "refs/heads/feature-123",
119
+
expected: true,
120
+
},
121
+
{
122
+
name: "branch no match",
123
+
constraint: Constraint{Branch: []string{"main"}},
124
+
ref: "refs/heads/develop",
125
+
expected: false,
126
+
},
127
+
{
128
+
name: "no constraints matches nothing",
129
+
constraint: Constraint{},
130
+
ref: "refs/heads/anything",
131
+
expected: false,
132
+
},
133
+
}
134
+
135
+
for _, tt := range tests {
136
+
t.Run(tt.name, func(t *testing.T) {
137
+
result, _ := tt.constraint.MatchRef(tt.ref)
138
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
139
+
})
140
+
}
141
+
}
142
+
143
+
func TestConstraintMatchRef_Tags(t *testing.T) {
144
+
tests := []struct {
145
+
name string
146
+
constraint Constraint
147
+
ref string
148
+
expected bool
149
+
}{
150
+
{
151
+
name: "exact tag match",
152
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
153
+
ref: "refs/tags/v1.0.0",
154
+
expected: true,
155
+
},
156
+
{
157
+
name: "tag glob match",
158
+
constraint: Constraint{Tag: []string{"v*"}},
159
+
ref: "refs/tags/v1.2.3",
160
+
expected: true,
161
+
},
162
+
{
163
+
name: "tag glob with pattern",
164
+
constraint: Constraint{Tag: []string{"release-*"}},
165
+
ref: "refs/tags/release-2024",
166
+
expected: true,
167
+
},
168
+
{
169
+
name: "tag no match",
170
+
constraint: Constraint{Tag: []string{"v*"}},
171
+
ref: "refs/tags/release-1.0",
172
+
expected: false,
173
+
},
174
+
{
175
+
name: "tag not matched when only branch constraint",
176
+
constraint: Constraint{Branch: []string{"main"}},
177
+
ref: "refs/tags/v1.0.0",
178
+
expected: false,
179
+
},
180
+
}
181
+
182
+
for _, tt := range tests {
183
+
t.Run(tt.name, func(t *testing.T) {
184
+
result, _ := tt.constraint.MatchRef(tt.ref)
185
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
186
+
})
187
+
}
188
+
}
189
+
190
+
func TestConstraintMatchRef_Combined(t *testing.T) {
191
+
tests := []struct {
192
+
name string
193
+
constraint Constraint
194
+
ref string
195
+
expected bool
196
+
}{
197
+
{
198
+
name: "matches branch in combined constraint",
199
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
200
+
ref: "refs/heads/main",
201
+
expected: true,
202
+
},
203
+
{
204
+
name: "matches tag in combined constraint",
205
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
206
+
ref: "refs/tags/v1.0.0",
207
+
expected: true,
208
+
},
209
+
{
210
+
name: "no match in combined constraint",
211
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
212
+
ref: "refs/heads/develop",
213
+
expected: false,
214
+
},
215
+
{
216
+
name: "glob patterns in combined constraint - branch",
217
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
218
+
ref: "refs/heads/release-2024",
219
+
expected: true,
220
+
},
221
+
{
222
+
name: "glob patterns in combined constraint - tag",
223
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
224
+
ref: "refs/tags/v2.0.0",
225
+
expected: true,
226
+
},
227
+
}
228
+
229
+
for _, tt := range tests {
230
+
t.Run(tt.name, func(t *testing.T) {
231
+
result, _ := tt.constraint.MatchRef(tt.ref)
232
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
233
+
})
234
+
}
235
+
}
236
+
237
+
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
238
+
tests := []struct {
239
+
name string
240
+
constraint Constraint
241
+
branch string
242
+
expected bool
243
+
}{
244
+
{
245
+
name: "exact match",
246
+
constraint: Constraint{Branch: []string{"main"}},
247
+
branch: "main",
248
+
expected: true,
249
+
},
250
+
{
251
+
name: "glob match",
252
+
constraint: Constraint{Branch: []string{"feature-*"}},
253
+
branch: "feature-123",
254
+
expected: true,
255
+
},
256
+
{
257
+
name: "no match",
258
+
constraint: Constraint{Branch: []string{"main"}},
259
+
branch: "develop",
260
+
expected: false,
261
+
},
262
+
{
263
+
name: "multiple patterns with match",
264
+
constraint: Constraint{Branch: []string{"main", "release-*"}},
265
+
branch: "release-1.0",
266
+
expected: true,
267
+
},
268
+
}
269
+
270
+
for _, tt := range tests {
271
+
t.Run(tt.name, func(t *testing.T) {
272
+
result, _ := tt.constraint.MatchBranch(tt.branch)
273
+
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
274
+
})
275
+
}
276
+
}
277
+
278
+
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
279
+
tests := []struct {
280
+
name string
281
+
constraint Constraint
282
+
tag string
283
+
expected bool
284
+
}{
285
+
{
286
+
name: "exact match",
287
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
288
+
tag: "v1.0.0",
289
+
expected: true,
290
+
},
291
+
{
292
+
name: "glob match",
293
+
constraint: Constraint{Tag: []string{"v*"}},
294
+
tag: "v2.3.4",
295
+
expected: true,
296
+
},
297
+
{
298
+
name: "no match",
299
+
constraint: Constraint{Tag: []string{"v*"}},
300
+
tag: "release-1.0",
301
+
expected: false,
302
+
},
303
+
{
304
+
name: "multiple patterns with match",
305
+
constraint: Constraint{Tag: []string{"v*", "release-*"}},
306
+
tag: "release-2024",
307
+
expected: true,
308
+
},
309
+
{
310
+
name: "empty tag list matches nothing",
311
+
constraint: Constraint{Tag: []string{}},
312
+
tag: "v1.0.0",
313
+
expected: false,
314
+
},
315
+
}
316
+
317
+
for _, tt := range tests {
318
+
t.Run(tt.name, func(t *testing.T) {
319
+
result, _ := tt.constraint.MatchTag(tt.tag)
320
+
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
321
+
})
322
+
}
323
+
}