appview: add website and topics fields to repo #751

merged
opened by boltless.me targeting master from push-xmmzpvkwunnv

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

Changed files
+469 -161
api
appview
lexicons
repo
+138 -1
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 8
+
fieldCount := 10
if t.Description == nil {
fieldCount--
···
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
···
+
// 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 {
···
+
// 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")
···
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":
···
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":
+4
api/tangled/tangledrepo.go
···
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"`
}
+8
appview/db/db.go
···
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,
+9 -3
appview/db/notifications.go
···
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
···
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
···
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,
)
···
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
}
+50 -12
appview/db/repos.go
···
rkey,
created,
description,
+
website,
+
topics,
source,
spindle
from
···
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,
···
&repo.Rkey,
&createdAt,
&description,
+
&website,
+
&topicStr,
&source,
&spindle,
)
···
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
}
···
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)
···
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)
···
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 = ?)
···
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
}
···
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
}
···
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
}
+14 -1
appview/models/repo.go
···
import (
"fmt"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
Rkey string
Created time.Time
Description string
+
Website string
+
Topics []string
Spindle string
Labels []string
···
}
func (r *Repo) AsRecord() tangled.Repo {
-
var source, spindle, description *string
+
var source, spindle, description, website *string
if r.Source != "" {
source = &r.Source
···
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,
···
return p
}
+
func (r Repo) TopicStr() string {
+
return strings.Join(r.Topics, " ")
+
}
+
type RepoStats struct {
Language string
StarCount int
+5
appview/pages/funcmap.go
···
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
-12
appview/pages/pages.go
···
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
+2
appview/pages/repoinfo/repoinfo.go
···
OwnerDid string
OwnerHandle string
Description string
+
Website string
+
Topics []string
Knot string
Spindle string
RepoAt syntax.ATURI
+11 -1
appview/pages/templates/layouts/repobase.html
···
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
<span class="select-none">/</span>
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
+
{{ range $topic := .RepoInfo.Topics }}
+
<span class="font-normal normal-case text-sm rounded py-1 px-2 bg-white dark:bg-gray-800">{{ $topic }}</span>
+
{{ end }}
</div>
<div class="flex items-center gap-2 z-auto">
···
</a>
</div>
</div>
-
{{ template "repo/fragments/repoDescription" . }}
+
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
+
{{ if .RepoInfo.Description }}
+
{{ .RepoInfo.Description | description }}
+
{{ else }}
+
<span class="italic">this repo has no description</span>
+
{{ end }}
+
<a href="{{ .RepoInfo.Website }}" class="underline text-blue-800 dark:text-blue-300">{{ .RepoInfo.Website | trimUriScheme }}</a>
+
</span>
</section>
<section class="w-full flex flex-col" >
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
-
{{ define "repo/fragments/editRepoDescription" }}
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
-
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
-
{{ i "check" "w-3 h-3" }} save
-
</button>
-
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
-
{{ i "x" "w-3 h-3" }} cancel
-
</button>
-
</form>
-
{{ end }}
-15
appview/pages/templates/repo/fragments/repoDescription.html
···
-
{{ define "repo/fragments/repoDescription" }}
-
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
-
{{ if .RepoInfo.Description }}
-
{{ .RepoInfo.Description | description }}
-
{{ else }}
-
<span class="italic">this repo has no description</span>
-
{{ end }}
-
-
{{ if .RepoInfo.Roles.IsOwner }}
-
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
-
{{ i "pencil" "w-3 h-3" }}
-
</button>
-
{{ end }}
-
</span>
-
{{ end }}
+47
appview/pages/templates/repo/settings/general.html
···
{{ template "repo/settings/fragments/sidebar" . }}
</div>
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
+
{{ template "baseSettings" . }}
{{ template "branchSettings" . }}
{{ template "defaultLabelSettings" . }}
{{ template "customLabelSettings" . }}
···
</section>
{{ end }}
+
{{ define "baseSettings" }}
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none">
+
<fieldset
+
class=""
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
+
>
+
<h2 class="text-sm pb-2 uppercase font-bold">Description</h2>
+
<textarea
+
rows="3"
+
class="w-full mb-2"
+
id="base-form-description"
+
name="description"
+
>{{ .RepoInfo.Description }}</textarea>
+
<h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2>
+
<input
+
type="text"
+
class="w-full mb-2"
+
id="base-form-website"
+
name="website"
+
value="{{ .RepoInfo.Website }}"
+
>
+
<h2 class="text-sm pb-2 uppercase font-bold">Topics</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
List of topics separated by spaces.
+
</p>
+
<textarea
+
rows="2"
+
class="w-full my-2"
+
id="base-form-topics"
+
name="topics"
+
>{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea>
+
<div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div>
+
<div class="flex justify-end pt-2">
+
<button
+
type="submit"
+
class="btn-create flex items-center gap-2 group"
+
>
+
{{ i "save" "w-4 h-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
<fieldset>
+
</form>
+
{{ end }}
+
{{ define "branchSettings" }}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div class="col-span-1 md:col-span-2">
+93 -99
appview/repo/repo.go
···
})
}
-
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")
···
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")
+1 -6
appview/repo/router.go
···
// 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)
+2
appview/reporesolver/resolver.go
···
Rkey: f.Repo.Rkey,
RepoAt: repoAt,
Description: f.Description,
+
Website: f.Website,
+
Topics: f.Topics,
IsStarred: isStarred,
Knot: knot,
Spindle: f.Spindle,
+53
appview/validator/repo_topics.go
···
+
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
+
}
+17
appview/validator/uri.go
···
+
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
+
}
+15
lexicons/repo/repo.json
···
"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",