+138
-1
api/tangled/cbor_gen.go
+138
-1
api/tangled/cbor_gen.go
···
5806
5806
}
5807
5807
5808
5808
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5809
+
fieldCount := 10
5810
5810
5811
5811
if t.Description == nil {
5812
5812
fieldCount--
···
5821
5821
}
5822
5822
5823
5823
if t.Spindle == nil {
5824
+
fieldCount--
5825
+
}
5826
+
5827
+
if t.Topics == nil {
5828
+
fieldCount--
5829
+
}
5830
+
5831
+
if t.Website == nil {
5824
5832
fieldCount--
5825
5833
}
5826
5834
···
5961
5969
}
5962
5970
}
5963
5971
5972
+
// t.Topics ([]string) (slice)
5973
+
if t.Topics != nil {
5974
+
5975
+
if len("topics") > 1000000 {
5976
+
return xerrors.Errorf("Value in field \"topics\" was too long")
5977
+
}
5978
+
5979
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil {
5980
+
return err
5981
+
}
5982
+
if _, err := cw.WriteString(string("topics")); err != nil {
5983
+
return err
5984
+
}
5985
+
5986
+
if len(t.Topics) > 8192 {
5987
+
return xerrors.Errorf("Slice value in field t.Topics was too long")
5988
+
}
5989
+
5990
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil {
5991
+
return err
5992
+
}
5993
+
for _, v := range t.Topics {
5994
+
if len(v) > 1000000 {
5995
+
return xerrors.Errorf("Value in field v was too long")
5996
+
}
5997
+
5998
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
5999
+
return err
6000
+
}
6001
+
if _, err := cw.WriteString(string(v)); err != nil {
6002
+
return err
6003
+
}
6004
+
6005
+
}
6006
+
}
6007
+
5964
6008
// t.Spindle (string) (string)
5965
6009
if t.Spindle != nil {
5966
6010
···
5993
6037
}
5994
6038
}
5995
6039
6040
+
// t.Website (string) (string)
6041
+
if t.Website != nil {
6042
+
6043
+
if len("website") > 1000000 {
6044
+
return xerrors.Errorf("Value in field \"website\" was too long")
6045
+
}
6046
+
6047
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil {
6048
+
return err
6049
+
}
6050
+
if _, err := cw.WriteString(string("website")); err != nil {
6051
+
return err
6052
+
}
6053
+
6054
+
if t.Website == nil {
6055
+
if _, err := cw.Write(cbg.CborNull); err != nil {
6056
+
return err
6057
+
}
6058
+
} else {
6059
+
if len(*t.Website) > 1000000 {
6060
+
return xerrors.Errorf("Value in field t.Website was too long")
6061
+
}
6062
+
6063
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil {
6064
+
return err
6065
+
}
6066
+
if _, err := cw.WriteString(string(*t.Website)); err != nil {
6067
+
return err
6068
+
}
6069
+
}
6070
+
}
6071
+
5996
6072
// t.CreatedAt (string) (string)
5997
6073
if len("createdAt") > 1000000 {
5998
6074
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
6261
t.Source = (*string)(&sval)
6186
6262
}
6187
6263
}
6264
+
// t.Topics ([]string) (slice)
6265
+
case "topics":
6266
+
6267
+
maj, extra, err = cr.ReadHeader()
6268
+
if err != nil {
6269
+
return err
6270
+
}
6271
+
6272
+
if extra > 8192 {
6273
+
return fmt.Errorf("t.Topics: array too large (%d)", extra)
6274
+
}
6275
+
6276
+
if maj != cbg.MajArray {
6277
+
return fmt.Errorf("expected cbor array")
6278
+
}
6279
+
6280
+
if extra > 0 {
6281
+
t.Topics = make([]string, extra)
6282
+
}
6283
+
6284
+
for i := 0; i < int(extra); i++ {
6285
+
{
6286
+
var maj byte
6287
+
var extra uint64
6288
+
var err error
6289
+
_ = maj
6290
+
_ = extra
6291
+
_ = err
6292
+
6293
+
{
6294
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6295
+
if err != nil {
6296
+
return err
6297
+
}
6298
+
6299
+
t.Topics[i] = string(sval)
6300
+
}
6301
+
6302
+
}
6303
+
}
6188
6304
// t.Spindle (string) (string)
6189
6305
case "spindle":
6190
6306
···
6204
6320
}
6205
6321
6206
6322
t.Spindle = (*string)(&sval)
6323
+
}
6324
+
}
6325
+
// t.Website (string) (string)
6326
+
case "website":
6327
+
6328
+
{
6329
+
b, err := cr.ReadByte()
6330
+
if err != nil {
6331
+
return err
6332
+
}
6333
+
if b != cbg.CborNull[0] {
6334
+
if err := cr.UnreadByte(); err != nil {
6335
+
return err
6336
+
}
6337
+
6338
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6339
+
if err != nil {
6340
+
return err
6341
+
}
6342
+
6343
+
t.Website = (*string)(&sval)
6207
6344
}
6208
6345
}
6209
6346
// t.CreatedAt (string) (string)
+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
}
+8
appview/db/db.go
+8
appview/db/db.go
···
1113
1113
return err
1114
1114
})
1115
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
+
1116
1124
return &DB{
1117
1125
db,
1118
1126
logger,
+9
-3
appview/db/notifications.go
+9
-3
appview/db/notifications.go
···
134
134
select
135
135
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
136
136
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
137
-
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
137
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics,
138
138
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
139
139
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
140
140
from notifications n
···
163
163
var issue models.Issue
164
164
var pull models.Pull
165
165
var rId, iId, pId sql.NullInt64
166
-
var rDid, rName, rDescription sql.NullString
166
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
167
167
var iDid sql.NullString
168
168
var iIssueId sql.NullInt64
169
169
var iTitle sql.NullString
···
176
176
err := rows.Scan(
177
177
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
178
178
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
179
-
&rId, &rDid, &rName, &rDescription,
179
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
180
180
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
181
181
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
182
182
)
···
203
203
}
204
204
if rDescription.Valid {
205
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)
206
212
}
207
213
nwe.Repo = &repo
208
214
}
+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 {
+14
-1
appview/models/repo.go
+14
-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 {
+5
appview/pages/funcmap.go
+5
appview/pages/funcmap.go
···
243
243
sanitized := p.rctx.SanitizeDescription(htmlString)
244
244
return template.HTML(sanitized)
245
245
},
246
+
"trimUriScheme": func(text string) string {
247
+
text = strings.TrimPrefix(text, "https://")
248
+
text = strings.TrimPrefix(text, "http://")
249
+
return text
250
+
},
246
251
"isNil": func(t any) bool {
247
252
// returns false for other "zero" values
248
253
return t == nil
-12
appview/pages/pages.go
-12
appview/pages/pages.go
···
639
639
return p.executePlain("repo/fragments/repoStar", w, params)
640
640
}
641
641
642
-
type RepoDescriptionParams struct {
643
-
RepoInfo repoinfo.RepoInfo
644
-
}
645
-
646
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
647
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
648
-
}
649
-
650
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
651
-
return p.executePlain("repo/fragments/repoDescription", w, params)
652
-
}
653
-
654
642
type RepoIndexParams struct {
655
643
LoggedInUser *oauth.User
656
644
RepoInfo repoinfo.RepoInfo
+2
appview/pages/repoinfo/repoinfo.go
+2
appview/pages/repoinfo/repoinfo.go
+11
-1
appview/pages/templates/layouts/repobase.html
+11
-1
appview/pages/templates/layouts/repobase.html
···
17
17
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
18
18
<span class="select-none">/</span>
19
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
+
{{ range $topic := .RepoInfo.Topics }}
21
+
<span class="font-normal normal-case text-sm rounded py-1 px-2 bg-white dark:bg-gray-800">{{ $topic }}</span>
22
+
{{ end }}
20
23
</div>
21
24
22
25
<div class="flex items-center gap-2 z-auto">
···
38
41
</a>
39
42
</div>
40
43
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
44
+
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
45
+
{{ if .RepoInfo.Description }}
46
+
{{ .RepoInfo.Description | description }}
47
+
{{ else }}
48
+
<span class="italic">this repo has no description</span>
49
+
{{ end }}
50
+
<a href="{{ .RepoInfo.Website }}" class="underline text-blue-800 dark:text-blue-300">{{ .RepoInfo.Website | trimUriScheme }}</a>
51
+
</span>
42
52
</section>
43
53
44
54
<section class="w-full flex flex-col" >
-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 }}
-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 }}
+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" }}
+93
-99
appview/repo/repo.go
+93
-99
appview/repo/repo.go
···
252
252
})
253
253
}
254
254
255
-
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
256
-
l := rp.logger.With("handler", "RepoDescriptionEdit")
257
-
258
-
f, err := rp.repoResolver.Resolve(r)
259
-
if err != nil {
260
-
l.Error("failed to get repo and knot", "err", err)
261
-
w.WriteHeader(http.StatusBadRequest)
262
-
return
263
-
}
264
-
265
-
user := rp.oauth.GetUser(r)
266
-
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
267
-
RepoInfo: f.RepoInfo(user),
268
-
})
269
-
}
270
-
271
-
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
272
-
l := rp.logger.With("handler", "RepoDescription")
273
-
274
-
f, err := rp.repoResolver.Resolve(r)
275
-
if err != nil {
276
-
l.Error("failed to get repo and knot", "err", err)
277
-
w.WriteHeader(http.StatusBadRequest)
278
-
return
279
-
}
280
-
281
-
repoAt := f.RepoAt()
282
-
rkey := repoAt.RecordKey().String()
283
-
if rkey == "" {
284
-
l.Error("invalid aturi for repo", "err", err)
285
-
w.WriteHeader(http.StatusInternalServerError)
286
-
return
287
-
}
288
-
289
-
user := rp.oauth.GetUser(r)
290
-
291
-
switch r.Method {
292
-
case http.MethodGet:
293
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
294
-
RepoInfo: f.RepoInfo(user),
295
-
})
296
-
return
297
-
case http.MethodPut:
298
-
newDescription := r.FormValue("description")
299
-
client, err := rp.oauth.AuthorizedClient(r)
300
-
if err != nil {
301
-
l.Error("failed to get client")
302
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
303
-
return
304
-
}
305
-
306
-
// optimistic update
307
-
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
308
-
if err != nil {
309
-
l.Error("failed to perform update-description query", "err", err)
310
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
311
-
return
312
-
}
313
-
314
-
newRepo := f.Repo
315
-
newRepo.Description = newDescription
316
-
record := newRepo.AsRecord()
317
-
318
-
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
319
-
//
320
-
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
321
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
322
-
if err != nil {
323
-
// failed to get record
324
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
325
-
return
326
-
}
327
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
328
-
Collection: tangled.RepoNSID,
329
-
Repo: newRepo.Did,
330
-
Rkey: newRepo.Rkey,
331
-
SwapRecord: ex.Cid,
332
-
Record: &lexutil.LexiconTypeDecoder{
333
-
Val: &record,
334
-
},
335
-
})
336
-
337
-
if err != nil {
338
-
l.Error("failed to perferom update-description query", "err", err)
339
-
// failed to get record
340
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
341
-
return
342
-
}
343
-
344
-
newRepoInfo := f.RepoInfo(user)
345
-
newRepoInfo.Description = newDescription
346
-
347
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
348
-
RepoInfo: newRepoInfo,
349
-
})
350
-
return
351
-
}
352
-
}
353
-
354
255
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
355
256
l := rp.logger.With("handler", "RepoCommit")
356
257
···
1783
1684
}
1784
1685
1785
1686
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1687
+
}
1688
+
1689
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
1690
+
l := rp.logger.With("handler", "EditBaseSettings")
1691
+
1692
+
noticeId := "repo-base-settings-error"
1693
+
1694
+
f, err := rp.repoResolver.Resolve(r)
1695
+
if err != nil {
1696
+
l.Error("failed to get repo and knot", "err", err)
1697
+
w.WriteHeader(http.StatusBadRequest)
1698
+
return
1699
+
}
1700
+
1701
+
client, err := rp.oauth.AuthorizedClient(r)
1702
+
if err != nil {
1703
+
l.Error("failed to get client")
1704
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
1705
+
return
1706
+
}
1707
+
1708
+
var (
1709
+
description = r.FormValue("description")
1710
+
website = r.FormValue("website")
1711
+
topicStr = r.FormValue("topics")
1712
+
)
1713
+
1714
+
err = rp.validator.ValidateURI(website)
1715
+
if err != nil {
1716
+
l.Error("invalid uri", "err", err)
1717
+
rp.pages.Notice(w, noticeId, err.Error())
1718
+
return
1719
+
}
1720
+
1721
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
1722
+
if err != nil {
1723
+
l.Error("invalid topics", "err", err)
1724
+
rp.pages.Notice(w, noticeId, err.Error())
1725
+
return
1726
+
}
1727
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
1728
+
1729
+
newRepo := f.Repo
1730
+
newRepo.Description = description
1731
+
newRepo.Website = website
1732
+
newRepo.Topics = topics
1733
+
record := newRepo.AsRecord()
1734
+
1735
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1736
+
if err != nil {
1737
+
l.Error("failed to begin transaction", "err", err)
1738
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1739
+
return
1740
+
}
1741
+
defer tx.Rollback()
1742
+
1743
+
err = db.PutRepo(tx, newRepo)
1744
+
if err != nil {
1745
+
l.Error("failed to update repository", "err", err)
1746
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1747
+
return
1748
+
}
1749
+
1750
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1751
+
if err != nil {
1752
+
// failed to get record
1753
+
l.Error("failed to get repo record", "err", err)
1754
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
1755
+
return
1756
+
}
1757
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1758
+
Collection: tangled.RepoNSID,
1759
+
Repo: newRepo.Did,
1760
+
Rkey: newRepo.Rkey,
1761
+
SwapRecord: ex.Cid,
1762
+
Record: &lexutil.LexiconTypeDecoder{
1763
+
Val: &record,
1764
+
},
1765
+
})
1766
+
1767
+
if err != nil {
1768
+
l.Error("failed to perferom update-repo query", "err", err)
1769
+
// failed to get record
1770
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
1771
+
return
1772
+
}
1773
+
1774
+
err = tx.Commit()
1775
+
if err != nil {
1776
+
l.Error("failed to commit", "err", err)
1777
+
}
1778
+
1779
+
rp.pages.HxRefresh(w)
1786
1780
}
1787
1781
1788
1782
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+1
-6
appview/repo/router.go
+1
-6
appview/repo/router.go
···
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
78
r.Get("/", rp.RepoSettings)
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)
+2
appview/reporesolver/resolver.go
+2
appview/reporesolver/resolver.go
+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
+
}
+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",