Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

Seongmin Lee and committed by
Tangled
dd1bcee8 8abac09c

+327 -160
api/tangled/cbor_gen.go

This is a binary file and will not be displayed.

api/tangled/tangledrepo.go

This is a binary file and will not be displayed.

+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 ··· 91 89 for rows.Next() { 92 90 var repo models.Repo 93 91 var createdAt string 94 - var description, source, spindle sql.NullString 92 + var description, website, topicStr, source, spindle sql.NullString 95 93 96 94 err := rows.Scan( 97 95 &repo.Id, ··· 101 99 &repo.Rkey, 102 100 &createdAt, 103 101 &description, 102 + &website, 103 + &topicStr, 104 104 &source, 105 105 &spindle, 106 106 ) ··· 115 111 } 116 112 if description.Valid { 117 113 repo.Description = description.String 114 + } 115 + if website.Valid { 116 + repo.Website = website.String 117 + } 118 + if topicStr.Valid { 119 + repo.Topics = strings.Fields(topicStr.String) 118 120 } 119 121 if source.Valid { 120 122 repo.Source = source.String ··· 366 356 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 367 357 var repo models.Repo 368 358 var nullableDescription sql.NullString 359 + var nullableWebsite sql.NullString 360 + var nullableTopicStr sql.NullString 369 361 370 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 362 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 371 363 372 364 var createdAt string 373 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 365 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 374 366 return nil, err 375 367 } 376 368 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 380 368 381 369 if nullableDescription.Valid { 382 370 repo.Description = nullableDescription.String 383 - } else { 384 - repo.Description = "" 371 + } 372 + if nullableWebsite.Valid { 373 + repo.Website = nullableWebsite.String 374 + } 375 + if nullableTopicStr.Valid { 376 + repo.Topics = strings.Fields(nullableTopicStr.String) 385 377 } 386 378 387 379 return &repo, nil 388 380 } 389 381 382 + func PutRepo(tx *sql.Tx, repo models.Repo) error { 383 + _, err := tx.Exec( 384 + `update repos 385 + set knot = ?, description = ?, website = ?, topics = ? 386 + where did = ? and rkey = ? 387 + `, 388 + repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey, 389 + ) 390 + return err 391 + } 392 + 390 393 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 391 394 _, err := tx.Exec( 392 395 `insert into repos 393 - (did, name, knot, rkey, at_uri, description, source) 394 - values (?, ?, ?, ?, ?, ?, ?)`, 395 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 396 + (did, name, knot, rkey, at_uri, description, website, topics source) 397 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 398 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 396 399 ) 397 400 if err != nil { 398 401 return fmt.Errorf("failed to insert repo: %w", err) ··· 443 416 var repos []models.Repo 444 417 445 418 rows, err := e.Query( 446 - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 447 420 from repos r 448 421 left join collaborators c on r.at_uri = c.repo_at 449 422 where (r.did = ? or c.subject_did = ?) ··· 461 434 var repo models.Repo 462 435 var createdAt string 463 436 var nullableDescription sql.NullString 437 + var nullableWebsite sql.NullString 464 438 var nullableSource sql.NullString 465 439 466 - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 440 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 467 441 if err != nil { 468 442 return nil, err 469 443 } ··· 498 470 var repo models.Repo 499 471 var createdAt string 500 472 var nullableDescription sql.NullString 473 + var nullableWebsite sql.NullString 474 + var nullableTopicStr sql.NullString 501 475 var nullableSource sql.NullString 502 476 503 477 row := e.QueryRow( 504 - `select id, did, name, knot, rkey, description, created, source 478 + `select id, did, name, knot, rkey, description, website, topics, created, source 505 479 from repos 506 480 where did = ? and name = ? and source is not null and source != ''`, 507 481 did, name, 508 482 ) 509 483 510 - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 484 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 511 485 if err != nil { 512 486 return nil, err 513 487 } 514 488 515 489 if nullableDescription.Valid { 516 490 repo.Description = nullableDescription.String 491 + } 492 + 493 + if nullableWebsite.Valid { 494 + repo.Website = nullableWebsite.String 495 + } 496 + 497 + if nullableTopicStr.Valid { 498 + repo.Topics = strings.Fields(nullableTopicStr.String) 517 499 } 518 500 519 501 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" ··· 18 17 Rkey string 19 18 Created time.Time 20 19 Description string 20 + Website string 21 + Topics []string 21 22 Spindle string 22 23 Labels []string 23 24 ··· 31 28 } 32 29 33 30 func (r *Repo) AsRecord() tangled.Repo { 34 - var source, spindle, description *string 31 + var source, spindle, description, website *string 35 32 36 33 if r.Source != "" { 37 34 source = &r.Source ··· 45 42 description = &r.Description 46 43 } 47 44 45 + if r.Website != "" { 46 + website = &r.Website 47 + } 48 + 48 49 return tangled.Repo{ 49 50 Knot: r.Knot, 50 51 Name: r.Name, 51 52 Description: description, 53 + Website: website, 54 + Topics: r.Topics, 52 55 CreatedAt: r.Created.Format(time.RFC3339), 53 56 Source: source, 54 57 Spindle: spindle, ··· 69 60 func (r Repo) DidSlashRepo() string { 70 61 p, _ := securejoin.SecureJoin(r.Did, r.Name) 71 62 return p 63 + } 64 + 65 + func (r Repo) TopicStr() string { 66 + return strings.Join(r.Topics, " ") 72 67 } 73 68 74 69 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"> ··· 41 38 </a> 42 39 </div> 43 40 </div> 44 - {{ template "repo/fragments/repoDescription" . }} 41 + <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 42 + {{ if .RepoInfo.Description }} 43 + {{ .RepoInfo.Description | description }} 44 + {{ else }} 45 + <span class="italic">this repo has no description</span> 46 + {{ end }} 47 + <a href="{{ .RepoInfo.Website }}" class="underline text-blue-800 dark:text-blue-300">{{ .RepoInfo.Website | trimUriScheme }}</a> 48 + </span> 45 49 </section> 46 50 47 51 <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" . }} ··· 14 13 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 15 14 </div> 16 15 </section> 16 + {{ end }} 17 + 18 + {{ define "baseSettings" }} 19 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none"> 20 + <fieldset 21 + class="" 22 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 23 + > 24 + <h2 class="text-sm pb-2 uppercase font-bold">Description</h2> 25 + <textarea 26 + rows="3" 27 + class="w-full mb-2" 28 + id="base-form-description" 29 + name="description" 30 + >{{ .RepoInfo.Description }}</textarea> 31 + <h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2> 32 + <input 33 + type="text" 34 + class="w-full mb-2" 35 + id="base-form-website" 36 + name="website" 37 + value="{{ .RepoInfo.Website }}" 38 + > 39 + <h2 class="text-sm pb-2 uppercase font-bold">Topics</h2> 40 + <p class="text-gray-500 dark:text-gray-400"> 41 + List of topics separated by spaces. 42 + </p> 43 + <textarea 44 + rows="2" 45 + class="w-full my-2" 46 + id="base-form-topics" 47 + name="topics" 48 + >{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea> 49 + <div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div> 50 + <div class="flex justify-end pt-2"> 51 + <button 52 + type="submit" 53 + class="btn-create flex items-center gap-2 group" 54 + > 55 + {{ i "save" "w-4 h-4" }} 56 + save 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + </div> 60 + <fieldset> 61 + </form> 17 62 {{ end }} 18 63 19 64 {{ 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 ··· 1684 1783 } 1685 1784 1686 1785 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1786 + } 1787 + 1788 + func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 1789 + l := rp.logger.With("handler", "EditBaseSettings") 1790 + 1791 + noticeId := "repo-base-settings-error" 1792 + 1793 + f, err := rp.repoResolver.Resolve(r) 1794 + if err != nil { 1795 + l.Error("failed to get repo and knot", "err", err) 1796 + w.WriteHeader(http.StatusBadRequest) 1797 + return 1798 + } 1799 + 1800 + client, err := rp.oauth.AuthorizedClient(r) 1801 + if err != nil { 1802 + l.Error("failed to get client") 1803 + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 1804 + return 1805 + } 1806 + 1807 + var ( 1808 + description = r.FormValue("description") 1809 + website = r.FormValue("website") 1810 + topicStr = r.FormValue("topics") 1811 + ) 1812 + 1813 + err = rp.validator.ValidateURI(website) 1814 + if err != nil { 1815 + l.Error("invalid uri", "err", err) 1816 + rp.pages.Notice(w, noticeId, err.Error()) 1817 + return 1818 + } 1819 + 1820 + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 1821 + if err != nil { 1822 + l.Error("invalid topics", "err", err) 1823 + rp.pages.Notice(w, noticeId, err.Error()) 1824 + return 1825 + } 1826 + l.Debug("got", "topicsStr", topicStr, "topics", topics) 1827 + 1828 + newRepo := f.Repo 1829 + newRepo.Description = description 1830 + newRepo.Website = website 1831 + newRepo.Topics = topics 1832 + record := newRepo.AsRecord() 1833 + 1834 + tx, err := rp.db.BeginTx(r.Context(), nil) 1835 + if err != nil { 1836 + l.Error("failed to begin transaction", "err", err) 1837 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 1838 + return 1839 + } 1840 + defer tx.Rollback() 1841 + 1842 + err = db.PutRepo(tx, newRepo) 1843 + if err != nil { 1844 + l.Error("failed to update repository", "err", err) 1845 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 1846 + return 1847 + } 1848 + 1849 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1850 + if err != nil { 1851 + // failed to get record 1852 + l.Error("failed to get repo record", "err", err) 1853 + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 1854 + return 1855 + } 1856 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1857 + Collection: tangled.RepoNSID, 1858 + Repo: newRepo.Did, 1859 + Rkey: newRepo.Rkey, 1860 + SwapRecord: ex.Cid, 1861 + Record: &lexutil.LexiconTypeDecoder{ 1862 + Val: &record, 1863 + }, 1864 + }) 1865 + 1866 + if err != nil { 1867 + l.Error("failed to perferom update-repo query", "err", err) 1868 + // failed to get record 1869 + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 1870 + return 1871 + } 1872 + 1873 + err = tx.Commit() 1874 + if err != nil { 1875 + l.Error("failed to commit", "err", err) 1876 + } 1877 + 1878 + rp.pages.HxRefresh(w) 1687 1879 } 1688 1880 1689 1881 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",