diff --git a/appview/db/repos.go b/appview/db/repos.go index 56375ac2..487303ab 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -17,6 +17,75 @@ import ( "tangled.org/core/orm" ) +func RenameRepo(tx *sql.Tx, did, oldRkey, newRkey, newName string) error { + newAtURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", did, newRkey) + + res, err := tx.Exec( + `update repos set rkey = ?, name = ?, at_uri = ? where did = ? and rkey = ?`, + newRkey, newName, newAtURI, did, oldRkey, + ) + if err != nil { + return fmt.Errorf("update repos row: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("no repo row found for did=%s rkey=%s", did, oldRkey) + } + + if _, err := tx.Exec( + `update pipelines set repo_name = ? where repo_owner = ? and repo_name = ?`, + newRkey, did, oldRkey, + ); err != nil { + return fmt.Errorf("rename pipelines.repo_name: %w", err) + } + + return nil +} + +func UpdateRepoDisplayName(e Execer, did, rkey, newName string) error { + _, err := e.Exec( + `update repos set name = ? where did = ? and rkey = ?`, + newName, did, rkey, + ) + return err +} + +func RecordRepoRename(e Execer, ownerDid, oldRkey, repoDid string) error { + _, err := e.Exec( + `insert into repo_renames (owner_did, old_rkey, repo_did) + values (?, ?, ?) + on conflict(owner_did, old_rkey) do update set + repo_did = excluded.repo_did, + renamed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`, + ownerDid, oldRkey, repoDid, + ) + return err +} + +func DeleteRepoRename(e Execer, ownerDid, oldRkey string) error { + _, err := e.Exec( + `delete from repo_renames where owner_did = ? and old_rkey = ?`, + ownerDid, oldRkey, + ) + return err +} + +func LookupRepoRename(e Execer, ownerDid, oldRkey string) (*models.Repo, error) { + var repoDid string + err := e.QueryRow( + `select repo_did from repo_renames where owner_did = ? and old_rkey = ?`, + ownerDid, oldRkey, + ).Scan(&repoDid) + if err != nil { + return nil, err + } + + repo, err := GetRepoByDid(e, repoDid) + if err != nil { + return nil, err + } + return repo, nil +} + func GetRepos(e Execer, filters ...orm.Filter) ([]models.Repo, error) { return GetReposPaginated(e, pagination.Page{}, filters...) } diff --git a/appview/pages/templates/repo/settings/general.html b/appview/pages/templates/repo/settings/general.html index 76e3f8a1..b7bb8a94 100644 --- a/appview/pages/templates/repo/settings/general.html +++ b/appview/pages/templates/repo/settings/general.html @@ -10,6 +10,7 @@ {{ template "branchSettings" . }} {{ template "defaultLabelSettings" . }} {{ template "customLabelSettings" . }} + {{ template "renameRepo" . }} {{ template "deleteRepo" . }}
@@ -214,6 +215,41 @@ {{ end }} +{{ define "renameRepo" }} + {{ if and .RepoInfo.Roles.IsOwner .RepoInfo.RepoDid }} +
+

Rename Repository

+

+ Existing git remotes that use the old name will break. Use the + stable, DID-based URLs below to avoid breakage on future renames. +

+ + +
+
+ +
+
+ {{ end }} +{{ end }} + {{ define "deleteRepo" }} {{ if .RepoInfo.Roles.RepoDeleteAllowed }}
diff --git a/appview/repo/repo.go b/appview/repo/repo.go index c5248c85..24818ec4 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -23,6 +23,7 @@ import ( "tangled.org/core/appview/pages" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" + "tangled.org/core/appview/sites" "tangled.org/core/appview/validator" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/eventconsumer" @@ -831,6 +832,205 @@ func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } +func (rp *Repo) RenameRepo(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "RenameRepo") + noticeId := "rename-repo-error" + + user := rp.oauth.GetMultiAccountUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + rp.pages.Notice(w, noticeId, "Failed to load repository.") + return + } + l = l.With("did", user.Did, "rkey", f.Rkey, "oldName", f.Name) + + if f.RepoDid == "" { + rp.pages.Notice(w, noticeId, "This repository's knot has not completed the DID migration; rename is unavailable.") + return + } + + newName, err := validateRenameInput(f.Name, f.Rkey, r.FormValue("name")) + if err != nil { + rp.pages.Notice(w, noticeId, err.Error()) + return + } + newRkey := strings.ToLower(newName) + l = l.With("newName", newName, "newRkey", newRkey) + + atpClient, err := rp.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + rp.pages.Notice(w, noticeId, "Failed to authorize. Try again later.") + return + } + + newRepo := *f + newRepo.Name = newName + newRepo.Rkey = newRkey + newRepo.Created = time.Now() + record := newRepo.AsRecord() + + if newRkey == f.Rkey { + ex, err := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, f.Rkey) + if err != nil { + l.Error("failed to fetch existing record", "err", err) + rp.pages.Notice(w, noticeId, "Failed to read repository record from PDS.") + return + } + + _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: f.Did, + Rkey: f.Rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + if err != nil { + l.Error("failed to update display name on PDS", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save display name to PDS.") + return + } + l.Info("updated display name on PDS") + + if err := db.UpdateRepoDisplayName(rp.db, f.Did, f.Rkey, newName); err != nil { + l.Error("optimistic display name update failed", "err", err) + } + } else { + ex, getErr := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, newRkey) + switch { + case getErr != nil: + _, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{ + Collection: tangled.RepoNSID, + Repo: f.Did, + Rkey: &newRkey, + Record: &lexutil.LexiconTypeDecoder{Val: &record}, + }) + if err != nil { + l.Error("failed to write rename to PDS", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.") + return + } + l.Info("wrote rename-create to PDS; old record retained as alias") + + default: + existing, ok := ex.Value.Val.(*tangled.Repo) + if !ok || existing.RepoDid == nil || *existing.RepoDid != f.RepoDid { + rp.pages.Notice(w, noticeId, fmt.Sprintf("You already have a repository named %q.", newRkey)) + return + } + _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: f.Did, + Rkey: newRkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{Val: &record}, + }) + if err != nil { + l.Error("failed to rewrite rename-back record on PDS", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.") + return + } + l.Info("rewrote rename-back record on PDS over prior alias") + } + + tx, err := rp.db.Begin() + if err != nil { + l.Error("failed to begin rename tx", "err", err) + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) + return + } + defer tx.Rollback() + + if err := db.RenameRepo(tx, f.Did, f.Rkey, newRkey, newName); err != nil { + l.Error("optimistic rename failed", "err", err) + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) + return + } + if err := db.RecordRepoRename(tx, f.Did, f.Rkey, f.RepoDid); err != nil { + l.Error("failed to record rename history", "err", err) + } + if err := db.DeleteRepoRename(tx, f.Did, newRkey); err != nil { + l.Error("failed to clear stale rename hint", "err", err) + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit rename tx", "err", err) + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) + return + } + } + + oldRepo := *f + rp.notifier.RenameRepo(r.Context(), syntax.DID(user.Did), &oldRepo, &newRepo) + + if newRkey != f.Rkey { + rp.migrateSiteOnRename(r.Context(), f, newRkey) + } + + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) +} + +func validateRenameInput(currentName, currentRkey, raw string) (string, error) { + newName := strings.TrimSpace(raw) + if newName == "" { + return "", errors.New("Repository name cannot be empty.") + } + if err := models.ValidateRepoName(newName); err != nil { + return "", err + } + newName = models.StripGitExt(newName) + if newName == currentName { + if _, tidErr := syntax.ParseTID(currentRkey); tidErr == nil { + return newName, nil + } + return "", errors.New("New name matches the current name.") + } + return newName, nil +} + +func (rp *Repo) migrateSiteOnRename(ctx context.Context, oldRepo *models.Repo, newRkey string) { + l := rp.logger.With("handler", "migrateSiteOnRename", "repo_did", oldRepo.RepoDid) + + siteConfig, err := db.GetRepoSiteConfig(rp.db, oldRepo.RepoDid) + if err != nil || siteConfig == nil { + return + } + + if !rp.cfClient.Enabled() { + return + } + + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, oldRepo.Did) + + go func() { + bgCtx := context.Background() + oldRkey := oldRepo.Rkey + + if err := sites.Delete(bgCtx, rp.cfClient, oldRepo.Did, oldRkey); err != nil { + l.Error("sites: failed to delete old R2 prefix", "oldRkey", oldRkey, "err", err) + } + + newRepo := *oldRepo + newRepo.Rkey = newRkey + if deployErr := sites.Deploy(bgCtx, rp.cfClient, rp.config, &newRepo, siteConfig.Branch, siteConfig.Dir); deployErr != nil { + l.Error("sites: redeploy after rename failed", "err", deployErr) + } + + if ownerClaim != nil { + if err := sites.DeleteDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRkey); err != nil { + l.Error("sites: failed to remove old KV mapping", "oldRkey", oldRkey, "err", err) + } + if err := sites.PutDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRepo.Did, newRkey, siteConfig.IsIndex); err != nil { + l.Error("sites: failed to write new KV mapping", "newRkey", newRkey, "err", err) + } + } + + l.Info("sites: migrated on rename", "oldRkey", oldRkey, "newRkey", newRkey) + }() +} + func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetMultiAccountUser(r) l := rp.logger.With("handler", "DeleteRepo") diff --git a/appview/state/state.go b/appview/state/state.go index 779c10fa..70bff30a 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -499,6 +499,19 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { return } + atpClient, err := s.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + s.pages.Notice(w, "repo", "Failed to authorize. Try again later.") + return + } + + if rkeyOccupied(r.Context(), atpClient, user.Did, rkey) { + l.Info("rkey occupied by prior rename alias") + s.pages.Notice(w, "repo", fmt.Sprintf("The name %q still has a record on your PDS from a prior rename. Pick a different name, or delete at://%s/%s/%s first.", rkey, user.Did, tangled.RepoNSID, rkey)) + return + } + client, err := s.oauth.ServiceClient( r, oauth.WithService(domain), @@ -583,18 +596,10 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { }() } - atpClient, err := s.oauth.AuthorizedClient(r) - if err != nil { - l.Info("PDS write failed", "err", err) - cleanupKnot() - s.pages.Notice(w, "repo", "Failed to write record to PDS.") - return - } - - _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, - Rkey: rkey, + Rkey: &rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &record, }, diff --git a/knotserver/db/db.go b/knotserver/db/db.go index ea6abb6b..b7fdbec5 100644 --- a/knotserver/db/db.go +++ b/knotserver/db/db.go @@ -19,6 +19,11 @@ type DB struct { logger *slog.Logger } +type Querier interface { + QueryRow(query string, args ...any) *sql.Row + Exec(query string, args ...any) (sql.Result, error) +} + func Setup(ctx context.Context, dbPath string) (*DB, error) { // https://github.com/mattn/go-sqlite3#connection-string opts := []string{ @@ -130,24 +135,89 @@ func Setup(ctx context.Context, dbPath string) (*DB, error) { return nil, err } + if err := orm.RunMigration(conn, logger, "add-repo-aliases", func(tx *sql.Tx) error { + _, mErr := tx.ExecContext(ctx, ` + create table if not exists repo_aliases ( + owner_did text not null, + rkey text not null, + repo_did text not null, + rev text not null, + primary key (owner_did, rkey) + ); + create index if not exists idx_repo_aliases_repo_did on repo_aliases(repo_did); + + insert or ignore into repo_aliases (owner_did, rkey, repo_did, rev) + select owner_did, repo_name, repo_did, '1_' || created_at + from repo_keys + where owner_did is not null and repo_name is not null and repo_did is not null; + `) + return mErr + }); err != nil { + return nil, err + } + + if err := orm.RunMigration(conn, logger, "drop-at-uri-from-repo-keys", func(tx *sql.Tx) error { + _, mErr := tx.ExecContext(ctx, ` + create table repo_keys_new ( + repo_did text primary key, + signing_key blob, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + owner_did text, + repo_name text, + key_type text not null default 'k256' + ); + insert into repo_keys_new (repo_did, signing_key, created_at, owner_did, repo_name, key_type) + select repo_did, signing_key, created_at, owner_did, repo_name, key_type + from repo_keys; + drop table repo_keys; + alter table repo_keys_new rename to repo_keys; + create unique index if not exists idx_repo_keys_owner_repo + on repo_keys(owner_did, repo_name); + `) + return mErr + }); err != nil { + return nil, err + } + return &DB{ db: db, logger: logger, }, nil } -func (d *DB) StoreRepoKey(repoDid string, signingKey []byte, ownerDid, repoName, atUri string) error { - _, err := d.db.Exec( - `INSERT INTO repo_keys (repo_did, signing_key, owner_did, repo_name, at_uri, key_type) VALUES (?, ?, ?, ?, ?, 'k256')`, - repoDid, signingKey, ownerDid, repoName, atUri, - ) - return err +func (d *DB) StoreRepoKey(repoDid string, signingKey []byte, ownerDid, repoName string) error { + return d.storeRepoKeyRow(repoDid, signingKey, ownerDid, repoName, "k256") +} + +func (d *DB) StoreRepoDidWeb(repoDid, ownerDid, repoName string) error { + return d.storeRepoKeyRow(repoDid, nil, ownerDid, repoName, "web") } -func (d *DB) StoreRepoDidWeb(repoDid, ownerDid, repoName, atUri string) error { - _, err := d.db.Exec( - `INSERT INTO repo_keys (repo_did, signing_key, owner_did, repo_name, at_uri, key_type) VALUES (?, NULL, ?, ?, ?, 'web')`, - repoDid, ownerDid, repoName, atUri, +func (d *DB) storeRepoKeyRow(repoDid string, signingKey []byte, ownerDid, repoName, keyType string) (err error) { + tx, err := d.db.Begin() + if err != nil { + return err + } + defer func() { + if err != nil { + tx.Rollback() + return + } + err = tx.Commit() + }() + + if _, err = tx.Exec( + `INSERT INTO repo_keys (repo_did, signing_key, owner_did, repo_name, key_type) VALUES (?, ?, ?, ?, ?)`, + repoDid, signingKey, ownerDid, repoName, keyType, + ); err != nil { + return err + } + + _, err = tx.Exec( + `INSERT INTO repo_aliases (owner_did, rkey, repo_did, rev) + VALUES (?, ?, ?, '0_' || strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ON CONFLICT(owner_did, rkey) DO NOTHING`, + ownerDid, repoName, repoDid, ) return err } @@ -163,30 +233,34 @@ func (d *DB) RepoDidExists(repoDid string) (bool, error) { return count > 0, err } -func (d *DB) GetRepoDid(ownerDid, repoName string) (string, error) { +func (d *DB) GetRepoDid(ownerDid, rkey string) (string, error) { var repoDid string err := d.db.QueryRow( - `SELECT repo_did FROM repo_keys WHERE owner_did = ? AND repo_name = ?`, - ownerDid, repoName, + `SELECT repo_did FROM repo_aliases WHERE owner_did = ? AND rkey = ?`, + ownerDid, rkey, ).Scan(&repoDid) return repoDid, err } -func (d *DB) GetRepoKeyOwner(repoDid string) (ownerDid string, repoName string, err error) { - var nullOwner, nullName sql.NullString - err = d.db.QueryRow( - `SELECT owner_did, repo_name FROM repo_keys WHERE repo_did = ?`, +func (d *DB) GetRepoKeyOwner(repoDid string) (string, string, error) { + return GetRepoKeyOwner(d.db, repoDid) +} + +func GetRepoKeyOwner(q Querier, repoDid string) (ownerDid string, repoName string, err error) { + err = q.QueryRow( + `SELECT owner_did, rkey FROM repo_aliases + WHERE repo_did = ? + ORDER BY rev DESC + LIMIT 1`, repoDid, - ).Scan(&nullOwner, &nullName) + ).Scan(&ownerDid, &repoName) if err != nil { return } - if !nullOwner.Valid || !nullName.Valid || nullOwner.String == "" || nullName.String == "" { - err = fmt.Errorf("repo_keys row for %s has empty or null owner_did or repo_name", repoDid) + if ownerDid == "" || repoName == "" { + err = fmt.Errorf("repo_aliases row for %s has empty owner_did or rkey", repoDid) return } - ownerDid = nullOwner.String - repoName = nullName.String return } diff --git a/knotserver/db/didassign.go b/knotserver/db/didassign.go index 6f20d42a..8fcea545 100644 --- a/knotserver/db/didassign.go +++ b/knotserver/db/didassign.go @@ -3,8 +3,7 @@ package db const RepoDIDAssignNSID = "sh.tangled.repo.didAssign" type RepoDIDAssign struct { - OwnerDid string `json:"ownerDid"` - RepoName string `json:"repoName"` - RepoDid string `json:"repoDid"` - OldRepoAt string `json:"oldRepoAt,omitempty"` + OwnerDid string `json:"ownerDid"` + RepoName string `json:"repoName"` + RepoDid string `json:"repoDid"` } diff --git a/knotserver/db/events.go b/knotserver/db/events.go index 09612ba7..336ebeac 100644 --- a/knotserver/db/events.go +++ b/knotserver/db/events.go @@ -31,12 +31,11 @@ func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { return err } -func (d *DB) EmitDIDAssign(n *notifier.Notifier, ownerDid, repoName, repoDid, oldRepoAt string) error { +func (d *DB) EmitDIDAssign(n *notifier.Notifier, ownerDid, repoName, repoDid string) error { payload := RepoDIDAssign{ - OwnerDid: ownerDid, - RepoName: repoName, - RepoDid: repoDid, - OldRepoAt: oldRepoAt, + OwnerDid: ownerDid, + RepoName: repoName, + RepoDid: repoDid, } eventJson, err := json.Marshal(payload) diff --git a/knotserver/db/repo_aliases.go b/knotserver/db/repo_aliases.go new file mode 100644 index 00000000..7343cd88 --- /dev/null +++ b/knotserver/db/repo_aliases.go @@ -0,0 +1,60 @@ +package db + +import ( + "database/sql" + "errors" +) + +type RepoAlias struct { + OwnerDid string + Rkey string + RepoDid string + Rev string +} + +func (d *DB) UpsertRepoAlias(a RepoAlias) error { + _, err := d.db.Exec( + `insert into repo_aliases (owner_did, rkey, repo_did, rev) + values (?, ?, ?, ?) + on conflict(owner_did, rkey) do update set + repo_did = excluded.repo_did, + rev = excluded.rev + where excluded.rev > repo_aliases.rev`, + a.OwnerDid, a.Rkey, a.RepoDid, a.Rev, + ) + return err +} + +func (d *DB) DeleteRepoAlias(ownerDid, rkey string) error { + _, err := d.db.Exec( + `delete from repo_aliases where owner_did = ? and rkey = ?`, + ownerDid, rkey, + ) + return err +} + +func (d *DB) ResolveAlias(ownerDid, rkey string) (*RepoAlias, error) { + var a RepoAlias + err := d.db.QueryRow( + `select owner_did, rkey, repo_did, rev from repo_aliases where owner_did = ? and rkey = ?`, + ownerDid, rkey, + ).Scan(&a.OwnerDid, &a.Rkey, &a.RepoDid, &a.Rev) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &a, nil +} + +func (d *DB) CurrentRkey(repoDid string) (ownerDid string, rkey string, err error) { + err = d.db.QueryRow( + `select owner_did, rkey from repo_aliases + where repo_did = ? + order by rev desc + limit 1`, + repoDid, + ).Scan(&ownerDid, &rkey) + return +} diff --git a/knotserver/git.go b/knotserver/git.go index b407b1f8..e42db103 100644 --- a/knotserver/git.go +++ b/knotserver/git.go @@ -26,9 +26,9 @@ func (h *Knot) resolveRepoPath(r *http.Request) (string, string, error) { return repoPath, repoName, nil } - repoDid, err := h.db.GetRepoDid(did, name) - if err == nil { - repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) + alias, err := h.db.ResolveAlias(did, name) + if err == nil && alias != nil { + repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, alias.RepoDid) if resolveErr == nil { return repoPath, name, nil } @@ -44,10 +44,16 @@ func (h *Knot) resolveRepoPath(r *http.Request) (string, string, error) { return repoPath, name, nil } +func (h *Knot) repoNotFound(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "repository not found\n") +} + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { repoPath, name, err := h.resolveRepoPath(r) if err != nil { - gitError(w, "repository not found", http.StatusNotFound) + h.repoNotFound(w, r) h.l.Error("git: failed to resolve repo path", "handler", "InfoRefs", "error", err) return } @@ -81,7 +87,7 @@ func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { repo, _, err := h.resolveRepoPath(r) if err != nil { - gitError(w, "repository not found", http.StatusNotFound) + h.repoNotFound(w, r) h.l.Error("git: failed to resolve repo path", "handler", "UploadArchive", "error", err) return } @@ -126,7 +132,7 @@ func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { repo, _, err := h.resolveRepoPath(r) if err != nil { - gitError(w, "repository not found", http.StatusNotFound) + h.repoNotFound(w, r) h.l.Error("git: failed to resolve repo path", "handler", "UploadPack", "error", err) return } @@ -173,7 +179,7 @@ func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { _, name, err := h.resolveRepoPath(r) if err != nil { - gitError(w, "repository not found", http.StatusNotFound) + h.repoNotFound(w, r) h.l.Error("git: failed to resolve repo path", "handler", "ReceivePack", "error", err) return } diff --git a/knotserver/git_test.go b/knotserver/git_test.go new file mode 100644 index 00000000..15bf2b55 --- /dev/null +++ b/knotserver/git_test.go @@ -0,0 +1,65 @@ +package knotserver + +import ( + "context" + "path/filepath" + "testing" + + "tangled.org/core/knotserver/db" +) + +func newTestKnotDB(t *testing.T) *db.DB { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + d, err := db.Setup(context.Background(), path) + if err != nil { + t.Fatalf("db.Setup: %v", err) + } + return d +} + +func TestAliasResolvesOriginalName(t *testing.T) { + d := newTestKnotDB(t) + if err := d.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "foo"); err != nil { + t.Fatalf("StoreRepoKey: %v", err) + } + got, err := d.GetRepoDid("did:plc:akshay", "foo") + if err != nil { + t.Fatalf("GetRepoDid: %v", err) + } + if got != "did:plc:repo1" { + t.Errorf("repoDid = %q, want did:plc:repo1", got) + } +} + +func TestAliasUpsertRespectsRevOrdering(t *testing.T) { + d := newTestKnotDB(t) + if err := d.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "foo"); err != nil { + t.Fatalf("StoreRepoKey: %v", err) + } + if err := d.UpsertRepoAlias(db.RepoAlias{ + OwnerDid: "did:plc:akshay", + Rkey: "bar", + RepoDid: "did:plc:repo1", + Rev: "3laaaaaaaaaab", + }); err != nil { + t.Fatalf("UpsertRepoAlias bar: %v", err) + } + + _, current, err := d.CurrentRkey("did:plc:repo1") + if err != nil { + t.Fatalf("CurrentRkey: %v", err) + } + if current != "bar" { + t.Errorf("current rkey = %q, want bar", current) + } + + fooDid, err := d.GetRepoDid("did:plc:akshay", "foo") + if err != nil || fooDid != "did:plc:repo1" { + t.Errorf("old rkey lookup: got (%q, %v), want did:plc:repo1", fooDid, err) + } + barDid, err := d.GetRepoDid("did:plc:akshay", "bar") + if err != nil || barDid != "did:plc:repo1" { + t.Errorf("new rkey lookup: got (%q, %v), want did:plc:repo1", barDid, err) + } +} diff --git a/knotserver/ingester.go b/knotserver/ingester.go index 926decba..bba0e082 100644 --- a/knotserver/ingester.go +++ b/knotserver/ingester.go @@ -18,6 +18,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/knotserver/db" "tangled.org/core/knotserver/git" + knotxrpc "tangled.org/core/knotserver/xrpc" "tangled.org/core/log" "tangled.org/core/rbac" "tangled.org/core/workflow" @@ -99,27 +100,31 @@ func (h *Knot) validatePullRecord(ctx context.Context, record *tangled.RepoPull) return nil, fmt.Errorf("ignoring pull record: target repo is nil") } + l := log.FromContext(ctx).With("handler", "validatePullRecord") + l = l.With("target_repo", record.Target.Repo) + l = l.With("target_branch", record.Target.Branch) + if record.Source == nil { return nil, fmt.Errorf("ignoring pull record: not a branch-based pull request") } - if record.Source.Repo != nil || record.Source.RepoDid != nil { + if record.Source.Repo != nil { return nil, fmt.Errorf("ignoring pull record: fork based pull") } var repoPath, ownerDid, repoName, repoDid string switch { - case record.Target.RepoDid != nil && *record.Target.RepoDid != "": - repoDid = *record.Target.RepoDid + case strings.HasPrefix(record.Target.Repo, "did:"): + repoDid = record.Target.Repo var lookupErr error repoPath, ownerDid, repoName, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) if lookupErr != nil { return nil, fmt.Errorf("unknown target repo DID %s: %w", repoDid, lookupErr) } - case record.Target.Repo != nil: + case strings.Contains(record.Target.Repo, "/"): // TODO: get rid of this PDS fetch once all repos have DIDs - repoAt, parseErr := syntax.ParseATURI(*record.Target.Repo) + repoAt, parseErr := syntax.ParseATURI(record.Target.Repo) if parseErr != nil { return nil, fmt.Errorf("failed to parse ATURI: %w", parseErr) } @@ -138,14 +143,17 @@ func (h *Knot) validatePullRecord(ctx context.Context, record *tangled.RepoPull) return nil, fmt.Errorf("failed to resolve repo: %w", getErr) } - repo := resp.Value.Val.(*tangled.Repo) + repo, ok := resp.Value.Val.(*tangled.Repo) + if !ok { + return nil, fmt.Errorf("record at %s is not a tangled.Repo", repoAt) + } if repo.Knot != h.c.Server.Hostname { return nil, fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) } ownerDid = ident.DID.String() - repoName = repo.Name + repoName = repoAt.RecordKey().String() repoDid, didErr := h.db.GetRepoDid(ownerDid, repoName) if didErr != nil { @@ -159,7 +167,7 @@ func (h *Knot) validatePullRecord(ctx context.Context, record *tangled.RepoPull) } default: - return nil, fmt.Errorf("ignoring pull record: target has neither repo nor repoDid") + return nil, fmt.Errorf("ignoring pull record: target repo has unrecognized format: %s", record.Target.Repo) } gr, err := git.Open(repoPath, record.Source.Branch) @@ -374,19 +382,19 @@ func (h *Knot) processCollaborator(ctx context.Context, event *jmodels.Event) er var rbacResource string switch { - case record.RepoDid != nil && *record.RepoDid != "": - ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(*record.RepoDid) + case strings.HasPrefix(record.Repo, "did:"): + ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(record.Repo) if lookupErr != nil { - return fmt.Errorf("unknown repo DID %s: %w", *record.RepoDid, lookupErr) + return fmt.Errorf("unknown repo DID %s: %w", record.Repo, lookupErr) } if ownerDid != did { - return fmt.Errorf("collaborator record author %s does not own repo %s", did, *record.RepoDid) + return fmt.Errorf("collaborator record author %s does not own repo %s", did, record.Repo) } - rbacResource = *record.RepoDid + rbacResource = record.Repo - case record.Repo != nil: + case strings.Contains(record.Repo, "/"): // TODO: get rid of this PDS fetch once all repos have DIDs - repoAt, parseErr := syntax.ParseATURI(*record.Repo) + repoAt, parseErr := syntax.ParseATURI(record.Repo) if parseErr != nil { return parseErr } @@ -405,15 +413,18 @@ func (h *Knot) processCollaborator(ctx context.Context, event *jmodels.Event) er return getErr } - repo := resp.Value.Val.(*tangled.Repo) - repoDid, didErr := h.db.GetRepoDid(owner.DID.String(), repo.Name) + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { + return fmt.Errorf("record at %s is not a tangled.Repo", repoAt) + } + rkey := repoAt.RecordKey().String() + repoDid, didErr := h.db.GetRepoDid(owner.DID.String(), rkey) if didErr != nil { - return fmt.Errorf("failed to resolve repo DID for %s/%s: %w", owner.DID.String(), repo.Name, didErr) + return fmt.Errorf("failed to resolve repo DID for %s/%s: %w", owner.DID.String(), rkey, didErr) } rbacResource = repoDid default: - return fmt.Errorf("collaborator record has neither repo nor repoDid") + return fmt.Errorf("collaborator record has unrecognized repo format: %s", record.Repo) } ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, rbacResource) @@ -479,6 +490,70 @@ func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { return nil } +func (h *Knot) processRepo(ctx context.Context, event *jmodels.Event) error { + l := log.FromContext(ctx).With("handler", "processRepo", "did", event.Did, "rkey", event.Commit.RKey) + + rkey := strings.TrimSuffix(strings.TrimSpace(event.Commit.RKey), ".git") + if rkey == "" { + return nil + } + + if event.Commit.Operation == jmodels.CommitOperationDelete { + if err := h.db.DeleteRepoAlias(event.Did, rkey); err != nil { + l.Warn("failed to delete repo alias", "err", err) + } + return nil + } + + if event.Commit.Operation != jmodels.CommitOperationCreate && event.Commit.Operation != jmodels.CommitOperationUpdate { + return nil + } + + raw := json.RawMessage(event.Commit.Record) + var record tangled.Repo + if err := json.Unmarshal(raw, &record); err != nil { + return fmt.Errorf("failed to unmarshal repo record: %w", err) + } + + if record.Knot != h.c.Server.Hostname { + return nil + } + if record.RepoDid == nil || *record.RepoDid == "" { + l.Info("skipping repo event without repoDid") + return nil + } + repoDid := *record.RepoDid + + if err := knotxrpc.ValidateRepoName(rkey); err != nil { + l.Warn("skipping repo event with invalid rkey", "repoDid", repoDid, "rkey", rkey, "err", err) + return nil + } + + ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(repoDid) + if lookupErr != nil { + l.Info("skipping repo event for unknown repoDid", "repoDid", repoDid) + return nil + } + if ownerDid != event.Did { + l.Warn("repo event author does not own repoDid", "repoDid", repoDid, "author", event.Did) + return nil + } + + alias := db.RepoAlias{ + OwnerDid: event.Did, + Rkey: rkey, + RepoDid: repoDid, + Rev: event.Commit.Rev, + } + if err := h.db.UpsertRepoAlias(alias); err != nil { + l.Warn("failed to upsert repo alias", "err", err) + return nil + } + + l.Info("recorded repo alias", "repoDid", repoDid, "rkey", rkey, "rev", event.Commit.Rev) + return nil +} + func (h *Knot) processMessages(ctx context.Context, event *jmodels.Event) error { var err error switch event.Kind { @@ -490,6 +565,8 @@ func (h *Knot) processMessages(ctx context.Context, event *jmodels.Event) error err = h.processPublicKey(ctx, event) case tangled.KnotMemberNSID: err = h.processKnotMember(ctx, event) + case tangled.RepoNSID: + err = h.processRepo(ctx, event) case tangled.RepoPullNSID: err = h.processPull(ctx, event) case tangled.RepoCollaboratorNSID: diff --git a/knotserver/ingester_repo_test.go b/knotserver/ingester_repo_test.go new file mode 100644 index 00000000..54c2a8d7 --- /dev/null +++ b/knotserver/ingester_repo_test.go @@ -0,0 +1,223 @@ +package knotserver + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "testing" + + jsmodels "github.com/bluesky-social/jetstream/pkg/models" + "tangled.org/core/api/tangled" + "tangled.org/core/knotserver/config" + "tangled.org/core/knotserver/db" + "tangled.org/core/log" +) + +type logRecord struct { + Level slog.Level + Msg string + Attrs map[string]any +} + +type capturingHandler struct { + mu *sync.Mutex + records *[]logRecord + attrs []slog.Attr +} + +func newCapturingHandler() *capturingHandler { + return &capturingHandler{ + mu: &sync.Mutex{}, + records: &[]logRecord{}, + } +} + +func (h *capturingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } + +func (h *capturingHandler) Handle(_ context.Context, r slog.Record) error { + rec := logRecord{Level: r.Level, Msg: r.Message, Attrs: map[string]any{}} + for _, a := range h.attrs { + rec.Attrs[a.Key] = a.Value.Any() + } + r.Attrs(func(a slog.Attr) bool { + rec.Attrs[a.Key] = a.Value.Any() + return true + }) + h.mu.Lock() + *h.records = append(*h.records, rec) + h.mu.Unlock() + return nil +} + +func (h *capturingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) + merged = append(merged, h.attrs...) + merged = append(merged, attrs...) + return &capturingHandler{mu: h.mu, records: h.records, attrs: merged} +} + +func (h *capturingHandler) WithGroup(string) slog.Handler { + panic("capturingHandler: WithGroup not supported") +} + +func (h *capturingHandler) snapshot() []logRecord { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]logRecord, len(*h.records)) + copy(out, *h.records) + return out +} + +func newProcessRepoFixture(t *testing.T) (*Knot, context.Context, *capturingHandler) { + t.Helper() + d := newTestKnotDB(t) + cap := newCapturingHandler() + l := slog.New(cap) + ctx := log.IntoContext(context.Background(), l) + + c := &config.Config{ + Server: config.Server{Hostname: "knot.example"}, + } + return &Knot{ + c: c, + db: d, + l: l, + }, ctx, cap +} + +func repoEvent(t *testing.T, authorDid, rkey, rev string, record tangled.Repo, op string) *jsmodels.Event { + t.Helper() + raw, err := json.Marshal(record) + if err != nil { + t.Fatalf("marshal record: %v", err) + } + return &jsmodels.Event{ + Did: authorDid, + Kind: jsmodels.EventKindCommit, + Commit: &jsmodels.Commit{ + Operation: op, + Collection: tangled.RepoNSID, + RKey: rkey, + Rev: rev, + Record: raw, + }, + } +} + +func ptr(s string) *string { return &s } + +func TestProcessRepo_CreateRegistersAlias(t *testing.T) { + h, ctx, _ := newProcessRepoFixture(t) + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { + t.Fatalf("StoreRepoKey: %v", err) + } + + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaab", tangled.Repo{ + Knot: "knot.example", + RepoDid: ptr("did:plc:repo1"), + }, jsmodels.CommitOperationCreate) + if err := h.processRepo(ctx, ev); err != nil { + t.Fatalf("processRepo: %v", err) + } + + _, current, err := h.db.CurrentRkey("did:plc:repo1") + if err != nil { + t.Fatalf("CurrentRkey: %v", err) + } + if current != "bar" { + t.Errorf("current rkey = %q, want bar (highest rev alias)", current) + } + + oldDid, err := h.db.GetRepoDid("did:plc:akshay", "foo") + if err != nil || oldDid != "did:plc:repo1" { + t.Errorf("old rkey foo should still resolve: got (%q, %v)", oldDid, err) + } +} + +func TestProcessRepo_DeleteRemovesAlias(t *testing.T) { + h, ctx, _ := newProcessRepoFixture(t) + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { + t.Fatalf("StoreRepoKey: %v", err) + } + if err := h.db.UpsertRepoAlias(db.RepoAlias{ + OwnerDid: "did:plc:akshay", Rkey: "bar", RepoDid: "did:plc:repo1", Rev: "3laaaaaaaaaab", + }); err != nil { + t.Fatalf("UpsertRepoAlias: %v", err) + } + + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaac", tangled.Repo{}, jsmodels.CommitOperationDelete) + if err := h.processRepo(ctx, ev); err != nil { + t.Fatalf("processRepo: %v", err) + } + + if _, err := h.db.GetRepoDid("did:plc:akshay", "bar"); err == nil { + t.Errorf("bar alias should have been deleted") + } + + _, current, _ := h.db.CurrentRkey("did:plc:repo1") + if current != "foo" { + t.Errorf("current rkey after delete = %q, want foo", current) + } +} + +func TestProcessRepo_MalformedJSONReturnsError(t *testing.T) { + h, ctx, _ := newProcessRepoFixture(t) + + ev := &jsmodels.Event{ + Did: "did:plc:akshay", + Kind: jsmodels.EventKindCommit, + Commit: &jsmodels.Commit{ + Operation: jsmodels.CommitOperationCreate, + Collection: tangled.RepoNSID, + RKey: "rkey1", + Record: []byte("{not valid json"), + }, + } + if err := h.processRepo(ctx, ev); err == nil { + t.Fatalf("processRepo returned nil, want unmarshal error") + } +} + +func TestProcessRepo_NotOwnedRejected(t *testing.T) { + h, ctx, _ := newProcessRepoFixture(t) + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { + t.Fatalf("StoreRepoKey: %v", err) + } + + ev := repoEvent(t, "did:plc:mallory", "pwned", "3laaaaaaaaaab", tangled.Repo{ + Knot: "knot.example", + RepoDid: ptr("did:plc:repo1"), + }, jsmodels.CommitOperationCreate) + if err := h.processRepo(ctx, ev); err != nil { + t.Fatalf("processRepo: %v", err) + } + + _, current, _ := h.db.CurrentRkey("did:plc:repo1") + if current != "foo" { + t.Errorf("current rkey = %q, want foo (mallory's event must be rejected)", current) + } + if _, err := h.db.GetRepoDid("did:plc:mallory", "pwned"); err == nil { + t.Errorf("mallory should not be able to register an alias on alice's repo") + } +} + +func TestProcessRepo_WrongKnotIgnored(t *testing.T) { + h, ctx, _ := newProcessRepoFixture(t) + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { + t.Fatalf("StoreRepoKey: %v", err) + } + + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaab", tangled.Repo{ + Knot: "other.example", + RepoDid: ptr("did:plc:repo1"), + }, jsmodels.CommitOperationCreate) + if err := h.processRepo(ctx, ev); err != nil { + t.Fatalf("processRepo: %v", err) + } + + _, current, _ := h.db.CurrentRkey("did:plc:repo1") + if current != "foo" { + t.Errorf("current rkey = %q, want foo (foreign-knot event must be ignored)", current) + } +} diff --git a/knotserver/internal.go b/knotserver/internal.go index b04a02ca..39b8c93b 100644 --- a/knotserver/internal.go +++ b/knotserver/internal.go @@ -140,14 +140,16 @@ func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { } else { legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid.String(), repoName)) if joinErr != nil { + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") w.WriteHeader(http.StatusNotFound) - fmt.Fprintln(w, "repo not found") + fmt.Fprint(w, "repo not found\n") return } if _, statErr := os.Stat(legacyPath); statErr != nil { + l.Info("legacy repo path missing, checking rename history", "owner", ownerDid, "name", repoName) + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") w.WriteHeader(http.StatusNotFound) - l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName) - fmt.Fprintln(w, "repo not found") + fmt.Fprint(w, "repo not found\n") return } repoPath = legacyPath @@ -253,7 +255,7 @@ func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) } for _, line := range lines { - err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid) + err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid) if err != nil { l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) } @@ -272,7 +274,7 @@ func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) writeJSON(w, resp) } -func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error { +func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string) error { repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) if resolveErr != nil { return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) @@ -296,8 +298,7 @@ func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, o Ref: line.Ref, CommitterDid: gitUserDid, OwnerDid: &ownerDid, - RepoName: repoName, - RepoDid: &repoDid, + Repo: repoDid, Meta: &metaRecord, } diff --git a/knotserver/migrate.go b/knotserver/migrate.go index 6ff34e0f..a82194ed 100644 --- a/knotserver/migrate.go +++ b/knotserver/migrate.go @@ -145,7 +145,7 @@ func migrateOneRepo( l.Warn("could not remove empty owner dir", "path", ownerDir, "error", err) } - if err := d.EmitDIDAssign(n, repo.ownerDid, repo.repoName, repoDid, ""); err != nil { + if err := d.EmitDIDAssign(n, repo.ownerDid, repo.repoName, repoDid); err != nil { l.Error("emitting didAssign event failed (non-fatal)", "error", err) } @@ -170,7 +170,7 @@ func mintAndStoreRepoDID( return "", fmt.Errorf("PLC submission: %w", err) } - if err := d.StoreRepoKey(prepared.RepoDid, prepared.SigningKeyRaw, repo.ownerDid, repo.repoName, ""); err != nil { + if err := d.StoreRepoKey(prepared.RepoDid, prepared.SigningKeyRaw, repo.ownerDid, repo.repoName); err != nil { return "", fmt.Errorf("storing repo key: %w", err) } diff --git a/knotserver/server.go b/knotserver/server.go index 01393959..1afd7ac4 100644 --- a/knotserver/server.go +++ b/knotserver/server.go @@ -81,6 +81,7 @@ func Run(ctx context.Context, cmd *cli.Command) error { jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ tangled.PublicKeyNSID, tangled.KnotMemberNSID, + tangled.RepoNSID, tangled.RepoPullNSID, tangled.RepoCollaboratorNSID, }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) diff --git a/knotserver/xrpc/create_repo.go b/knotserver/xrpc/create_repo.go index f742b8fe..63fbd281 100644 --- a/knotserver/xrpc/create_repo.go +++ b/knotserver/xrpc/create_repo.go @@ -63,7 +63,7 @@ func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { defaultBranch = *data.DefaultBranch } - if err := validateRepoName(repoName); err != nil { + if err := ValidateRepoName(repoName); err != nil { l.Error("creating repo", "error", err.Error()) fail(xrpcerr.GenericError(err)) return @@ -129,8 +129,7 @@ func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { } repoDid = prepared.RepoDid - atUri := fmt.Sprintf("at://%s/%s/%s", actorDid, tangled.RepoNSID, data.Rkey) - if err := h.Db.StoreRepoKey(repoDid, prepared.SigningKeyRaw, actorDid.String(), repoName, atUri); err != nil { + if err := h.Db.StoreRepoKey(repoDid, prepared.SigningKeyRaw, actorDid.String(), repoName); err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { writeError(w, xrpcerr.GenericError(fmt.Errorf("repository %s already being created", repoName)), http.StatusConflict) return @@ -188,8 +187,7 @@ func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { } if data.RepoDid != nil && strings.HasPrefix(*data.RepoDid, "did:web:") { - webAtUri := fmt.Sprintf("at://%s/%s/%s", actorDid, tangled.RepoNSID, data.Rkey) - if err := h.Db.StoreRepoDidWeb(repoDid, actorDid.String(), repoName, webAtUri); err != nil { + if err := h.Db.StoreRepoDidWeb(repoDid, actorDid.String(), repoName); err != nil { cleanupAll() if strings.Contains(err.Error(), "UNIQUE constraint failed") { writeError(w, xrpcerr.GenericError(fmt.Errorf("did:web %s is already in use", repoDid)), http.StatusConflict) @@ -266,7 +264,11 @@ func (h *Xrpc) requestCrawl(ctx context.Context, input *tangled.SyncRequestCrawl return nil } -func validateRepoName(name string) error { +var reservedRepoNames = map[string]struct{}{ + "self": {}, +} + +func ValidateRepoName(name string) error { // check for path traversal attempts if name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") { @@ -279,6 +281,13 @@ func validateRepoName(name string) error { return fmt.Errorf("Repository name contains invalid path sequence") } + if len(name) == 0 { + return fmt.Errorf("Repository name cannot be empty") + } + if len(name) > 100 { + return fmt.Errorf("Repository name must be 100 characters or fewer") + } + // then continue with character validation for _, char := range name { if !((char >= 'a' && char <= 'z') || @@ -294,6 +303,10 @@ func validateRepoName(name string) error { return fmt.Errorf("Repository name cannot contain sequential dots") } + if _, reserved := reservedRepoNames[strings.ToLower(name)]; reserved { + return fmt.Errorf("Repository name %q is reserved", name) + } + // if all checks pass return nil } diff --git a/knotserver/xrpc/delete_branch.go b/knotserver/xrpc/delete_branch.go index 36c9c3ce..f4cd3c82 100644 --- a/knotserver/xrpc/delete_branch.go +++ b/knotserver/xrpc/delete_branch.go @@ -55,8 +55,11 @@ func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { return } - repo := resp.Value.Val.(*tangled.Repo) - repoDid, err := x.Db.GetRepoDid(ident.DID.String(), repo.Name) + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { + fail(xrpcerr.RepoNotFoundError) + return + } + repoDid, err := x.Db.GetRepoDid(ident.DID.String(), repoAt.RecordKey().String()) if err != nil { fail(xrpcerr.RepoNotFoundError) return diff --git a/knotserver/xrpc/hidden_ref.go b/knotserver/xrpc/hidden_ref.go index dc371c79..2522cdd5 100644 --- a/knotserver/xrpc/hidden_ref.go +++ b/knotserver/xrpc/hidden_ref.go @@ -61,8 +61,11 @@ func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { return } - repo := resp.Value.Val.(*tangled.Repo) - repoDid, err := x.Db.GetRepoDid(actorDid.String(), repo.Name) + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { + fail(xrpcerr.RepoNotFoundError) + return + } + repoDid, err := x.Db.GetRepoDid(actorDid.String(), repoAt.RecordKey().String()) if err != nil { fail(xrpcerr.RepoNotFoundError) return diff --git a/knotserver/xrpc/set_default_branch.go b/knotserver/xrpc/set_default_branch.go index d7f51d55..42d81d21 100644 --- a/knotserver/xrpc/set_default_branch.go +++ b/knotserver/xrpc/set_default_branch.go @@ -59,8 +59,11 @@ func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { return } - repo := resp.Value.Val.(*tangled.Repo) - repoDid, err := x.Db.GetRepoDid(actorDid.String(), repo.Name) + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { + fail(xrpcerr.RepoNotFoundError) + return + } + repoDid, err := x.Db.GetRepoDid(actorDid.String(), repoAt.RecordKey().String()) if err != nil { fail(xrpcerr.RepoNotFoundError) return @@ -92,9 +95,8 @@ func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { ownerDid := ident.DID.String() refUpdate := tangled.GitRefUpdate{ - RepoDid: repo.RepoDid, + Repo: repoDid, OwnerDid: &ownerDid, - RepoName: repo.Name, CommitterDid: actorDid.String(), } eventJson, err := json.Marshal(refUpdate)