forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

mvp: edit repo descriptions

- implemented in a backwards compatible way, introduces migrations and
updates an existing lexicon
- newly added field is not marked required, so we should still be able
to index old repo-create events properly

Changed files
+342 -71
api
tangled
appview
knotserver
lexicons
rbac
+1
api/tangled/tangledrepo.go
···
type Repo struct {
LexiconTypeID string `json:"$type,const=sh.tangled.repo" cborgen:"$type,const=sh.tangled.repo"`
AddedAt *string `json:"addedAt,omitempty" cborgen:"addedAt,omitempty"`
+
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
// knot: knot where the repo was created
Knot string `json:"knot" cborgen:"knot"`
// name: name of the repo
+1 -1
appview/auth/auth.go
···
clientSession.Values[appview.SessionPds] = pdsEndpoint
clientSession.Values[appview.SessionAccessJwt] = atSessionish.GetAccessJwt()
clientSession.Values[appview.SessionRefreshJwt] = atSessionish.GetRefreshJwt()
-
clientSession.Values[appview.SessionExpiry] = time.Now().Add(time.Second * 5).Format(time.RFC3339)
+
clientSession.Values[appview.SessionExpiry] = time.Now().Add(time.Minute * 15).Format(time.RFC3339)
clientSession.Values[appview.SessionAuthenticated] = true
return clientSession.Save(r, w)
}
+53 -18
appview/db/repos.go
···
)
type Repo struct {
-
Did string
-
Name string
-
Knot string
-
Rkey string
-
Created time.Time
-
AtUri string
+
Did string
+
Name string
+
Knot string
+
Rkey string
+
Created time.Time
+
AtUri string
+
Description string
}
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
var repos []Repo
rows, err := e.Query(
-
`select did, name, knot, rkey, created
+
`select did, name, knot, rkey, description, created
from repos
order by created desc
limit ?
···
for rows.Next() {
var repo Repo
-
err := scanRepo(rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Created)
+
err := scanRepo(
+
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created,
+
)
if err != nil {
return nil, err
}
···
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
var repos []Repo
-
rows, err := e.Query(`select did, name, knot, rkey, created from repos where did = ?`, did)
+
rows, err := e.Query(`select did, name, knot, rkey, description, created from repos where did = ?`, did)
if err != nil {
return nil, err
}
···
for rows.Next() {
var repo Repo
-
err := scanRepo(rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Created)
+
err := scanRepo(rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created)
if err != nil {
return nil, err
}
···
func GetRepo(e Execer, did, name string) (*Repo, error) {
var repo Repo
+
var nullableDescription sql.NullString
-
row := e.QueryRow(`select did, name, knot, created, at_uri from repos where did = ? and name = ?`, did, name)
+
row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where did = ? and name = ?`, did, name)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri); err != nil {
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
repo.Created = createdAtTime
+
if nullableDescription.Valid {
+
repo.Description = nullableDescription.String
+
} else {
+
repo.Description = ""
+
}
+
return &repo, nil
}
func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
var repo Repo
+
var nullableDescription sql.NullString
-
row := e.QueryRow(`select did, name, knot, created, at_uri from repos where at_uri = ?`, atUri)
+
row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri); err != nil {
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
repo.Created = createdAtTime
+
+
if nullableDescription.Valid {
+
repo.Description = nullableDescription.String
+
} else {
+
repo.Description = ""
+
}
return &repo, nil
}
func AddRepo(e Execer, repo *Repo) error {
-
_, err := e.Exec(`insert into repos (did, name, knot, rkey, at_uri) values (?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri)
+
_, err := e.Exec(
+
`insert into repos
+
(did, name, knot, rkey, at_uri, description)
+
values (?, ?, ?, ?, ?, ?)`,
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description,
+
)
return err
}
···
return err
}
+
func UpdateDescription(e Execer, repoAt, newDescription string) error {
+
_, err := e.Exec(
+
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
+
return err
+
}
+
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
var repos []Repo
···
for rows.Next() {
var repo Repo
-
err := scanRepo(rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Created)
+
err := scanRepo(rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created)
if err != nil {
return nil, err
}
···
IssueCount IssueCount
}
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey *string, created *time.Time) error {
+
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error {
var createdAt string
-
if err := rows.Scan(did, name, knot, rkey, &createdAt); err != nil {
+
var nullableDescription sql.NullString
+
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil {
return err
+
}
+
+
if nullableDescription.Valid {
+
*description = nullableDescription.String
+
} else {
+
*description = ""
}
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
+35 -10
appview/pages/pages.go
···
"net/http"
"path"
"path/filepath"
+
"slices"
"strings"
"github.com/alecthomas/chroma/v2"
···
return p.executePlain("fragments/star", w, params)
}
+
type RepoDescriptionParams struct {
+
RepoInfo RepoInfo
+
}
+
+
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
+
return p.executePlain("fragments/editRepoDescription", w, params)
+
}
+
+
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
+
return p.executePlain("fragments/repoDescription", w, params)
+
}
+
type RepoInfo struct {
-
Name string
-
OwnerDid string
-
OwnerHandle string
-
Description string
-
Knot string
-
RepoAt syntax.ATURI
-
SettingsAllowed bool
-
IsStarred bool
-
Stats db.RepoStats
+
Name string
+
OwnerDid string
+
OwnerHandle string
+
Description string
+
Knot string
+
RepoAt syntax.ATURI
+
IsStarred bool
+
Stats db.RepoStats
+
Roles RolesInRepo
+
}
+
+
type RolesInRepo struct {
+
Roles []string
+
}
+
+
func (r RolesInRepo) SettingsAllowed() bool {
+
return slices.Contains(r.Roles, "repo:settings")
+
}
+
+
func (r RolesInRepo) IsOwner() bool {
+
return slices.Contains(r.Roles, "repo:owner")
}
func (r RepoInfo) OwnerWithAt() string {
···
{"pulls", "/pulls"},
}
-
if r.SettingsAllowed {
+
if r.Roles.SettingsAllowed() {
tabs = append(tabs, []string{"settings", "/settings"})
}
+9
appview/pages/templates/fragments/editRepoDescription.html
···
+
{{ define "fragments/editRepoDescription" }}
+
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML">
+
<input type="text" name="description" value="{{ .RepoInfo.Description }}" class="input">
+
<button type="submit" class="btn">save</button>
+
<button type="button" class="btn" hx-get="/{{ .RepoInfo.FullName }}/description" >
+
cancel
+
</button>
+
</form>
+
{{ end }}
+15
appview/pages/templates/fragments/repoDescription.html
···
+
{{ define "fragments/repoDescription" }}
+
<span id="repo-description" hx-target="this" hx-swap="outerHTML">
+
{{ if .RepoInfo.Description }}
+
{{ .RepoInfo.Description }}
+
{{ else }}
+
<span class="italic">this repo has no description</span>
+
{{ end }}
+
+
{{ if .RepoInfo.Roles.IsOwner }}
+
<button class="btn" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
+
edit
+
</button>
+
{{ end }}
+
</span>
+
{{ end }}
+1 -7
appview/pages/templates/layouts/repobase.html
···
</p>
{{ template "fragments/star" .RepoInfo }}
</div>
-
<span>
-
{{ if .RepoInfo.Description }}
-
{{ .RepoInfo.Description }}
-
{{ else }}
-
<span class="italic">this repo has no description</span>
-
{{ end }}
-
</span>
+
{{ template "fragments/repoDescription" . }}
</section>
<section id="repo-links" class="min-h-screen flex flex-col drop-shadow-sm">
<nav class="w-full mx-auto ml-4">
+9 -1
appview/pages/templates/repo/new.html
···
/>
<p class="text-sm text-gray-500">All repositories are publicly visible.</p>
-
<label for="name" class="block uppercase font-bold text-sm">Default branch</label>
+
<label for="branch" class="block uppercase font-bold text-sm">Default branch</label>
<input
type="text"
id="branch"
name="branch"
value="main"
required
+
class="w-full max-w-md"
+
/>
+
+
<label for="description" class="block uppercase font-bold text-sm">Description</label>
+
<input
+
type="text"
+
id="description"
+
name="description"
class="w-full max-w-md"
/>
</div>
+2
appview/state/middleware.go
···
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
+
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
+
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
next.ServeHTTP(w, req.WithContext(ctx))
})
}
+124 -25
appview/state/repo.go
···
return
}
+
func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
user := s.auth.GetUser(r)
+
s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
+
RepoInfo: f.RepoInfo(s, user),
+
})
+
return
+
}
+
+
func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
repoAt := f.RepoAt
+
rkey := repoAt.RecordKey().String()
+
if rkey == "" {
+
log.Println("invalid aturi for repo", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
+
user := s.auth.GetUser(r)
+
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
+
RepoInfo: f.RepoInfo(s, user),
+
})
+
return
+
case http.MethodPut:
+
user := s.auth.GetUser(r)
+
newDescription := r.FormValue("description")
+
client, _ := s.auth.AuthorizedClient(r)
+
+
// optimistic update
+
err = db.UpdateDescription(s.db, string(repoAt), newDescription)
+
if err != nil {
+
log.Println("failed to perferom update-description query", err)
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
+
return
+
}
+
+
// 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, user.Did, rkey)
+
if err != nil {
+
// failed to get record
+
s.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: user.Did,
+
Rkey: rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.Repo{
+
Knot: f.Knot,
+
Name: f.RepoName,
+
Owner: user.Did,
+
AddedAt: &f.AddedAt,
+
Description: &newDescription,
+
},
+
},
+
})
+
+
if err != nil {
+
log.Println("failed to perferom update-description query", err)
+
// failed to get record
+
s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
+
return
+
}
+
+
newRepoInfo := f.RepoInfo(s, user)
+
newRepoInfo.Description = newDescription
+
+
s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
+
RepoInfo: newRepoInfo,
+
})
+
return
+
}
+
}
+
func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
if err != nil {
···
}
type FullyResolvedRepo struct {
-
Knot string
-
OwnerId identity.Identity
-
RepoName string
-
RepoAt syntax.ATURI
+
Knot string
+
OwnerId identity.Identity
+
RepoName string
+
RepoAt syntax.ATURI
+
Description string
+
AddedAt string
}
func (f *FullyResolvedRepo) OwnerDid() string {
···
}
return pages.RepoInfo{
-
OwnerDid: f.OwnerDid(),
-
OwnerHandle: f.OwnerHandle(),
-
Name: f.RepoName,
-
RepoAt: f.RepoAt,
-
SettingsAllowed: settingsAllowed(s, u, f),
-
IsStarred: isStarred,
-
Knot: knot,
+
OwnerDid: f.OwnerDid(),
+
OwnerHandle: f.OwnerHandle(),
+
Name: f.RepoName,
+
RepoAt: f.RepoAt,
+
Description: f.Description,
+
IsStarred: isStarred,
+
Knot: knot,
+
Roles: rolesInRepo(s, u, f),
Stats: db.RepoStats{
StarCount: starCount,
IssueCount: issueCount,
···
return nil, fmt.Errorf("malformed middleware")
}
+
// pass through values from the middleware
+
description, ok := r.Context().Value("repoDescription").(string)
+
addedAt, ok := r.Context().Value("repoAddedAt").(string)
+
return &FullyResolvedRepo{
-
Knot: knot,
-
OwnerId: id,
-
RepoName: repoName,
-
RepoAt: parsedRepoAt,
+
Knot: knot,
+
OwnerId: id,
+
RepoName: repoName,
+
RepoAt: parsedRepoAt,
+
Description: description,
+
AddedAt: addedAt,
}, nil
}
-
func settingsAllowed(s *State, u *auth.User, f *FullyResolvedRepo) bool {
-
settingsAllowed := false
+
func rolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
if u != nil {
-
ok, err := s.enforcer.IsSettingsAllowed(u.Did, f.Knot, f.OwnerSlashRepo())
-
if err == nil && ok {
-
settingsAllowed = true
-
} else {
-
log.Println(err, ok)
-
}
+
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
+
log.Println(r)
+
return pages.RolesInRepo{r}
+
} else {
+
return pages.RolesInRepo{}
}
-
-
return settingsAllowed
}
+22 -5
appview/state/signer.go
···
Endpoint = "/init"
)
-
body, _ := json.Marshal(map[string]interface{}{
+
body, _ := json.Marshal(map[string]any{
"did": did,
})
···
Endpoint = "/repo/new"
)
-
body, _ := json.Marshal(map[string]interface{}{
+
body, _ := json.Marshal(map[string]any{
"did": did,
"name": repoName,
"default_branch": defaultBranch,
})
-
fmt.Println(body)
+
req, err := s.newRequest(Method, Endpoint, body)
+
if err != nil {
+
return nil, err
+
}
+
+
return s.client.Do(req)
+
}
+
+
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
+
const (
+
Method = "DELETE"
+
Endpoint = "/repo"
+
)
+
+
body, _ := json.Marshal(map[string]any{
+
"did": did,
+
"name": repoName,
+
})
req, err := s.newRequest(Method, Endpoint, body)
if err != nil {
···
Endpoint = "/member/add"
)
-
body, _ := json.Marshal(map[string]interface{}{
+
body, _ := json.Marshal(map[string]any{
"did": did,
})
···
)
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
-
body, _ := json.Marshal(map[string]interface{}{
+
body, _ := json.Marshal(map[string]any{
"did": memberDid,
})
+14 -4
appview/state/state.go
···
defaultBranch = "main"
}
+
description := r.FormValue("description")
+
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
if err != nil || !ok {
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
···
rkey := s.TID()
repo := &db.Repo{
-
Did: user.Did,
-
Name: repoName,
-
Knot: domain,
-
Rkey: rkey,
+
Did: user.Did,
+
Name: repoName,
+
Knot: domain,
+
Rkey: rkey,
+
Description: description,
}
xrpcClient, _ := s.auth.AuthorizedClient(r)
···
if err != nil {
log.Println("failed to rollback policies")
}
+
client.RemoveRepo(user.Did, repoName)
}()
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
···
// settings routes, needs auth
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(s))
+
// repo description can only be edited by owner
+
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
+
r.Put("/", s.RepoDescription)
+
r.Get("/", s.RepoDescription)
+
r.Get("/edit", s.RepoDescriptionEdit)
+
})
r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
r.Get("/", s.RepoSettings)
r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
+1
knotserver/handler.go
···
r.Route("/repo", func(r chi.Router) {
r.Use(h.VerifySignature)
r.Put("/new", h.NewRepo)
+
r.Delete("/", h.RemoveRepo)
})
r.Route("/member", func(r chi.Router) {
+35
knotserver/routes.go
···
"fmt"
"log"
"net/http"
+
"os"
"path/filepath"
"strconv"
"strings"
···
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
if err != nil {
l.Error("adding repo permissions", "error", err.Error())
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusNoContent)
+
}
+
+
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
+
l := h.l.With("handler", "RemoveRepo")
+
+
data := struct {
+
Did string `json:"did"`
+
Name string `json:"name"`
+
}{}
+
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
writeError(w, "invalid request body", http.StatusBadRequest)
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
+
if did == "" || name == "" {
+
l.Error("invalid request body, empty did or name")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
+
err := os.RemoveAll(repoPath)
+
if err != nil {
+
l.Error("removing repo", "error", err.Error())
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
+6
lexicons/repo.json
···
"addedAt": {
"type": "string",
"format": "datetime"
+
},
+
"description": {
+
"type": "string",
+
"format": "datetime",
+
"minLength": 1,
+
"maxLength": 140
}
}
}
+14
rbac/rbac.go
···
return e.E.Enforce(user, domain, repo, "repo:settings")
}
+
// given a repo, what permissions does this user have? repo:owner? repo:invite? etc.
+
func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string {
+
var permissions []string
+
res := e.E.GetPermissionsForUserInDomain(user, domain)
+
for _, p := range res {
+
// get only permissions for this resource/repo
+
if p[2] == repo {
+
permissions = append(permissions, p[3])
+
}
+
}
+
+
return permissions
+
}
+
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
return e.E.Enforce(user, domain, repo, "repo:invite")
}