Lewis: May this revision serve well! lewis@tangled.org
+923
-90
Diff
round #0
+69
appview/db/repos.go
+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
+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>
···
214
215
</div>
215
216
{{ end }}
216
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 }}
251
+
{{ end }}
252
+
217
253
{{ define "deleteRepo" }}
218
254
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
219
255
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+197
appview/repo/repo.go
+197
appview/repo/repo.go
···
23
23
"tangled.org/core/appview/pages"
24
24
"tangled.org/core/appview/pagination"
25
25
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/sites"
26
27
"tangled.org/core/appview/validator"
27
28
xrpcclient "tangled.org/core/appview/xrpcclient"
28
29
"tangled.org/core/eventconsumer"
···
831
832
rp.pages.HxRefresh(w)
832
833
}
833
834
835
+
func (rp *Repo) RenameRepo(w http.ResponseWriter, r *http.Request) {
836
+
l := rp.logger.With("handler", "RenameRepo")
837
+
noticeId := "rename-repo-error"
838
+
839
+
user := rp.oauth.GetMultiAccountUser(r)
840
+
f, err := rp.repoResolver.Resolve(r)
841
+
if err != nil {
842
+
l.Error("failed to get repo and knot", "err", err)
843
+
rp.pages.Notice(w, noticeId, "Failed to load repository.")
844
+
return
845
+
}
846
+
l = l.With("did", user.Did, "rkey", f.Rkey, "oldName", f.Name)
847
+
848
+
if f.RepoDid == "" {
849
+
rp.pages.Notice(w, noticeId, "This repository's knot has not completed the DID migration; rename is unavailable.")
850
+
return
851
+
}
852
+
853
+
newName, err := validateRenameInput(f.Name, r.FormValue("name"))
854
+
if err != nil {
855
+
rp.pages.Notice(w, noticeId, err.Error())
856
+
return
857
+
}
858
+
newRkey := strings.ToLower(newName)
859
+
l = l.With("newName", newName, "newRkey", newRkey)
860
+
861
+
atpClient, err := rp.oauth.AuthorizedClient(r)
862
+
if err != nil {
863
+
l.Error("failed to get authorized client", "err", err)
864
+
rp.pages.Notice(w, noticeId, "Failed to authorize. Try again later.")
865
+
return
866
+
}
867
+
868
+
newRepo := *f
869
+
newRepo.Name = newName
870
+
newRepo.Rkey = newRkey
871
+
newRepo.Created = time.Now()
872
+
record := newRepo.AsRecord()
873
+
874
+
if newRkey == f.Rkey {
875
+
ex, err := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, f.Rkey)
876
+
if err != nil {
877
+
l.Error("failed to fetch existing record", "err", err)
878
+
rp.pages.Notice(w, noticeId, "Failed to read repository record from PDS.")
879
+
return
880
+
}
881
+
882
+
_, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
883
+
Collection: tangled.RepoNSID,
884
+
Repo: f.Did,
885
+
Rkey: f.Rkey,
886
+
SwapRecord: ex.Cid,
887
+
Record: &lexutil.LexiconTypeDecoder{
888
+
Val: &record,
889
+
},
890
+
})
891
+
if err != nil {
892
+
l.Error("failed to update display name on PDS", "err", err)
893
+
rp.pages.Notice(w, noticeId, "Failed to save display name to PDS.")
894
+
return
895
+
}
896
+
l.Info("updated display name on PDS")
897
+
898
+
if err := db.UpdateRepoDisplayName(rp.db, f.Did, f.Rkey, newName); err != nil {
899
+
l.Error("optimistic display name update failed", "err", err)
900
+
}
901
+
} else {
902
+
ex, getErr := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, newRkey)
903
+
switch {
904
+
case getErr != nil:
905
+
_, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{
906
+
Collection: tangled.RepoNSID,
907
+
Repo: f.Did,
908
+
Rkey: &newRkey,
909
+
Record: &lexutil.LexiconTypeDecoder{Val: &record},
910
+
})
911
+
if err != nil {
912
+
l.Error("failed to write rename to PDS", "err", err)
913
+
rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.")
914
+
return
915
+
}
916
+
l.Info("wrote rename-create to PDS; old record retained as alias")
917
+
918
+
default:
919
+
existing, ok := ex.Value.Val.(*tangled.Repo)
920
+
if !ok || existing.RepoDid == nil || *existing.RepoDid != f.RepoDid {
921
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("You already have a repository named %q.", newRkey))
922
+
return
923
+
}
924
+
_, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
925
+
Collection: tangled.RepoNSID,
926
+
Repo: f.Did,
927
+
Rkey: newRkey,
928
+
SwapRecord: ex.Cid,
929
+
Record: &lexutil.LexiconTypeDecoder{Val: &record},
930
+
})
931
+
if err != nil {
932
+
l.Error("failed to rewrite rename-back record on PDS", "err", err)
933
+
rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.")
934
+
return
935
+
}
936
+
l.Info("rewrote rename-back record on PDS over prior alias")
937
+
}
938
+
939
+
tx, err := rp.db.Begin()
940
+
if err != nil {
941
+
l.Error("failed to begin rename tx", "err", err)
942
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
943
+
return
944
+
}
945
+
defer tx.Rollback()
946
+
947
+
if err := db.RenameRepo(tx, f.Did, f.Rkey, newRkey, newName); err != nil {
948
+
l.Error("optimistic rename failed", "err", err)
949
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
950
+
return
951
+
}
952
+
if err := db.RecordRepoRename(tx, f.Did, f.Rkey, f.RepoDid); err != nil {
953
+
l.Error("failed to record rename history", "err", err)
954
+
}
955
+
if err := db.DeleteRepoRename(tx, f.Did, newRkey); err != nil {
956
+
l.Error("failed to clear stale rename hint", "err", err)
957
+
}
958
+
if err := tx.Commit(); err != nil {
959
+
l.Error("failed to commit rename tx", "err", err)
960
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
961
+
return
962
+
}
963
+
}
964
+
965
+
oldRepo := *f
966
+
rp.notifier.RenameRepo(r.Context(), syntax.DID(user.Did), &oldRepo, &newRepo)
967
+
968
+
if newRkey != f.Rkey {
969
+
rp.migrateSiteOnRename(r.Context(), f, newRkey)
970
+
}
971
+
972
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
973
+
}
974
+
975
+
func validateRenameInput(currentName, raw string) (string, error) {
976
+
newName := strings.TrimSpace(raw)
977
+
if newName == "" {
978
+
return "", errors.New("Repository name cannot be empty.")
979
+
}
980
+
if err := models.ValidateRepoName(newName); err != nil {
981
+
return "", err
982
+
}
983
+
newName = models.StripGitExt(newName)
984
+
if newName == currentName {
985
+
return "", errors.New("New name matches the current name.")
986
+
}
987
+
return newName, nil
988
+
}
989
+
990
+
func (rp *Repo) migrateSiteOnRename(ctx context.Context, oldRepo *models.Repo, newRkey string) {
991
+
l := rp.logger.With("handler", "migrateSiteOnRename", "repo_did", oldRepo.RepoDid)
992
+
993
+
siteConfig, err := db.GetRepoSiteConfig(rp.db, oldRepo.RepoDid)
994
+
if err != nil || siteConfig == nil {
995
+
return
996
+
}
997
+
998
+
if !rp.cfClient.Enabled() {
999
+
return
1000
+
}
1001
+
1002
+
ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, oldRepo.Did)
1003
+
1004
+
go func() {
1005
+
bgCtx := context.Background()
1006
+
oldRkey := oldRepo.Rkey
1007
+
1008
+
if err := sites.Delete(bgCtx, rp.cfClient, oldRepo.Did, oldRkey); err != nil {
1009
+
l.Error("sites: failed to delete old R2 prefix", "oldRkey", oldRkey, "err", err)
1010
+
}
1011
+
1012
+
newRepo := *oldRepo
1013
+
newRepo.Rkey = newRkey
1014
+
if deployErr := sites.Deploy(bgCtx, rp.cfClient, rp.config, &newRepo, siteConfig.Branch, siteConfig.Dir); deployErr != nil {
1015
+
l.Error("sites: redeploy after rename failed", "err", deployErr)
1016
+
}
1017
+
1018
+
if ownerClaim != nil {
1019
+
if err := sites.DeleteDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRkey); err != nil {
1020
+
l.Error("sites: failed to remove old KV mapping", "oldRkey", oldRkey, "err", err)
1021
+
}
1022
+
if err := sites.PutDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRepo.Did, newRkey, siteConfig.IsIndex); err != nil {
1023
+
l.Error("sites: failed to write new KV mapping", "newRkey", newRkey, "err", err)
1024
+
}
1025
+
}
1026
+
1027
+
l.Info("sites: migrated on rename", "oldRkey", oldRkey, "newRkey", newRkey)
1028
+
}()
1029
+
}
1030
+
834
1031
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
835
1032
user := rp.oauth.GetMultiAccountUser(r)
836
1033
l := rp.logger.With("handler", "DeleteRepo")
+15
-10
appview/state/state.go
+15
-10
appview/state/state.go
···
499
499
return
500
500
}
501
501
502
+
atpClient, err := s.oauth.AuthorizedClient(r)
503
+
if err != nil {
504
+
l.Error("failed to get authorized client", "err", err)
505
+
s.pages.Notice(w, "repo", "Failed to authorize. Try again later.")
506
+
return
507
+
}
508
+
509
+
if rkeyOccupied(r.Context(), atpClient, user.Did, rkey) {
510
+
l.Info("rkey occupied by prior rename alias")
511
+
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))
512
+
return
513
+
}
514
+
502
515
client, err := s.oauth.ServiceClient(
503
516
r,
504
517
oauth.WithService(domain),
···
583
596
}()
584
597
}
585
598
586
-
atpClient, err := s.oauth.AuthorizedClient(r)
587
-
if err != nil {
588
-
l.Info("PDS write failed", "err", err)
589
-
cleanupKnot()
590
-
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
591
-
return
592
-
}
593
-
594
-
_, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
599
+
_, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{
595
600
Collection: tangled.RepoNSID,
596
601
Repo: user.Did,
597
-
Rkey: rkey,
602
+
Rkey: &rkey,
598
603
Record: &lexutil.LexiconTypeDecoder{
599
604
Val: &record,
600
605
},
+96
-22
knotserver/db/db.go
+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")
190
+
}
191
+
192
+
func (d *DB) StoreRepoDidWeb(repoDid, ownerDid, repoName string) error {
193
+
return d.storeRepoKeyRow(repoDid, nil, ownerDid, repoName, "web")
145
194
}
146
195
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,
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
-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
+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
+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
+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
+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
+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)
···
369
377
370
378
var rbacResource string
371
379
switch {
372
-
case record.RepoDid != nil && *record.RepoDid != "":
373
-
ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(*record.RepoDid)
380
+
case strings.HasPrefix(record.Repo, "did:"):
381
+
ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(record.Repo)
374
382
if lookupErr != nil {
375
-
return fmt.Errorf("unknown repo DID %s: %w", *record.RepoDid, lookupErr)
383
+
return fmt.Errorf("unknown repo DID %s: %w", record.Repo, lookupErr)
376
384
}
377
385
if ownerDid != did {
378
-
return fmt.Errorf("collaborator record author %s does not own repo %s", did, *record.RepoDid)
386
+
return fmt.Errorf("collaborator record author %s does not own repo %s", did, record.Repo)
379
387
}
380
-
rbacResource = *record.RepoDid
388
+
rbacResource = record.Repo
381
389
382
-
case record.Repo != nil:
390
+
case strings.Contains(record.Repo, "/"):
383
391
// TODO: get rid of this PDS fetch once all repos have DIDs
384
-
repoAt, parseErr := syntax.ParseATURI(*record.Repo)
392
+
repoAt, parseErr := syntax.ParseATURI(record.Repo)
385
393
if parseErr != nil {
386
394
return parseErr
387
395
}
···
400
408
return getErr
401
409
}
402
410
403
-
repo := resp.Value.Val.(*tangled.Repo)
404
-
repoDid, didErr := h.db.GetRepoDid(owner.DID.String(), repo.Name)
411
+
if _, ok := resp.Value.Val.(*tangled.Repo); !ok {
412
+
return fmt.Errorf("record at %s is not a tangled.Repo", repoAt)
413
+
}
414
+
rkey := repoAt.RecordKey().String()
415
+
repoDid, didErr := h.db.GetRepoDid(owner.DID.String(), rkey)
405
416
if didErr != nil {
406
-
return fmt.Errorf("failed to resolve repo DID for %s/%s: %w", owner.DID.String(), repo.Name, didErr)
417
+
return fmt.Errorf("failed to resolve repo DID for %s/%s: %w", owner.DID.String(), rkey, didErr)
407
418
}
408
419
rbacResource = repoDid
409
420
410
421
default:
411
-
return fmt.Errorf("collaborator record has neither repo nor repoDid")
422
+
return fmt.Errorf("collaborator record has unrecognized repo format: %s", record.Repo)
412
423
}
413
424
414
425
ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, rbacResource)
···
474
485
return nil
475
486
}
476
487
488
+
func (h *Knot) processRepo(ctx context.Context, event *jmodels.Event) error {
489
+
l := log.FromContext(ctx).With("handler", "processRepo", "did", event.Did, "rkey", event.Commit.RKey)
490
+
491
+
rkey := strings.TrimSuffix(strings.TrimSpace(event.Commit.RKey), ".git")
492
+
if rkey == "" {
493
+
return nil
494
+
}
495
+
496
+
if event.Commit.Operation == jmodels.CommitOperationDelete {
497
+
if err := h.db.DeleteRepoAlias(event.Did, rkey); err != nil {
498
+
l.Warn("failed to delete repo alias", "err", err)
499
+
}
500
+
return nil
501
+
}
502
+
503
+
if event.Commit.Operation != jmodels.CommitOperationCreate && event.Commit.Operation != jmodels.CommitOperationUpdate {
504
+
return nil
505
+
}
506
+
507
+
raw := json.RawMessage(event.Commit.Record)
508
+
var record tangled.Repo
509
+
if err := json.Unmarshal(raw, &record); err != nil {
510
+
return fmt.Errorf("failed to unmarshal repo record: %w", err)
511
+
}
512
+
513
+
if record.Knot != h.c.Server.Hostname {
514
+
return nil
515
+
}
516
+
if record.RepoDid == nil || *record.RepoDid == "" {
517
+
l.Info("skipping repo event without repoDid")
518
+
return nil
519
+
}
520
+
repoDid := *record.RepoDid
521
+
522
+
if err := knotxrpc.ValidateRepoName(rkey); err != nil {
523
+
l.Warn("skipping repo event with invalid rkey", "repoDid", repoDid, "rkey", rkey, "err", err)
524
+
return nil
525
+
}
526
+
527
+
ownerDid, _, lookupErr := h.db.GetRepoKeyOwner(repoDid)
528
+
if lookupErr != nil {
529
+
l.Info("skipping repo event for unknown repoDid", "repoDid", repoDid)
530
+
return nil
531
+
}
532
+
if ownerDid != event.Did {
533
+
l.Warn("repo event author does not own repoDid", "repoDid", repoDid, "author", event.Did)
534
+
return nil
535
+
}
536
+
537
+
alias := db.RepoAlias{
538
+
OwnerDid: event.Did,
539
+
Rkey: rkey,
540
+
RepoDid: repoDid,
541
+
Rev: event.Commit.Rev,
542
+
}
543
+
if err := h.db.UpsertRepoAlias(alias); err != nil {
544
+
l.Warn("failed to upsert repo alias", "err", err)
545
+
return nil
546
+
}
547
+
548
+
l.Info("recorded repo alias", "repoDid", repoDid, "rkey", rkey, "rev", event.Commit.Rev)
549
+
return nil
550
+
}
551
+
477
552
func (h *Knot) processMessages(ctx context.Context, event *jmodels.Event) error {
478
553
var err error
479
554
switch event.Kind {
···
485
560
err = h.processPublicKey(ctx, event)
486
561
case tangled.KnotMemberNSID:
487
562
err = h.processKnotMember(ctx, event)
563
+
case tangled.RepoNSID:
564
+
err = h.processRepo(ctx, event)
488
565
case tangled.RepoPullNSID:
489
566
err = h.processPull(ctx, event)
490
567
case tangled.RepoCollaboratorNSID:
+223
knotserver/ingester_repo_test.go
+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
+8
-7
knotserver/internal.go
···
139
139
} else {
140
140
legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid.String(), repoName))
141
141
if joinErr != nil {
142
+
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
142
143
w.WriteHeader(http.StatusNotFound)
143
-
fmt.Fprintln(w, "repo not found")
144
+
fmt.Fprint(w, "repo not found\n")
144
145
return
145
146
}
146
147
if _, statErr := os.Stat(legacyPath); statErr != nil {
148
+
l.Info("legacy repo path missing, checking rename history", "owner", ownerDid, "name", repoName)
149
+
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
147
150
w.WriteHeader(http.StatusNotFound)
148
-
l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName)
149
-
fmt.Fprintln(w, "repo not found")
151
+
fmt.Fprint(w, "repo not found\n")
150
152
return
151
153
}
152
154
repoPath = legacyPath
···
252
254
}
253
255
254
256
for _, line := range lines {
255
-
err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid)
257
+
err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid)
256
258
if err != nil {
257
259
l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
258
260
}
···
271
273
writeJSON(w, resp)
272
274
}
273
275
274
-
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error {
276
+
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string) error {
275
277
repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
276
278
if resolveErr != nil {
277
279
return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
···
295
297
Ref: line.Ref,
296
298
CommitterDid: gitUserDid,
297
299
OwnerDid: &ownerDid,
298
-
RepoName: repoName,
299
-
RepoDid: &repoDid,
300
+
Repo: repoDid,
300
301
Meta: &metaRecord,
301
302
}
302
303
+2
-2
knotserver/migrate.go
+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
+1
knotserver/server.go
···
81
81
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
82
82
tangled.PublicKeyNSID,
83
83
tangled.KnotMemberNSID,
84
+
tangled.RepoNSID,
84
85
tangled.RepoPullNSID,
85
86
tangled.RepoCollaboratorNSID,
86
87
}, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
+19
-6
knotserver/xrpc/create_repo.go
+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') ||
···
294
303
return fmt.Errorf("Repository name cannot contain sequential dots")
295
304
}
296
305
306
+
if _, reserved := reservedRepoNames[strings.ToLower(name)]; reserved {
307
+
return fmt.Errorf("Repository name %q is reserved", name)
308
+
}
309
+
297
310
// if all checks pass
298
311
return nil
299
312
}
+5
-2
knotserver/xrpc/delete_branch.go
+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
+6
-4
knotserver/xrpc/set_default_branch.go
+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)
History
1 round
0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
knotserver: rkey-based repo renaming
Lewis: May this revision serve well! <lewis@tangled.org>
no conflicts, ready to merge