forked from tangled.org/core
Monorepo for Tangled

appview: add website and topics fields to repo

Removed description edit UI / endpoints and put unified base settings
form in repository settings page.
This form is restricted to `repo:owner` permission same as before.

The internal model of topics is an array but they are stored/edited as
single string where each topics are joined with whitespaces.

Having a dedicated topics table with M:M relationship to the repo seems
a bit overkill considering we will have external search indexer anyway.

Signed-off-by: Seongmin Lee <git@boltless.me>

authored by boltless.me and committed by Tangled dd1bcee8 8abac09c

Changed files
+469 -161
api
appview
lexicons
repo
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 246 246 sanitized := p.rctx.SanitizeDescription(htmlString) 247 247 return template.HTML(sanitized) 248 248 }, 249 + "trimUriScheme": func(text string) string { 250 + text = strings.TrimPrefix(text, "https://") 251 + text = strings.TrimPrefix(text, "http://") 252 + return text 253 + }, 249 254 "isNil": func(t any) bool { 250 255 // returns false for other "zero" values 251 256 return t == nil
-12
appview/pages/pages.go
··· 640 640 return p.executePlain("repo/fragments/repoStar", w, params) 641 641 } 642 642 643 - type RepoDescriptionParams struct { 644 - RepoInfo repoinfo.RepoInfo 645 - } 646 - 647 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 648 - return p.executePlain("repo/fragments/editRepoDescription", w, params) 649 - } 650 - 651 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 652 - return p.executePlain("repo/fragments/repoDescription", w, params) 653 - } 654 - 655 643 type RepoIndexParams struct { 656 644 LoggedInUser *oauth.User 657 645 RepoInfo repoinfo.RepoInfo
+2
appview/pages/repoinfo/repoinfo.go
··· 54 54 OwnerDid string 55 55 OwnerHandle string 56 56 Description string 57 + Website string 58 + Topics []string 57 59 Knot string 58 60 Spindle string 59 61 RepoAt syntax.ATURI
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 188 188 Rkey: f.Repo.Rkey, 189 189 RepoAt: repoAt, 190 190 Description: f.Description, 191 + Website: f.Website, 192 + Topics: f.Topics, 191 193 IsStarred: isStarred, 192 194 Knot: knot, 193 195 Spindle: f.Spindle,
+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
··· 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
··· 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",