Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

knotserver: rkey-based repo renaming

Lewis: May this revision serve well! <lewis@tangled.org>

Lewis 0baf5ebb 1f60fce9

+923 -90
+69
appview/db/repos.go
··· 15 15 "tangled.org/core/orm" 16 16 ) 17 17 18 + func RenameRepo(tx *sql.Tx, did, oldRkey, newRkey, newName string) error { 19 + newAtURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", did, newRkey) 20 + 21 + res, err := tx.Exec( 22 + `update repos set rkey = ?, name = ?, at_uri = ? where did = ? and rkey = ?`, 23 + newRkey, newName, newAtURI, did, oldRkey, 24 + ) 25 + if err != nil { 26 + return fmt.Errorf("update repos row: %w", err) 27 + } 28 + if n, _ := res.RowsAffected(); n == 0 { 29 + return fmt.Errorf("no repo row found for did=%s rkey=%s", did, oldRkey) 30 + } 31 + 32 + if _, err := tx.Exec( 33 + `update pipelines set repo_name = ? where repo_owner = ? and repo_name = ?`, 34 + newRkey, did, oldRkey, 35 + ); err != nil { 36 + return fmt.Errorf("rename pipelines.repo_name: %w", err) 37 + } 38 + 39 + return nil 40 + } 41 + 42 + func UpdateRepoDisplayName(e Execer, did, rkey, newName string) error { 43 + _, err := e.Exec( 44 + `update repos set name = ? where did = ? and rkey = ?`, 45 + newName, did, rkey, 46 + ) 47 + return err 48 + } 49 + 50 + func RecordRepoRename(e Execer, ownerDid, oldRkey, repoDid string) error { 51 + _, err := e.Exec( 52 + `insert into repo_renames (owner_did, old_rkey, repo_did) 53 + values (?, ?, ?) 54 + on conflict(owner_did, old_rkey) do update set 55 + repo_did = excluded.repo_did, 56 + renamed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`, 57 + ownerDid, oldRkey, repoDid, 58 + ) 59 + return err 60 + } 61 + 62 + func DeleteRepoRename(e Execer, ownerDid, oldRkey string) error { 63 + _, err := e.Exec( 64 + `delete from repo_renames where owner_did = ? and old_rkey = ?`, 65 + ownerDid, oldRkey, 66 + ) 67 + return err 68 + } 69 + 70 + func LookupRepoRename(e Execer, ownerDid, oldRkey string) (*models.Repo, error) { 71 + var repoDid string 72 + err := e.QueryRow( 73 + `select repo_did from repo_renames where owner_did = ? and old_rkey = ?`, 74 + ownerDid, oldRkey, 75 + ).Scan(&repoDid) 76 + if err != nil { 77 + return nil, err 78 + } 79 + 80 + repo, err := GetRepoByDid(e, repoDid) 81 + if err != nil { 82 + return nil, err 83 + } 84 + return repo, nil 85 + } 86 + 18 87 func GetRepos(e Execer, filters ...orm.Filter) ([]models.Repo, error) { 19 88 return GetReposPaginated(e, pagination.Page{}, filters...) 20 89 }
+36
appview/pages/templates/repo/settings/general.html
··· 10 10 {{ template "branchSettings" . }} 11 11 {{ template "defaultLabelSettings" . }} 12 12 {{ template "customLabelSettings" . }} 13 + {{ template "renameRepo" . }} 13 14 {{ template "deleteRepo" . }} 14 15 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 15 16 </div> ··· 212 213 </div> 213 214 <div id="label-operation" class="error"></div> 214 215 </div> 216 + {{ end }} 217 + 218 + {{ define "renameRepo" }} 219 + {{ if and .RepoInfo.Roles.IsOwner .RepoInfo.RepoDid }} 220 + <form hx-post="/{{ $.RepoInfo.FullName }}/settings/rename" hx-swap="none"> 221 + <h2 class="text-sm pb-2 uppercase font-bold">Rename Repository</h2> 222 + <p class="text-gray-500 dark:text-gray-400 mb-2"> 223 + Existing git remotes that use the old name will break. Use the 224 + stable, DID-based URLs below to avoid breakage on future renames. 225 + </p> 226 + <ul class="text-gray-500 dark:text-gray-400 text-sm mb-2 font-mono"> 227 + <li>https://{{ .RepoInfo.Knot }}/{{ .RepoInfo.RepoDid }}</li> 228 + <li>git@{{ .RepoInfo.Knot | stripPort }}:{{ .RepoInfo.RepoDid }}</li> 229 + </ul> 230 + <input 231 + type="text" 232 + class="w-full mb-2" 233 + id="rename-form-name" 234 + name="name" 235 + value="{{ .RepoInfo.Name }}" 236 + required 237 + > 238 + <div id="rename-repo-error" class="text-red-500 dark:text-red-400"></div> 239 + <div class="flex justify-end pt-2"> 240 + <button 241 + type="submit" 242 + class="btn flex items-center gap-2 group" 243 + > 244 + {{ i "pencil" "w-4 h-4" }} 245 + rename 246 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 247 + </button> 248 + </div> 249 + </form> 250 + {{ end }} 215 251 {{ end }} 216 252 217 253 {{ define "deleteRepo" }}
+197
appview/repo/repo.go
··· 22 22 "tangled.org/core/appview/oauth" 23 23 "tangled.org/core/appview/pages" 24 24 "tangled.org/core/appview/reporesolver" 25 + "tangled.org/core/appview/sites" 25 26 "tangled.org/core/appview/validator" 26 27 xrpcclient "tangled.org/core/appview/xrpcclient" 27 28 "tangled.org/core/eventconsumer" ··· 828 829 aturi = "" 829 830 830 831 rp.pages.HxRefresh(w) 832 + } 833 + 834 + func (rp *Repo) RenameRepo(w http.ResponseWriter, r *http.Request) { 835 + l := rp.logger.With("handler", "RenameRepo") 836 + noticeId := "rename-repo-error" 837 + 838 + user := rp.oauth.GetMultiAccountUser(r) 839 + f, err := rp.repoResolver.Resolve(r) 840 + if err != nil { 841 + l.Error("failed to get repo and knot", "err", err) 842 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 843 + return 844 + } 845 + l = l.With("did", user.Did, "rkey", f.Rkey, "oldName", f.Name) 846 + 847 + if f.RepoDid == "" { 848 + rp.pages.Notice(w, noticeId, "This repository's knot has not completed the DID migration; rename is unavailable.") 849 + return 850 + } 851 + 852 + newName, err := validateRenameInput(f.Name, r.FormValue("name")) 853 + if err != nil { 854 + rp.pages.Notice(w, noticeId, err.Error()) 855 + return 856 + } 857 + newRkey := strings.ToLower(newName) 858 + l = l.With("newName", newName, "newRkey", newRkey) 859 + 860 + atpClient, err := rp.oauth.AuthorizedClient(r) 861 + if err != nil { 862 + l.Error("failed to get authorized client", "err", err) 863 + rp.pages.Notice(w, noticeId, "Failed to authorize. Try again later.") 864 + return 865 + } 866 + 867 + newRepo := *f 868 + newRepo.Name = newName 869 + newRepo.Rkey = newRkey 870 + newRepo.Created = time.Now() 871 + record := newRepo.AsRecord() 872 + 873 + if newRkey == f.Rkey { 874 + ex, err := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, f.Rkey) 875 + if err != nil { 876 + l.Error("failed to fetch existing record", "err", err) 877 + rp.pages.Notice(w, noticeId, "Failed to read repository record from PDS.") 878 + return 879 + } 880 + 881 + _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 882 + Collection: tangled.RepoNSID, 883 + Repo: f.Did, 884 + Rkey: f.Rkey, 885 + SwapRecord: ex.Cid, 886 + Record: &lexutil.LexiconTypeDecoder{ 887 + Val: &record, 888 + }, 889 + }) 890 + if err != nil { 891 + l.Error("failed to update display name on PDS", "err", err) 892 + rp.pages.Notice(w, noticeId, "Failed to save display name to PDS.") 893 + return 894 + } 895 + l.Info("updated display name on PDS") 896 + 897 + if err := db.UpdateRepoDisplayName(rp.db, f.Did, f.Rkey, newName); err != nil { 898 + l.Error("optimistic display name update failed", "err", err) 899 + } 900 + } else { 901 + ex, getErr := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, newRkey) 902 + switch { 903 + case getErr != nil: 904 + _, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{ 905 + Collection: tangled.RepoNSID, 906 + Repo: f.Did, 907 + Rkey: &newRkey, 908 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 909 + }) 910 + if err != nil { 911 + l.Error("failed to write rename to PDS", "err", err) 912 + rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.") 913 + return 914 + } 915 + l.Info("wrote rename-create to PDS; old record retained as alias") 916 + 917 + default: 918 + existing, ok := ex.Value.Val.(*tangled.Repo) 919 + if !ok || existing.RepoDid == nil || *existing.RepoDid != f.RepoDid { 920 + rp.pages.Notice(w, noticeId, fmt.Sprintf("You already have a repository named %q.", newRkey)) 921 + return 922 + } 923 + _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 924 + Collection: tangled.RepoNSID, 925 + Repo: f.Did, 926 + Rkey: newRkey, 927 + SwapRecord: ex.Cid, 928 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 929 + }) 930 + if err != nil { 931 + l.Error("failed to rewrite rename-back record on PDS", "err", err) 932 + rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.") 933 + return 934 + } 935 + l.Info("rewrote rename-back record on PDS over prior alias") 936 + } 937 + 938 + tx, err := rp.db.Begin() 939 + if err != nil { 940 + l.Error("failed to begin rename tx", "err", err) 941 + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 942 + return 943 + } 944 + defer tx.Rollback() 945 + 946 + if err := db.RenameRepo(tx, f.Did, f.Rkey, newRkey, newName); err != nil { 947 + l.Error("optimistic rename failed", "err", err) 948 + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 949 + return 950 + } 951 + if err := db.RecordRepoRename(tx, f.Did, f.Rkey, f.RepoDid); err != nil { 952 + l.Error("failed to record rename history", "err", err) 953 + } 954 + if err := db.DeleteRepoRename(tx, f.Did, newRkey); err != nil { 955 + l.Error("failed to clear stale rename hint", "err", err) 956 + } 957 + if err := tx.Commit(); err != nil { 958 + l.Error("failed to commit rename tx", "err", err) 959 + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 960 + return 961 + } 962 + } 963 + 964 + oldRepo := *f 965 + rp.notifier.RenameRepo(r.Context(), syntax.DID(user.Did), &oldRepo, &newRepo) 966 + 967 + if newRkey != f.Rkey { 968 + rp.migrateSiteOnRename(r.Context(), f, newRkey) 969 + } 970 + 971 + rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 972 + } 973 + 974 + func validateRenameInput(currentName, raw string) (string, error) { 975 + newName := strings.TrimSpace(raw) 976 + if newName == "" { 977 + return "", errors.New("Repository name cannot be empty.") 978 + } 979 + if err := models.ValidateRepoName(newName); err != nil { 980 + return "", err 981 + } 982 + newName = models.StripGitExt(newName) 983 + if newName == currentName { 984 + return "", errors.New("New name matches the current name.") 985 + } 986 + return newName, nil 987 + } 988 + 989 + func (rp *Repo) migrateSiteOnRename(ctx context.Context, oldRepo *models.Repo, newRkey string) { 990 + l := rp.logger.With("handler", "migrateSiteOnRename", "repo_did", oldRepo.RepoDid) 991 + 992 + siteConfig, err := db.GetRepoSiteConfig(rp.db, oldRepo.RepoDid) 993 + if err != nil || siteConfig == nil { 994 + return 995 + } 996 + 997 + if !rp.cfClient.Enabled() { 998 + return 999 + } 1000 + 1001 + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, oldRepo.Did) 1002 + 1003 + go func() { 1004 + bgCtx := context.Background() 1005 + oldRkey := oldRepo.Rkey 1006 + 1007 + if err := sites.Delete(bgCtx, rp.cfClient, oldRepo.Did, oldRkey); err != nil { 1008 + l.Error("sites: failed to delete old R2 prefix", "oldRkey", oldRkey, "err", err) 1009 + } 1010 + 1011 + newRepo := *oldRepo 1012 + newRepo.Rkey = newRkey 1013 + if deployErr := sites.Deploy(bgCtx, rp.cfClient, rp.config, &newRepo, siteConfig.Branch, siteConfig.Dir); deployErr != nil { 1014 + l.Error("sites: redeploy after rename failed", "err", deployErr) 1015 + } 1016 + 1017 + if ownerClaim != nil { 1018 + if err := sites.DeleteDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRkey); err != nil { 1019 + l.Error("sites: failed to remove old KV mapping", "oldRkey", oldRkey, "err", err) 1020 + } 1021 + if err := sites.PutDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRepo.Did, newRkey, siteConfig.IsIndex); err != nil { 1022 + l.Error("sites: failed to write new KV mapping", "newRkey", newRkey, "err", err) 1023 + } 1024 + } 1025 + 1026 + l.Info("sites: migrated on rename", "oldRkey", oldRkey, "newRkey", newRkey) 1027 + }() 831 1028 } 832 1029 833 1030 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+15 -10
appview/state/state.go
··· 425 425 return 426 426 } 427 427 428 + atpClient, err := s.oauth.AuthorizedClient(r) 429 + if err != nil { 430 + l.Error("failed to get authorized client", "err", err) 431 + s.pages.Notice(w, "repo", "Failed to authorize. Try again later.") 432 + return 433 + } 434 + 435 + if rkeyOccupied(r.Context(), atpClient, user.Did, rkey) { 436 + l.Info("rkey occupied by prior rename alias") 437 + 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)) 438 + return 439 + } 440 + 428 441 client, err := s.oauth.ServiceClient( 429 442 r, 430 443 oauth.WithService(domain), ··· 509 522 }() 510 523 } 511 524 512 - atpClient, err := s.oauth.AuthorizedClient(r) 513 - if err != nil { 514 - l.Info("PDS write failed", "err", err) 515 - cleanupKnot() 516 - s.pages.Notice(w, "repo", "Failed to write record to PDS.") 517 - return 518 - } 519 - 520 - _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 525 + _, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{ 521 526 Collection: tangled.RepoNSID, 522 527 Repo: user.Did, 523 - Rkey: rkey, 528 + Rkey: &rkey, 524 529 Record: &lexutil.LexiconTypeDecoder{ 525 530 Val: &record, 526 531 },
+96 -22
knotserver/db/db.go
··· 19 19 logger *slog.Logger 20 20 } 21 21 22 + type Querier interface { 23 + QueryRow(query string, args ...any) *sql.Row 24 + Exec(query string, args ...any) (sql.Result, error) 25 + } 26 + 22 27 func Setup(ctx context.Context, dbPath string) (*DB, error) { 23 28 // https://github.com/mattn/go-sqlite3#connection-string 24 29 opts := []string{ ··· 130 135 return nil, err 131 136 } 132 137 138 + if err := orm.RunMigration(conn, logger, "add-repo-aliases", func(tx *sql.Tx) error { 139 + _, mErr := tx.ExecContext(ctx, ` 140 + create table if not exists repo_aliases ( 141 + owner_did text not null, 142 + rkey text not null, 143 + repo_did text not null, 144 + rev text not null, 145 + primary key (owner_did, rkey) 146 + ); 147 + create index if not exists idx_repo_aliases_repo_did on repo_aliases(repo_did); 148 + 149 + insert or ignore into repo_aliases (owner_did, rkey, repo_did, rev) 150 + select owner_did, repo_name, repo_did, '1_' || created_at 151 + from repo_keys 152 + where owner_did is not null and repo_name is not null and repo_did is not null; 153 + `) 154 + return mErr 155 + }); err != nil { 156 + return nil, err 157 + } 158 + 159 + if err := orm.RunMigration(conn, logger, "drop-at-uri-from-repo-keys", func(tx *sql.Tx) error { 160 + _, mErr := tx.ExecContext(ctx, ` 161 + create table repo_keys_new ( 162 + repo_did text primary key, 163 + signing_key blob, 164 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 165 + owner_did text, 166 + repo_name text, 167 + key_type text not null default 'k256' 168 + ); 169 + insert into repo_keys_new (repo_did, signing_key, created_at, owner_did, repo_name, key_type) 170 + select repo_did, signing_key, created_at, owner_did, repo_name, key_type 171 + from repo_keys; 172 + drop table repo_keys; 173 + alter table repo_keys_new rename to repo_keys; 174 + create unique index if not exists idx_repo_keys_owner_repo 175 + on repo_keys(owner_did, repo_name); 176 + `) 177 + return mErr 178 + }); err != nil { 179 + return nil, err 180 + } 181 + 133 182 return &DB{ 134 183 db: db, 135 184 logger: logger, 136 185 }, nil 137 186 } 138 187 139 - func (d *DB) StoreRepoKey(repoDid string, signingKey []byte, ownerDid, repoName, atUri string) error { 140 - _, err := d.db.Exec( 141 - `INSERT INTO repo_keys (repo_did, signing_key, owner_did, repo_name, at_uri, key_type) VALUES (?, ?, ?, ?, ?, 'k256')`, 142 - repoDid, signingKey, ownerDid, repoName, atUri, 143 - ) 144 - return err 188 + func (d *DB) StoreRepoKey(repoDid string, signingKey []byte, ownerDid, repoName string) error { 189 + return d.storeRepoKeyRow(repoDid, signingKey, ownerDid, repoName, "k256") 145 190 } 146 191 147 - func (d *DB) StoreRepoDidWeb(repoDid, ownerDid, repoName, atUri string) error { 148 - _, err := d.db.Exec( 149 - `INSERT INTO repo_keys (repo_did, signing_key, owner_did, repo_name, at_uri, key_type) VALUES (?, NULL, ?, ?, ?, 'web')`, 150 - repoDid, ownerDid, repoName, atUri, 192 + func (d *DB) StoreRepoDidWeb(repoDid, ownerDid, repoName string) error { 193 + return d.storeRepoKeyRow(repoDid, nil, ownerDid, repoName, "web") 194 + } 195 + 196 + func (d *DB) storeRepoKeyRow(repoDid string, signingKey []byte, ownerDid, repoName, keyType string) (err error) { 197 + tx, err := d.db.Begin() 198 + if err != nil { 199 + return err 200 + } 201 + defer func() { 202 + if err != nil { 203 + tx.Rollback() 204 + return 205 + } 206 + err = tx.Commit() 207 + }() 208 + 209 + if _, err = tx.Exec( 210 + `INSERT INTO repo_keys (repo_did, signing_key, owner_did, repo_name, key_type) VALUES (?, ?, ?, ?, ?)`, 211 + repoDid, signingKey, ownerDid, repoName, keyType, 212 + ); err != nil { 213 + return err 214 + } 215 + 216 + _, err = tx.Exec( 217 + `INSERT INTO repo_aliases (owner_did, rkey, repo_did, rev) 218 + VALUES (?, ?, ?, '0_' || strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 219 + ON CONFLICT(owner_did, rkey) DO NOTHING`, 220 + ownerDid, repoName, repoDid, 151 221 ) 152 222 return err 153 223 } ··· 163 233 return count > 0, err 164 234 } 165 235 166 - func (d *DB) GetRepoDid(ownerDid, repoName string) (string, error) { 236 + func (d *DB) GetRepoDid(ownerDid, rkey string) (string, error) { 167 237 var repoDid string 168 238 err := d.db.QueryRow( 169 - `SELECT repo_did FROM repo_keys WHERE owner_did = ? AND repo_name = ?`, 170 - ownerDid, repoName, 239 + `SELECT repo_did FROM repo_aliases WHERE owner_did = ? AND rkey = ?`, 240 + ownerDid, rkey, 171 241 ).Scan(&repoDid) 172 242 return repoDid, err 173 243 } 174 244 175 - func (d *DB) GetRepoKeyOwner(repoDid string) (ownerDid string, repoName string, err error) { 176 - var nullOwner, nullName sql.NullString 177 - err = d.db.QueryRow( 178 - `SELECT owner_did, repo_name FROM repo_keys WHERE repo_did = ?`, 245 + func (d *DB) GetRepoKeyOwner(repoDid string) (string, string, error) { 246 + return GetRepoKeyOwner(d.db, repoDid) 247 + } 248 + 249 + func GetRepoKeyOwner(q Querier, repoDid string) (ownerDid string, repoName string, err error) { 250 + err = q.QueryRow( 251 + `SELECT owner_did, rkey FROM repo_aliases 252 + WHERE repo_did = ? 253 + ORDER BY rev DESC 254 + LIMIT 1`, 179 255 repoDid, 180 - ).Scan(&nullOwner, &nullName) 256 + ).Scan(&ownerDid, &repoName) 181 257 if err != nil { 182 258 return 183 259 } 184 - if !nullOwner.Valid || !nullName.Valid || nullOwner.String == "" || nullName.String == "" { 185 - err = fmt.Errorf("repo_keys row for %s has empty or null owner_did or repo_name", repoDid) 260 + if ownerDid == "" || repoName == "" { 261 + err = fmt.Errorf("repo_aliases row for %s has empty owner_did or rkey", repoDid) 186 262 return 187 263 } 188 - ownerDid = nullOwner.String 189 - repoName = nullName.String 190 264 return 191 265 } 192 266
+3 -4
knotserver/db/didassign.go
··· 3 3 const RepoDIDAssignNSID = "sh.tangled.repo.didAssign" 4 4 5 5 type RepoDIDAssign struct { 6 - OwnerDid string `json:"ownerDid"` 7 - RepoName string `json:"repoName"` 8 - RepoDid string `json:"repoDid"` 9 - OldRepoAt string `json:"oldRepoAt,omitempty"` 6 + OwnerDid string `json:"ownerDid"` 7 + RepoName string `json:"repoName"` 8 + RepoDid string `json:"repoDid"` 10 9 }
+4 -5
knotserver/db/events.go
··· 31 31 return err 32 32 } 33 33 34 - func (d *DB) EmitDIDAssign(n *notifier.Notifier, ownerDid, repoName, repoDid, oldRepoAt string) error { 34 + func (d *DB) EmitDIDAssign(n *notifier.Notifier, ownerDid, repoName, repoDid string) error { 35 35 payload := RepoDIDAssign{ 36 - OwnerDid: ownerDid, 37 - RepoName: repoName, 38 - RepoDid: repoDid, 39 - OldRepoAt: oldRepoAt, 36 + OwnerDid: ownerDid, 37 + RepoName: repoName, 38 + RepoDid: repoDid, 40 39 } 41 40 42 41 eventJson, err := json.Marshal(payload)
+60
knotserver/db/repo_aliases.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + ) 7 + 8 + type RepoAlias struct { 9 + OwnerDid string 10 + Rkey string 11 + RepoDid string 12 + Rev string 13 + } 14 + 15 + func (d *DB) UpsertRepoAlias(a RepoAlias) error { 16 + _, err := d.db.Exec( 17 + `insert into repo_aliases (owner_did, rkey, repo_did, rev) 18 + values (?, ?, ?, ?) 19 + on conflict(owner_did, rkey) do update set 20 + repo_did = excluded.repo_did, 21 + rev = excluded.rev 22 + where excluded.rev > repo_aliases.rev`, 23 + a.OwnerDid, a.Rkey, a.RepoDid, a.Rev, 24 + ) 25 + return err 26 + } 27 + 28 + func (d *DB) DeleteRepoAlias(ownerDid, rkey string) error { 29 + _, err := d.db.Exec( 30 + `delete from repo_aliases where owner_did = ? and rkey = ?`, 31 + ownerDid, rkey, 32 + ) 33 + return err 34 + } 35 + 36 + func (d *DB) ResolveAlias(ownerDid, rkey string) (*RepoAlias, error) { 37 + var a RepoAlias 38 + err := d.db.QueryRow( 39 + `select owner_did, rkey, repo_did, rev from repo_aliases where owner_did = ? and rkey = ?`, 40 + ownerDid, rkey, 41 + ).Scan(&a.OwnerDid, &a.Rkey, &a.RepoDid, &a.Rev) 42 + if errors.Is(err, sql.ErrNoRows) { 43 + return nil, nil 44 + } 45 + if err != nil { 46 + return nil, err 47 + } 48 + return &a, nil 49 + } 50 + 51 + func (d *DB) CurrentRkey(repoDid string) (ownerDid string, rkey string, err error) { 52 + err = d.db.QueryRow( 53 + `select owner_did, rkey from repo_aliases 54 + where repo_did = ? 55 + order by rev desc 56 + limit 1`, 57 + repoDid, 58 + ).Scan(&ownerDid, &rkey) 59 + return 60 + }
+13 -7
knotserver/git.go
··· 26 26 return repoPath, repoName, nil 27 27 } 28 28 29 - repoDid, err := h.db.GetRepoDid(did, name) 30 - if err == nil { 31 - repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 29 + alias, err := h.db.ResolveAlias(did, name) 30 + if err == nil && alias != nil { 31 + repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, alias.RepoDid) 32 32 if resolveErr == nil { 33 33 return repoPath, name, nil 34 34 } ··· 44 44 return repoPath, name, nil 45 45 } 46 46 47 + func (h *Knot) repoNotFound(w http.ResponseWriter, r *http.Request) { 48 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 49 + w.WriteHeader(http.StatusNotFound) 50 + fmt.Fprint(w, "repository not found\n") 51 + } 52 + 47 53 func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 48 54 repoPath, name, err := h.resolveRepoPath(r) 49 55 if err != nil { 50 - gitError(w, "repository not found", http.StatusNotFound) 56 + h.repoNotFound(w, r) 51 57 h.l.Error("git: failed to resolve repo path", "handler", "InfoRefs", "error", err) 52 58 return 53 59 } ··· 81 87 func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 82 88 repo, _, err := h.resolveRepoPath(r) 83 89 if err != nil { 84 - gitError(w, "repository not found", http.StatusNotFound) 90 + h.repoNotFound(w, r) 85 91 h.l.Error("git: failed to resolve repo path", "handler", "UploadArchive", "error", err) 86 92 return 87 93 } ··· 126 132 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 127 133 repo, _, err := h.resolveRepoPath(r) 128 134 if err != nil { 129 - gitError(w, "repository not found", http.StatusNotFound) 135 + h.repoNotFound(w, r) 130 136 h.l.Error("git: failed to resolve repo path", "handler", "UploadPack", "error", err) 131 137 return 132 138 } ··· 173 179 func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 174 180 _, name, err := h.resolveRepoPath(r) 175 181 if err != nil { 176 - gitError(w, "repository not found", http.StatusNotFound) 182 + h.repoNotFound(w, r) 177 183 h.l.Error("git: failed to resolve repo path", "handler", "ReceivePack", "error", err) 178 184 return 179 185 }
+65
knotserver/git_test.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "testing" 7 + 8 + "tangled.org/core/knotserver/db" 9 + ) 10 + 11 + func newTestKnotDB(t *testing.T) *db.DB { 12 + t.Helper() 13 + path := filepath.Join(t.TempDir(), "test.db") 14 + d, err := db.Setup(context.Background(), path) 15 + if err != nil { 16 + t.Fatalf("db.Setup: %v", err) 17 + } 18 + return d 19 + } 20 + 21 + func TestAliasResolvesOriginalName(t *testing.T) { 22 + d := newTestKnotDB(t) 23 + if err := d.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "foo"); err != nil { 24 + t.Fatalf("StoreRepoKey: %v", err) 25 + } 26 + got, err := d.GetRepoDid("did:plc:akshay", "foo") 27 + if err != nil { 28 + t.Fatalf("GetRepoDid: %v", err) 29 + } 30 + if got != "did:plc:repo1" { 31 + t.Errorf("repoDid = %q, want did:plc:repo1", got) 32 + } 33 + } 34 + 35 + func TestAliasUpsertRespectsRevOrdering(t *testing.T) { 36 + d := newTestKnotDB(t) 37 + if err := d.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "foo"); err != nil { 38 + t.Fatalf("StoreRepoKey: %v", err) 39 + } 40 + if err := d.UpsertRepoAlias(db.RepoAlias{ 41 + OwnerDid: "did:plc:akshay", 42 + Rkey: "bar", 43 + RepoDid: "did:plc:repo1", 44 + Rev: "3laaaaaaaaaab", 45 + }); err != nil { 46 + t.Fatalf("UpsertRepoAlias bar: %v", err) 47 + } 48 + 49 + _, current, err := d.CurrentRkey("did:plc:repo1") 50 + if err != nil { 51 + t.Fatalf("CurrentRkey: %v", err) 52 + } 53 + if current != "bar" { 54 + t.Errorf("current rkey = %q, want bar", current) 55 + } 56 + 57 + fooDid, err := d.GetRepoDid("did:plc:akshay", "foo") 58 + if err != nil || fooDid != "did:plc:repo1" { 59 + t.Errorf("old rkey lookup: got (%q, %v), want did:plc:repo1", fooDid, err) 60 + } 61 + barDid, err := d.GetRepoDid("did:plc:akshay", "bar") 62 + if err != nil || barDid != "did:plc:repo1" { 63 + t.Errorf("new rkey lookup: got (%q, %v), want did:plc:repo1", barDid, err) 64 + } 65 + }
+96 -19
knotserver/ingester.go
··· 18 18 "tangled.org/core/appview/models" 19 19 "tangled.org/core/knotserver/db" 20 20 "tangled.org/core/knotserver/git" 21 + knotxrpc "tangled.org/core/knotserver/xrpc" 21 22 "tangled.org/core/log" 22 23 "tangled.org/core/rbac" 23 24 "tangled.org/core/workflow" ··· 98 99 return nil, fmt.Errorf("ignoring pull record: target repo is nil") 99 100 } 100 101 102 + l := log.FromContext(ctx).With("handler", "validatePullRecord") 103 + l = l.With("target_repo", record.Target.Repo) 104 + l = l.With("target_branch", record.Target.Branch) 105 + 101 106 if record.Source == nil { 102 107 return nil, fmt.Errorf("ignoring pull record: not a branch-based pull request") 103 108 } 104 109 105 - if record.Source.Repo != nil || record.Source.RepoDid != nil { 110 + if record.Source.Repo != nil { 106 111 return nil, fmt.Errorf("ignoring pull record: fork based pull") 107 112 } 108 113 109 114 var repoPath, ownerDid, repoName, repoDid string 110 115 switch { 111 - case record.Target.RepoDid != nil && *record.Target.RepoDid != "": 112 - repoDid = *record.Target.RepoDid 116 + case strings.HasPrefix(record.Target.Repo, "did:"): 117 + repoDid = record.Target.Repo 113 118 var lookupErr error 114 119 repoPath, ownerDid, repoName, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 115 120 if lookupErr != nil { 116 121 return nil, fmt.Errorf("unknown target repo DID %s: %w", repoDid, lookupErr) 117 122 } 118 123 119 - case record.Target.Repo != nil: 124 + case strings.Contains(record.Target.Repo, "/"): 120 125 // TODO: get rid of this PDS fetch once all repos have DIDs 121 - repoAt, parseErr := syntax.ParseATURI(*record.Target.Repo) 126 + repoAt, parseErr := syntax.ParseATURI(record.Target.Repo) 122 127 if parseErr != nil { 123 128 return nil, fmt.Errorf("failed to parse ATURI: %w", parseErr) 124 129 } ··· 137 142 return nil, fmt.Errorf("failed to resolve repo: %w", getErr) 138 143 } 139 144 140 - repo := resp.Value.Val.(*tangled.Repo) 145 + repo, ok := resp.Value.Val.(*tangled.Repo) 146 + if !ok { 147 + return nil, fmt.Errorf("record at %s is not a tangled.Repo", repoAt) 148 + } 141 149 142 150 if repo.Knot != h.c.Server.Hostname { 143 151 return nil, fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 144 152 } 145 153 146 154 ownerDid = ident.DID.String() 147 - repoName = repo.Name 155 + repoName = repoAt.RecordKey().String() 148 156 149 157 repoDid, didErr := h.db.GetRepoDid(ownerDid, repoName) 150 158 if didErr != nil { ··· 158 166 } 159 167 160 168 default: 161 - return nil, fmt.Errorf("ignoring pull record: target has neither repo nor repoDid") 169 + return nil, fmt.Errorf("ignoring pull record: target repo has unrecognized format: %s", record.Target.Repo) 162 170 } 163 171 164 172 _, err := git.Open(repoPath, record.Source.Branch) ··· 365 373 366 374 var rbacResource string 367 375 switch { 368 - case record.RepoDid != nil && *record.RepoDid != "": 369 - ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(*record.RepoDid) 376 + case strings.HasPrefix(record.Repo, "did:"): 377 + ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(record.Repo) 370 378 if lookupErr != nil { 371 - return fmt.Errorf("unknown repo DID %s: %w", *record.RepoDid, lookupErr) 379 + return fmt.Errorf("unknown repo DID %s: %w", record.Repo, lookupErr) 372 380 } 373 381 if ownerDid != did { 374 - return fmt.Errorf("collaborator record author %s does not own repo %s", did, *record.RepoDid) 382 + return fmt.Errorf("collaborator record author %s does not own repo %s", did, record.Repo) 375 383 } 376 - rbacResource = *record.RepoDid 384 + rbacResource = record.Repo 377 385 378 - case record.Repo != nil: 386 + case strings.Contains(record.Repo, "/"): 379 387 // TODO: get rid of this PDS fetch once all repos have DIDs 380 - repoAt, parseErr := syntax.ParseATURI(*record.Repo) 388 + repoAt, parseErr := syntax.ParseATURI(record.Repo) 381 389 if parseErr != nil { 382 390 return parseErr 383 391 } ··· 396 404 return getErr 397 405 } 398 406 399 - repo := resp.Value.Val.(*tangled.Repo) 400 - repoDid, didErr := h.db.GetRepoDid(owner.DID.String(), repo.Name) 407 + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { 408 + return fmt.Errorf("record at %s is not a tangled.Repo", repoAt) 409 + } 410 + rkey := repoAt.RecordKey().String() 411 + repoDid, didErr := h.db.GetRepoDid(owner.DID.String(), rkey) 401 412 if didErr != nil { 402 - return fmt.Errorf("failed to resolve repo DID for %s/%s: %w", owner.DID.String(), repo.Name, didErr) 413 + return fmt.Errorf("failed to resolve repo DID for %s/%s: %w", owner.DID.String(), rkey, didErr) 403 414 } 404 415 rbacResource = repoDid 405 416 406 417 default: 407 - return fmt.Errorf("collaborator record has neither repo nor repoDid") 418 + return fmt.Errorf("collaborator record has unrecognized repo format: %s", record.Repo) 408 419 } 409 420 410 421 ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, rbacResource) ··· 470 481 return nil 471 482 } 472 483 484 + func (h *Knot) processRepo(ctx context.Context, event *jmodels.Event) error { 485 + l := log.FromContext(ctx).With("handler", "processRepo", "did", event.Did, "rkey", event.Commit.RKey) 486 + 487 + rkey := strings.TrimSuffix(strings.TrimSpace(event.Commit.RKey), ".git") 488 + if rkey == "" { 489 + return nil 490 + } 491 + 492 + if event.Commit.Operation == jmodels.CommitOperationDelete { 493 + if err := h.db.DeleteRepoAlias(event.Did, rkey); err != nil { 494 + l.Warn("failed to delete repo alias", "err", err) 495 + } 496 + return nil 497 + } 498 + 499 + if event.Commit.Operation != jmodels.CommitOperationCreate && event.Commit.Operation != jmodels.CommitOperationUpdate { 500 + return nil 501 + } 502 + 503 + raw := json.RawMessage(event.Commit.Record) 504 + var record tangled.Repo 505 + if err := json.Unmarshal(raw, &record); err != nil { 506 + return fmt.Errorf("failed to unmarshal repo record: %w", err) 507 + } 508 + 509 + if record.Knot != h.c.Server.Hostname { 510 + return nil 511 + } 512 + if record.RepoDid == nil || *record.RepoDid == "" { 513 + l.Info("skipping repo event without repoDid") 514 + return nil 515 + } 516 + repoDid := *record.RepoDid 517 + 518 + if err := knotxrpc.ValidateRepoName(rkey); err != nil { 519 + l.Warn("skipping repo event with invalid rkey", "repoDid", repoDid, "rkey", rkey, "err", err) 520 + return nil 521 + } 522 + 523 + ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(repoDid) 524 + if lookupErr != nil { 525 + l.Info("skipping repo event for unknown repoDid", "repoDid", repoDid) 526 + return nil 527 + } 528 + if ownerDid != event.Did { 529 + l.Warn("repo event author does not own repoDid", "repoDid", repoDid, "author", event.Did) 530 + return nil 531 + } 532 + 533 + alias := db.RepoAlias{ 534 + OwnerDid: event.Did, 535 + Rkey: rkey, 536 + RepoDid: repoDid, 537 + Rev: event.Commit.Rev, 538 + } 539 + if err := h.db.UpsertRepoAlias(alias); err != nil { 540 + l.Warn("failed to upsert repo alias", "err", err) 541 + return nil 542 + } 543 + 544 + l.Info("recorded repo alias", "repoDid", repoDid, "rkey", rkey, "rev", event.Commit.Rev) 545 + return nil 546 + } 547 + 473 548 func (h *Knot) processMessages(ctx context.Context, event *jmodels.Event) error { 474 549 if event.Kind != jmodels.EventKindCommit { 475 550 return nil ··· 481 556 err = h.processPublicKey(ctx, event) 482 557 case tangled.KnotMemberNSID: 483 558 err = h.processKnotMember(ctx, event) 559 + case tangled.RepoNSID: 560 + err = h.processRepo(ctx, event) 484 561 case tangled.RepoPullNSID: 485 562 err = h.processPull(ctx, event) 486 563 case tangled.RepoCollaboratorNSID:
+223
knotserver/ingester_repo_test.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "sync" 8 + "testing" 9 + 10 + jsmodels "github.com/bluesky-social/jetstream/pkg/models" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/config" 13 + "tangled.org/core/knotserver/db" 14 + "tangled.org/core/log" 15 + ) 16 + 17 + type logRecord struct { 18 + Level slog.Level 19 + Msg string 20 + Attrs map[string]any 21 + } 22 + 23 + type capturingHandler struct { 24 + mu *sync.Mutex 25 + records *[]logRecord 26 + attrs []slog.Attr 27 + } 28 + 29 + func newCapturingHandler() *capturingHandler { 30 + return &capturingHandler{ 31 + mu: &sync.Mutex{}, 32 + records: &[]logRecord{}, 33 + } 34 + } 35 + 36 + func (h *capturingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } 37 + 38 + func (h *capturingHandler) Handle(_ context.Context, r slog.Record) error { 39 + rec := logRecord{Level: r.Level, Msg: r.Message, Attrs: map[string]any{}} 40 + for _, a := range h.attrs { 41 + rec.Attrs[a.Key] = a.Value.Any() 42 + } 43 + r.Attrs(func(a slog.Attr) bool { 44 + rec.Attrs[a.Key] = a.Value.Any() 45 + return true 46 + }) 47 + h.mu.Lock() 48 + *h.records = append(*h.records, rec) 49 + h.mu.Unlock() 50 + return nil 51 + } 52 + 53 + func (h *capturingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 54 + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) 55 + merged = append(merged, h.attrs...) 56 + merged = append(merged, attrs...) 57 + return &capturingHandler{mu: h.mu, records: h.records, attrs: merged} 58 + } 59 + 60 + func (h *capturingHandler) WithGroup(string) slog.Handler { 61 + panic("capturingHandler: WithGroup not supported") 62 + } 63 + 64 + func (h *capturingHandler) snapshot() []logRecord { 65 + h.mu.Lock() 66 + defer h.mu.Unlock() 67 + out := make([]logRecord, len(*h.records)) 68 + copy(out, *h.records) 69 + return out 70 + } 71 + 72 + func newProcessRepoFixture(t *testing.T) (*Knot, context.Context, *capturingHandler) { 73 + t.Helper() 74 + d := newTestKnotDB(t) 75 + cap := newCapturingHandler() 76 + l := slog.New(cap) 77 + ctx := log.IntoContext(context.Background(), l) 78 + 79 + c := &config.Config{ 80 + Server: config.Server{Hostname: "knot.example"}, 81 + } 82 + return &Knot{ 83 + c: c, 84 + db: d, 85 + l: l, 86 + }, ctx, cap 87 + } 88 + 89 + func repoEvent(t *testing.T, authorDid, rkey, rev string, record tangled.Repo, op string) *jsmodels.Event { 90 + t.Helper() 91 + raw, err := json.Marshal(record) 92 + if err != nil { 93 + t.Fatalf("marshal record: %v", err) 94 + } 95 + return &jsmodels.Event{ 96 + Did: authorDid, 97 + Kind: jsmodels.EventKindCommit, 98 + Commit: &jsmodels.Commit{ 99 + Operation: op, 100 + Collection: tangled.RepoNSID, 101 + RKey: rkey, 102 + Rev: rev, 103 + Record: raw, 104 + }, 105 + } 106 + } 107 + 108 + func ptr(s string) *string { return &s } 109 + 110 + func TestProcessRepo_CreateRegistersAlias(t *testing.T) { 111 + h, ctx, _ := newProcessRepoFixture(t) 112 + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { 113 + t.Fatalf("StoreRepoKey: %v", err) 114 + } 115 + 116 + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaab", tangled.Repo{ 117 + Knot: "knot.example", 118 + RepoDid: ptr("did:plc:repo1"), 119 + }, jsmodels.CommitOperationCreate) 120 + if err := h.processRepo(ctx, ev); err != nil { 121 + t.Fatalf("processRepo: %v", err) 122 + } 123 + 124 + _, current, err := h.db.CurrentRkey("did:plc:repo1") 125 + if err != nil { 126 + t.Fatalf("CurrentRkey: %v", err) 127 + } 128 + if current != "bar" { 129 + t.Errorf("current rkey = %q, want bar (highest rev alias)", current) 130 + } 131 + 132 + oldDid, err := h.db.GetRepoDid("did:plc:akshay", "foo") 133 + if err != nil || oldDid != "did:plc:repo1" { 134 + t.Errorf("old rkey foo should still resolve: got (%q, %v)", oldDid, err) 135 + } 136 + } 137 + 138 + func TestProcessRepo_DeleteRemovesAlias(t *testing.T) { 139 + h, ctx, _ := newProcessRepoFixture(t) 140 + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { 141 + t.Fatalf("StoreRepoKey: %v", err) 142 + } 143 + if err := h.db.UpsertRepoAlias(db.RepoAlias{ 144 + OwnerDid: "did:plc:akshay", Rkey: "bar", RepoDid: "did:plc:repo1", Rev: "3laaaaaaaaaab", 145 + }); err != nil { 146 + t.Fatalf("UpsertRepoAlias: %v", err) 147 + } 148 + 149 + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaac", tangled.Repo{}, jsmodels.CommitOperationDelete) 150 + if err := h.processRepo(ctx, ev); err != nil { 151 + t.Fatalf("processRepo: %v", err) 152 + } 153 + 154 + if _, err := h.db.GetRepoDid("did:plc:akshay", "bar"); err == nil { 155 + t.Errorf("bar alias should have been deleted") 156 + } 157 + 158 + _, current, _ := h.db.CurrentRkey("did:plc:repo1") 159 + if current != "foo" { 160 + t.Errorf("current rkey after delete = %q, want foo", current) 161 + } 162 + } 163 + 164 + func TestProcessRepo_MalformedJSONReturnsError(t *testing.T) { 165 + h, ctx, _ := newProcessRepoFixture(t) 166 + 167 + ev := &jsmodels.Event{ 168 + Did: "did:plc:akshay", 169 + Kind: jsmodels.EventKindCommit, 170 + Commit: &jsmodels.Commit{ 171 + Operation: jsmodels.CommitOperationCreate, 172 + Collection: tangled.RepoNSID, 173 + RKey: "rkey1", 174 + Record: []byte("{not valid json"), 175 + }, 176 + } 177 + if err := h.processRepo(ctx, ev); err == nil { 178 + t.Fatalf("processRepo returned nil, want unmarshal error") 179 + } 180 + } 181 + 182 + func TestProcessRepo_NotOwnedRejected(t *testing.T) { 183 + h, ctx, _ := newProcessRepoFixture(t) 184 + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { 185 + t.Fatalf("StoreRepoKey: %v", err) 186 + } 187 + 188 + ev := repoEvent(t, "did:plc:mallory", "pwned", "3laaaaaaaaaab", tangled.Repo{ 189 + Knot: "knot.example", 190 + RepoDid: ptr("did:plc:repo1"), 191 + }, jsmodels.CommitOperationCreate) 192 + if err := h.processRepo(ctx, ev); err != nil { 193 + t.Fatalf("processRepo: %v", err) 194 + } 195 + 196 + _, current, _ := h.db.CurrentRkey("did:plc:repo1") 197 + if current != "foo" { 198 + t.Errorf("current rkey = %q, want foo (mallory's event must be rejected)", current) 199 + } 200 + if _, err := h.db.GetRepoDid("did:plc:mallory", "pwned"); err == nil { 201 + t.Errorf("mallory should not be able to register an alias on alice's repo") 202 + } 203 + } 204 + 205 + func TestProcessRepo_WrongKnotIgnored(t *testing.T) { 206 + h, ctx, _ := newProcessRepoFixture(t) 207 + if err := h.db.StoreRepoKey("did:plc:repo1", []byte("k"), "did:plc:akshay", "foo"); err != nil { 208 + t.Fatalf("StoreRepoKey: %v", err) 209 + } 210 + 211 + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaab", tangled.Repo{ 212 + Knot: "other.example", 213 + RepoDid: ptr("did:plc:repo1"), 214 + }, jsmodels.CommitOperationCreate) 215 + if err := h.processRepo(ctx, ev); err != nil { 216 + t.Fatalf("processRepo: %v", err) 217 + } 218 + 219 + _, current, _ := h.db.CurrentRkey("did:plc:repo1") 220 + if current != "foo" { 221 + t.Errorf("current rkey = %q, want foo (foreign-knot event must be ignored)", current) 222 + } 223 + }
+8 -7
knotserver/internal.go
··· 140 140 } else { 141 141 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid, repoName)) 142 142 if joinErr != nil { 143 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 143 144 w.WriteHeader(http.StatusNotFound) 144 - fmt.Fprintln(w, "repo not found") 145 + fmt.Fprint(w, "repo not found\n") 145 146 return 146 147 } 147 148 if _, statErr := os.Stat(legacyPath); statErr != nil { 149 + l.Info("legacy repo path missing, checking rename history", "owner", ownerDid, "name", repoName) 150 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 148 151 w.WriteHeader(http.StatusNotFound) 149 - l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName) 150 - fmt.Fprintln(w, "repo not found") 152 + fmt.Fprint(w, "repo not found\n") 151 153 return 152 154 } 153 155 repoPath = legacyPath ··· 253 255 } 254 256 255 257 for _, line := range lines { 256 - err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid) 258 + err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid) 257 259 if err != nil { 258 260 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 259 261 } ··· 272 274 writeJSON(w, resp) 273 275 } 274 276 275 - func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error { 277 + func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string) error { 276 278 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 277 279 if resolveErr != nil { 278 280 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) ··· 296 298 Ref: line.Ref, 297 299 CommitterDid: gitUserDid, 298 300 OwnerDid: &ownerDid, 299 - RepoName: repoName, 300 - RepoDid: &repoDid, 301 + Repo: repoDid, 301 302 Meta: &metaRecord, 302 303 } 303 304
+2 -2
knotserver/migrate.go
··· 145 145 l.Warn("could not remove empty owner dir", "path", ownerDir, "error", err) 146 146 } 147 147 148 - if err := d.EmitDIDAssign(n, repo.ownerDid, repo.repoName, repoDid, ""); err != nil { 148 + if err := d.EmitDIDAssign(n, repo.ownerDid, repo.repoName, repoDid); err != nil { 149 149 l.Error("emitting didAssign event failed (non-fatal)", "error", err) 150 150 } 151 151 ··· 170 170 return "", fmt.Errorf("PLC submission: %w", err) 171 171 } 172 172 173 - if err := d.StoreRepoKey(prepared.RepoDid, prepared.SigningKeyRaw, repo.ownerDid, repo.repoName, ""); err != nil { 173 + if err := d.StoreRepoKey(prepared.RepoDid, prepared.SigningKeyRaw, repo.ownerDid, repo.repoName); err != nil { 174 174 return "", fmt.Errorf("storing repo key: %w", err) 175 175 } 176 176
+1
knotserver/server.go
··· 80 80 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 81 81 tangled.PublicKeyNSID, 82 82 tangled.KnotMemberNSID, 83 + tangled.RepoNSID, 83 84 tangled.RepoPullNSID, 84 85 tangled.RepoCollaboratorNSID, 85 86 }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
+19 -6
knotserver/xrpc/create_repo.go
··· 63 63 defaultBranch = *data.DefaultBranch 64 64 } 65 65 66 - if err := validateRepoName(repoName); err != nil { 66 + if err := ValidateRepoName(repoName); err != nil { 67 67 l.Error("creating repo", "error", err.Error()) 68 68 fail(xrpcerr.GenericError(err)) 69 69 return ··· 129 129 } 130 130 repoDid = prepared.RepoDid 131 131 132 - atUri := fmt.Sprintf("at://%s/%s/%s", actorDid, tangled.RepoNSID, data.Rkey) 133 - if err := h.Db.StoreRepoKey(repoDid, prepared.SigningKeyRaw, actorDid.String(), repoName, atUri); err != nil { 132 + if err := h.Db.StoreRepoKey(repoDid, prepared.SigningKeyRaw, actorDid.String(), repoName); err != nil { 134 133 if strings.Contains(err.Error(), "UNIQUE constraint failed") { 135 134 writeError(w, xrpcerr.GenericError(fmt.Errorf("repository %s already being created", repoName)), http.StatusConflict) 136 135 return ··· 188 187 } 189 188 190 189 if data.RepoDid != nil && strings.HasPrefix(*data.RepoDid, "did:web:") { 191 - webAtUri := fmt.Sprintf("at://%s/%s/%s", actorDid, tangled.RepoNSID, data.Rkey) 192 - if err := h.Db.StoreRepoDidWeb(repoDid, actorDid.String(), repoName, webAtUri); err != nil { 190 + if err := h.Db.StoreRepoDidWeb(repoDid, actorDid.String(), repoName); err != nil { 193 191 cleanupAll() 194 192 if strings.Contains(err.Error(), "UNIQUE constraint failed") { 195 193 writeError(w, xrpcerr.GenericError(fmt.Errorf("did:web %s is already in use", repoDid)), http.StatusConflict) ··· 266 264 return nil 267 265 } 268 266 269 - func validateRepoName(name string) error { 267 + var reservedRepoNames = map[string]struct{}{ 268 + "self": {}, 269 + } 270 + 271 + func ValidateRepoName(name string) error { 270 272 // check for path traversal attempts 271 273 if name == "." || name == ".." || 272 274 strings.Contains(name, "/") || strings.Contains(name, "\\") { ··· 279 281 return fmt.Errorf("Repository name contains invalid path sequence") 280 282 } 281 283 284 + if len(name) == 0 { 285 + return fmt.Errorf("Repository name cannot be empty") 286 + } 287 + if len(name) > 100 { 288 + return fmt.Errorf("Repository name must be 100 characters or fewer") 289 + } 290 + 282 291 // then continue with character validation 283 292 for _, char := range name { 284 293 if !((char >= 'a' && char <= 'z') || ··· 292 301 // additional check to prevent multiple sequential dots 293 302 if strings.Contains(name, "..") { 294 303 return fmt.Errorf("Repository name cannot contain sequential dots") 304 + } 305 + 306 + if _, reserved := reservedRepoNames[strings.ToLower(name)]; reserved { 307 + return fmt.Errorf("Repository name %q is reserved", name) 295 308 } 296 309 297 310 // if all checks pass
+5 -2
knotserver/xrpc/delete_branch.go
··· 55 55 return 56 56 } 57 57 58 - repo := resp.Value.Val.(*tangled.Repo) 59 - repoDid, err := x.Db.GetRepoDid(ident.DID.String(), repo.Name) 58 + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { 59 + fail(xrpcerr.RepoNotFoundError) 60 + return 61 + } 62 + repoDid, err := x.Db.GetRepoDid(ident.DID.String(), repoAt.RecordKey().String()) 60 63 if err != nil { 61 64 fail(xrpcerr.RepoNotFoundError) 62 65 return
+5 -2
knotserver/xrpc/hidden_ref.go
··· 61 61 return 62 62 } 63 63 64 - repo := resp.Value.Val.(*tangled.Repo) 65 - repoDid, err := x.Db.GetRepoDid(actorDid.String(), repo.Name) 64 + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { 65 + fail(xrpcerr.RepoNotFoundError) 66 + return 67 + } 68 + repoDid, err := x.Db.GetRepoDid(actorDid.String(), repoAt.RecordKey().String()) 66 69 if err != nil { 67 70 fail(xrpcerr.RepoNotFoundError) 68 71 return
+6 -4
knotserver/xrpc/set_default_branch.go
··· 59 59 return 60 60 } 61 61 62 - repo := resp.Value.Val.(*tangled.Repo) 63 - repoDid, err := x.Db.GetRepoDid(actorDid.String(), repo.Name) 62 + if _, ok := resp.Value.Val.(*tangled.Repo); !ok { 63 + fail(xrpcerr.RepoNotFoundError) 64 + return 65 + } 66 + repoDid, err := x.Db.GetRepoDid(actorDid.String(), repoAt.RecordKey().String()) 64 67 if err != nil { 65 68 fail(xrpcerr.RepoNotFoundError) 66 69 return ··· 92 95 93 96 ownerDid := ident.DID.String() 94 97 refUpdate := tangled.GitRefUpdate{ 95 - RepoDid: repo.RepoDid, 98 + Repo: repoDid, 96 99 OwnerDid: &ownerDid, 97 - RepoName: repo.Name, 98 100 CommitterDid: actorDid.String(), 99 101 } 100 102 eventJson, err := json.Marshal(refUpdate)