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 }}
+
+ {{ 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)