From b6ab8222d23d4039675cb5741c32066c41de8426 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 31 Oct 2025 00:10:43 +0900 Subject: [PATCH] appview: add website and topics fields to repo Change-Id: xmmzpvkwunnvpwtmvqynmxvvzwupwxzt 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 --- api/tangled/cbor_gen.go | 139 ++++++++++++- api/tangled/tangledrepo.go | 4 + appview/db/db.go | 8 + appview/db/notifications.go | 12 +- appview/db/repos.go | 62 ++++-- appview/models/repo.go | 15 +- appview/pages/funcmap.go | 5 + appview/pages/pages.go | 12 -- appview/pages/repoinfo/repoinfo.go | 2 + appview/pages/templates/layouts/repobase.html | 12 +- .../repo/fragments/editRepoDescription.html | 11 - .../repo/fragments/repoDescription.html | 15 -- .../templates/repo/settings/general.html | 47 +++++ appview/repo/repo.go | 192 +++++++++--------- appview/repo/router.go | 7 +- appview/reporesolver/resolver.go | 2 + appview/validator/repo_topics.go | 53 +++++ appview/validator/uri.go | 17 ++ lexicons/repo/repo.json | 15 ++ 19 files changed, 469 insertions(+), 161 deletions(-) delete mode 100644 appview/pages/templates/repo/fragments/editRepoDescription.html delete mode 100644 appview/pages/templates/repo/fragments/repoDescription.html create mode 100644 appview/validator/repo_topics.go create mode 100644 appview/validator/uri.go diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 39193aae..b74dd068 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -5806,7 +5806,7 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 8 + fieldCount := 10 if t.Description == nil { fieldCount-- @@ -5824,6 +5824,14 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.Topics == nil { + fieldCount-- + } + + if t.Website == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -5961,6 +5969,42 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { } } + // t.Topics ([]string) (slice) + if t.Topics != nil { + + if len("topics") > 1000000 { + return xerrors.Errorf("Value in field \"topics\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil { + return err + } + if _, err := cw.WriteString(string("topics")); err != nil { + return err + } + + if len(t.Topics) > 8192 { + return xerrors.Errorf("Slice value in field t.Topics was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil { + return err + } + for _, v := range t.Topics { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + // t.Spindle (string) (string) if t.Spindle != nil { @@ -5993,6 +6037,38 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { } } + // t.Website (string) (string) + if t.Website != nil { + + if len("website") > 1000000 { + return xerrors.Errorf("Value in field \"website\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil { + return err + } + if _, err := cw.WriteString(string("website")); err != nil { + return err + } + + if t.Website == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Website) > 1000000 { + return xerrors.Errorf("Value in field t.Website was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Website)); err != nil { + return err + } + } + } + // t.CreatedAt (string) (string) if len("createdAt") > 1000000 { return xerrors.Errorf("Value in field \"createdAt\" was too long") @@ -6185,6 +6261,46 @@ func (t *Repo) UnmarshalCBOR(r io.Reader) (err error) { t.Source = (*string)(&sval) } } + // t.Topics ([]string) (slice) + case "topics": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Topics: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Topics = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Topics[i] = string(sval) + } + + } + } // t.Spindle (string) (string) case "spindle": @@ -6206,6 +6322,27 @@ func (t *Repo) UnmarshalCBOR(r io.Reader) (err error) { t.Spindle = (*string)(&sval) } } + // t.Website (string) (string) + case "website": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Website = (*string)(&sval) + } + } // t.CreatedAt (string) (string) case "createdAt": diff --git a/api/tangled/tangledrepo.go b/api/tangled/tangledrepo.go index 23532228..493e0cdf 100644 --- a/api/tangled/tangledrepo.go +++ b/api/tangled/tangledrepo.go @@ -30,4 +30,8 @@ type Repo struct { Source *string `json:"source,omitempty" cborgen:"source,omitempty"` // spindle: CI runner to send jobs to and receive results from Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` + // topics: Topics related to the repo + Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"` + // website: Any URI related to the repo + Website *string `json:"website,omitempty" cborgen:"website,omitempty"` } diff --git a/appview/db/db.go b/appview/db/db.go index f61490c7..9b331964 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1113,6 +1113,14 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { + _, err := tx.Exec(` + alter table repos add column website text; + alter table repos add column topics text; + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/notifications.go b/appview/db/notifications.go index 10f18ce1..429436c9 100644 --- a/appview/db/notifications.go +++ b/appview/db/notifications.go @@ -134,7 +134,7 @@ func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...fil select n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, n.read, n.created, n.repo_id, n.issue_id, n.pull_id, - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, + 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, 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, 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 from notifications n @@ -163,7 +163,7 @@ func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...fil var issue models.Issue var pull models.Pull var rId, iId, pId sql.NullInt64 - var rDid, rName, rDescription sql.NullString + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString var iDid sql.NullString var iIssueId sql.NullInt64 var iTitle sql.NullString @@ -176,7 +176,7 @@ func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...fil err := rows.Scan( &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, - &rId, &rDid, &rName, &rDescription, + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, &iId, &iDid, &iIssueId, &iTitle, &iOpen, &pId, &pOwnerDid, &pPullId, &pTitle, &pState, ) @@ -204,6 +204,12 @@ func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...fil if rDescription.Valid { repo.Description = rDescription.String } + if rWebsite.Valid { + repo.Website = rWebsite.String + } + if rTopicStr.Valid { + repo.Topics = strings.Fields(rTopicStr.String) + } nwe.Repo = &repo } diff --git a/appview/db/repos.go b/appview/db/repos.go index 27a88b66..5b2bcdd0 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -70,6 +70,8 @@ func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { rkey, created, description, + website, + topics, source, spindle from @@ -89,7 +91,7 @@ func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { for rows.Next() { var repo models.Repo var createdAt string - var description, source, spindle sql.NullString + var description, website, topicStr, source, spindle sql.NullString err := rows.Scan( &repo.Id, @@ -99,6 +101,8 @@ func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { &repo.Rkey, &createdAt, &description, + &website, + &topicStr, &source, &spindle, ) @@ -112,6 +116,12 @@ func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { if description.Valid { repo.Description = description.String } + if website.Valid { + repo.Website = website.String + } + if topicStr.Valid { + repo.Topics = strings.Fields(topicStr.String) + } if source.Valid { repo.Source = source.String } @@ -356,11 +366,13 @@ func CountRepos(e Execer, filters ...filter) (int64, error) { func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { var repo models.Repo var nullableDescription sql.NullString + var nullableWebsite sql.NullString + var nullableTopicStr sql.NullString - row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) var createdAt string - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { return nil, err } createdAtTime, _ := time.Parse(time.RFC3339, createdAt) @@ -368,19 +380,34 @@ func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { if nullableDescription.Valid { repo.Description = nullableDescription.String - } else { - repo.Description = "" + } + if nullableWebsite.Valid { + repo.Website = nullableWebsite.String + } + if nullableTopicStr.Valid { + repo.Topics = strings.Fields(nullableTopicStr.String) } return &repo, nil } +func PutRepo(tx *sql.Tx, repo models.Repo) error { + _, err := tx.Exec( + `update repos + set knot = ?, description = ?, website = ?, topics = ? + where did = ? and rkey = ? + `, + repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey, + ) + return err +} + func AddRepo(tx *sql.Tx, repo *models.Repo) error { _, err := tx.Exec( `insert into repos - (did, name, knot, rkey, at_uri, description, source) - values (?, ?, ?, ?, ?, ?, ?)`, - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, + (did, name, knot, rkey, at_uri, description, website, topics source) + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, ) if err != nil { return fmt.Errorf("failed to insert repo: %w", err) @@ -416,7 +443,7 @@ func GetForksByDid(e Execer, did string) ([]models.Repo, error) { var repos []models.Repo rows, err := e.Query( - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source from repos r left join collaborators c on r.at_uri = c.repo_at where (r.did = ? or c.subject_did = ?) @@ -434,9 +461,10 @@ func GetForksByDid(e Execer, did string) ([]models.Repo, error) { var repo models.Repo var createdAt string var nullableDescription sql.NullString + var nullableWebsite sql.NullString var nullableSource sql.NullString - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) if err != nil { return nil, err } @@ -470,16 +498,18 @@ func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { var repo models.Repo var createdAt string var nullableDescription sql.NullString + var nullableWebsite sql.NullString + var nullableTopicStr sql.NullString var nullableSource sql.NullString row := e.QueryRow( - `select id, did, name, knot, rkey, description, created, source + `select id, did, name, knot, rkey, description, website, topics, created, source from repos where did = ? and name = ? and source is not null and source != ''`, did, name, ) - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) if err != nil { return nil, err } @@ -488,6 +518,14 @@ func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { repo.Description = nullableDescription.String } + if nullableWebsite.Valid { + repo.Website = nullableWebsite.String + } + + if nullableTopicStr.Valid { + repo.Topics = strings.Fields(nullableTopicStr.String) + } + if nullableSource.Valid { repo.Source = nullableSource.String } diff --git a/appview/models/repo.go b/appview/models/repo.go index ee54b4f1..60d5ad5c 100644 --- a/appview/models/repo.go +++ b/appview/models/repo.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" @@ -17,6 +18,8 @@ type Repo struct { Rkey string Created time.Time Description string + Website string + Topics []string Spindle string Labels []string @@ -28,7 +31,7 @@ type Repo struct { } func (r *Repo) AsRecord() tangled.Repo { - var source, spindle, description *string + var source, spindle, description, website *string if r.Source != "" { source = &r.Source @@ -42,10 +45,16 @@ func (r *Repo) AsRecord() tangled.Repo { description = &r.Description } + if r.Website != "" { + website = &r.Website + } + return tangled.Repo{ Knot: r.Knot, Name: r.Name, Description: description, + Website: website, + Topics: r.Topics, CreatedAt: r.Created.Format(time.RFC3339), Source: source, Spindle: spindle, @@ -62,6 +71,10 @@ func (r Repo) DidSlashRepo() string { return p } +func (r Repo) TopicStr() string { + return strings.Join(r.Topics, " ") +} + type RepoStats struct { Language string StarCount int diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 49ca5778..55466ee9 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -243,6 +243,11 @@ func (p *Pages) funcMap() template.FuncMap { sanitized := p.rctx.SanitizeDescription(htmlString) return template.HTML(sanitized) }, + "trimUriScheme": func(text string) string { + text = strings.TrimPrefix(text, "https://") + text = strings.TrimPrefix(text, "http://") + return text + }, "isNil": func(t any) bool { // returns false for other "zero" values return t == nil diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 6f32e5a8..181d5c5a 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -639,18 +639,6 @@ func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) err return p.executePlain("repo/fragments/repoStar", w, params) } -type RepoDescriptionParams struct { - RepoInfo repoinfo.RepoInfo -} - -func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { - return p.executePlain("repo/fragments/editRepoDescription", w, params) -} - -func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { - return p.executePlain("repo/fragments/repoDescription", w, params) -} - type RepoIndexParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo diff --git a/appview/pages/repoinfo/repoinfo.go b/appview/pages/repoinfo/repoinfo.go index 2d4550ec..30e3d6fd 100644 --- a/appview/pages/repoinfo/repoinfo.go +++ b/appview/pages/repoinfo/repoinfo.go @@ -54,6 +54,8 @@ type RepoInfo struct { OwnerDid string OwnerHandle string Description string + Website string + Topics []string Knot string Spindle string RepoAt syntax.ATURI diff --git a/appview/pages/templates/layouts/repobase.html b/appview/pages/templates/layouts/repobase.html index 3299d60c..91c08fd1 100644 --- a/appview/pages/templates/layouts/repobase.html +++ b/appview/pages/templates/layouts/repobase.html @@ -17,6 +17,9 @@ {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} / {{ .RepoInfo.Name }} + {{ range $topic := .RepoInfo.Topics }} + {{ $topic }} + {{ end }}
@@ -38,7 +41,14 @@
- {{ template "repo/fragments/repoDescription" . }} + + {{ if .RepoInfo.Description }} + {{ .RepoInfo.Description | description }} + {{ else }} + this repo has no description + {{ end }} + {{ .RepoInfo.Website | trimUriScheme }} +
diff --git a/appview/pages/templates/repo/fragments/editRepoDescription.html b/appview/pages/templates/repo/fragments/editRepoDescription.html deleted file mode 100644 index 85d6b8c1..00000000 --- a/appview/pages/templates/repo/fragments/editRepoDescription.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ define "repo/fragments/editRepoDescription" }} -
- - - -
-{{ end }} diff --git a/appview/pages/templates/repo/fragments/repoDescription.html b/appview/pages/templates/repo/fragments/repoDescription.html deleted file mode 100644 index 15460d59..00000000 --- a/appview/pages/templates/repo/fragments/repoDescription.html +++ /dev/null @@ -1,15 +0,0 @@ -{{ define "repo/fragments/repoDescription" }} - - {{ if .RepoInfo.Description }} - {{ .RepoInfo.Description | description }} - {{ else }} - this repo has no description - {{ end }} - - {{ if .RepoInfo.Roles.IsOwner }} - - {{ end }} - -{{ end }} diff --git a/appview/pages/templates/repo/settings/general.html b/appview/pages/templates/repo/settings/general.html index 9afae837..132d6384 100644 --- a/appview/pages/templates/repo/settings/general.html +++ b/appview/pages/templates/repo/settings/general.html @@ -6,6 +6,7 @@ {{ template "repo/settings/fragments/sidebar" . }}
+ {{ template "baseSettings" . }} {{ template "branchSettings" . }} {{ template "defaultLabelSettings" . }} {{ template "customLabelSettings" . }} @@ -15,6 +16,52 @@
{{ end }} +{{ define "baseSettings" }} +
+
+

Description

+ +

Website URL

+ +

Topics

+

+ List of topics separated by spaces. +

+ +
+
+ +
+
+ +{{ end }} + {{ define "branchSettings" }}
diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 7ddd6357..0c33c788 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -252,105 +252,6 @@ func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { }) } -func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoDescriptionEdit") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - - user := rp.oauth.GetUser(r) - rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: f.RepoInfo(user), - }) -} - -func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "RepoDescription") - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - - repoAt := f.RepoAt() - rkey := repoAt.RecordKey().String() - if rkey == "" { - l.Error("invalid aturi for repo", "err", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - user := rp.oauth.GetUser(r) - - switch r.Method { - case http.MethodGet: - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: f.RepoInfo(user), - }) - return - case http.MethodPut: - newDescription := r.FormValue("description") - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get client") - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") - return - } - - // optimistic update - err = db.UpdateDescription(rp.db, string(repoAt), newDescription) - if err != nil { - l.Error("failed to perform update-description query", "err", err) - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") - return - } - - newRepo := f.Repo - newRepo.Description = newDescription - record := newRepo.AsRecord() - - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field - // - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) - if err != nil { - // failed to get record - rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") - return - } - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoNSID, - Repo: newRepo.Did, - Rkey: newRepo.Rkey, - SwapRecord: ex.Cid, - Record: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, - }) - - if err != nil { - l.Error("failed to perferom update-description query", "err", err) - // failed to get record - rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") - return - } - - newRepoInfo := f.RepoInfo(user) - newRepoInfo.Description = newDescription - - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: newRepoInfo, - }) - return - } -} - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoCommit") @@ -1785,6 +1686,99 @@ func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) } +func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "EditBaseSettings") + + noticeId := "repo-base-settings-error" + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get client") + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") + return + } + + var ( + description = r.FormValue("description") + website = r.FormValue("website") + topicStr = r.FormValue("topics") + ) + + err = rp.validator.ValidateURI(website) + if err != nil { + l.Error("invalid uri", "err", err) + rp.pages.Notice(w, noticeId, err.Error()) + return + } + + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) + if err != nil { + l.Error("invalid topics", "err", err) + rp.pages.Notice(w, noticeId, err.Error()) + return + } + l.Debug("got", "topicsStr", topicStr, "topics", topics) + + newRepo := f.Repo + newRepo.Description = description + newRepo.Website = website + newRepo.Topics = topics + record := newRepo.AsRecord() + + tx, err := rp.db.BeginTx(r.Context(), nil) + if err != nil { + l.Error("failed to begin transaction", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save repository information.") + return + } + defer tx.Rollback() + + err = db.PutRepo(tx, newRepo) + if err != nil { + l.Error("failed to update repository", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save repository information.") + return + } + + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) + if err != nil { + // failed to get record + l.Error("failed to get repo record", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") + return + } + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: newRepo.Did, + Rkey: newRepo.Rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + + if err != nil { + l.Error("failed to perferom update-repo query", "err", err) + // failed to get record + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") + return + } + + err = tx.Commit() + if err != nil { + l.Error("failed to commit", "err", err) + } + + rp.pages.HxRefresh(w) +} + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "SetDefaultBranch") diff --git a/appview/repo/router.go b/appview/repo/router.go index 2ec99a87..70fffa63 100644 --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -74,14 +74,9 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { // settings routes, needs auth r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(rp.oauth)) - // repo description can only be edited by owner - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { - r.Put("/", rp.RepoDescription) - r.Get("/", rp.RepoDescription) - r.Get("/edit", rp.RepoDescriptionEdit) - }) r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { r.Get("/", rp.RepoSettings) + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef) diff --git a/appview/reporesolver/resolver.go b/appview/reporesolver/resolver.go index c2f73f84..26988591 100644 --- a/appview/reporesolver/resolver.go +++ b/appview/reporesolver/resolver.go @@ -188,6 +188,8 @@ func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { Rkey: f.Repo.Rkey, RepoAt: repoAt, Description: f.Description, + Website: f.Website, + Topics: f.Topics, IsStarred: isStarred, Knot: knot, Spindle: f.Spindle, diff --git a/appview/validator/repo_topics.go b/appview/validator/repo_topics.go new file mode 100644 index 00000000..c4a9609b --- /dev/null +++ b/appview/validator/repo_topics.go @@ -0,0 +1,53 @@ +package validator + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strings" +) + +const ( + maxTopicLen = 50 + maxTopics = 20 +) + +var ( + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) +) + +// ValidateRepoTopicStr parses and validates whitespace-separated topic string. +// +// Rules: +// - topics are separated by whitespace +// - each topic may contain lowercase letters, digits, and hyphens only +// - each topic must be <= 50 characters long +// - no more than 20 topics allowed +// - duplicates are removed +func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { + topicsStr = strings.TrimSpace(topicsStr) + if topicsStr == "" { + return nil, nil + } + parts := strings.Fields(topicsStr) + if len(parts) > maxTopics { + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) + } + + topicSet := make(map[string]struct{}) + + for _, t := range parts { + if _, exists := topicSet[t]; exists { + continue + } + if len(t) > maxTopicLen { + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) + } + if !topicRE.MatchString(t) { + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) + } + topicSet[t] = struct{}{} + } + return slices.Collect(maps.Keys(topicSet)), nil +} diff --git a/appview/validator/uri.go b/appview/validator/uri.go new file mode 100644 index 00000000..ef824cf1 --- /dev/null +++ b/appview/validator/uri.go @@ -0,0 +1,17 @@ +package validator + +import ( + "fmt" + "net/url" +) + +func (v *Validator) ValidateURI(uri string) error { + parsed, err := url.Parse(uri) + if err != nil { + return fmt.Errorf("invalid uri format") + } + if parsed.Scheme == "" { + return fmt.Errorf("uri scheme missing") + } + return nil +} diff --git a/lexicons/repo/repo.json b/lexicons/repo/repo.json index e8d1728f..34a4e289 100644 --- a/lexicons/repo/repo.json +++ b/lexicons/repo/repo.json @@ -32,6 +32,21 @@ "minGraphemes": 1, "maxGraphemes": 140 }, + "website": { + "type": "string", + "format": "uri", + "description": "Any URI related to the repo" + }, + "topics": { + "type": "array", + "description": "Topics related to the repo", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "maxLength": 50 + }, "source": { "type": "string", "format": "uri", -- 2.43.0