diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -5806,7 +5806,7 @@ return err } cw := cbg.NewCborWriter(w) - fieldCount := 8 + fieldCount := 10 if t.Description == nil { fieldCount-- @@ -5821,6 +5821,14 @@ fieldCount-- } if t.Spindle == nil { + fieldCount-- + } + + if t.Topics == nil { + fieldCount-- + } + + if t.Website == nil { fieldCount-- } @@ -5961,6 +5969,42 @@ } } } + // 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 @@ } } } + // 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 @@ 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": @@ -6204,6 +6320,27 @@ return err } 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) diff --git a/api/tangled/tangledrepo.go b/api/tangled/tangledrepo.go --- a/api/tangled/tangledrepo.go +++ b/api/tangled/tangledrepo.go @@ -30,4 +30,8 @@ // source: source of the repo 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 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1113,6 +1113,14 @@ `) 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 --- a/appview/db/notifications.go +++ b/appview/db/notifications.go @@ -134,7 +134,7 @@ query := fmt.Sprintf(` 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 @@ var repo models.Repo 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 @@ 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, ) @@ -203,6 +203,12 @@ repo.Name = rName.String } 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 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -70,6 +70,8 @@ knot, rkey, created, description, + website, + topics, source, spindle from @@ -89,7 +91,7 @@ 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 @@ &repo.Knot, &repo.Rkey, &createdAt, &description, + &website, + &topicStr, &source, &spindle, ) @@ -111,6 +115,12 @@ repo.Created = t } 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 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 @@ repo.Created = createdAtTime 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 @@ for rows.Next() { 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,22 +498,32 @@ 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 } if nullableDescription.Valid { repo.Description = nullableDescription.String + } + + if nullableWebsite.Valid { + repo.Website = nullableWebsite.String + } + + if nullableTopicStr.Valid { + repo.Topics = strings.Fields(nullableTopicStr.String) } if nullableSource.Valid { diff --git a/appview/models/repo.go b/appview/models/repo.go --- 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 @@ Knot string Rkey string Created time.Time Description string + Website string + Topics []string Spindle string Labels []string @@ -28,7 +31,7 @@ Source string } 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 @@ if r.Description != "" { 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, @@ -60,6 +69,10 @@ func (r Repo) DidSlashRepo() string { p, _ := securejoin.SecureJoin(r.Did, r.Name) return p +} + +func (r Repo) TopicStr() string { + return strings.Join(r.Topics, " ") } type RepoStats struct { diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -246,6 +246,11 @@ htmlString := p.rctx.RenderMarkdown(text) 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 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -640,18 +640,6 @@ func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 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 --- a/appview/pages/repoinfo/repoinfo.go +++ b/appview/pages/repoinfo/repoinfo.go @@ -54,6 +54,8 @@ Rkey string 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 --- 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 @@ {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
- {{ 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 --- 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 --- 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 --- 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" . }} @@ -13,6 +14,52 @@ {{ template "deleteRepo" . }}
+{{ 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 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -252,105 +252,6 @@ Pipelines: pipelines, }) } -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") @@ -1783,6 +1684,99 @@ return } 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) { diff --git a/appview/repo/router.go b/appview/repo/router.go --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -74,14 +74,9 @@ // 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 --- a/appview/reporesolver/resolver.go +++ b/appview/reporesolver/resolver.go @@ -188,6 +188,8 @@ Name: f.Name, 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 --- /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 --- /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 --- a/lexicons/repo/repo.json +++ b/lexicons/repo/repo.json @@ -32,6 +32,21 @@ "type": "string", "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",