diff --git a/.dockerignore b/.dockerignore --- a/.dockerignore +++ b/.dockerignore @@ -11,8 +11,8 @@ .wrangler/ localinfra/certs/root.key -appview/pages/static/* -!appview/pages/static/topbar-search.js +appview/pages/static/tw.css +appview/pages/static/x sites/target sites/.wrangler diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -1190,6 +1190,11 @@ return err } + err = db.RemoveReposByKnot(tx, domain) + if err != nil { + return err + } + if registration.Registered != nil { err = i.Enforcer.RemoveKnot(domain) if err != nil { diff --git a/rbac/rbac_test.go b/rbac/rbac_test.go --- a/rbac/rbac_test.go +++ b/rbac/rbac_test.go @@ -362,6 +362,34 @@ assert.Empty(t, knots) } +func TestRemoveKnotRemovesRepoPolicies(t *testing.T) { + e := setup(t) + + knot := "kelp.example" + owner := "did:plc:akshay" + collaborator := "did:plc:boltless" + repo := "did:plc:akshay/anemone" + + assert.NoError(t, e.AddKnot(knot)) + assert.NoError(t, e.AddKnotOwner(knot, owner)) + assert.NoError(t, e.AddRepo(owner, knot, repo)) + assert.NoError(t, e.AddCollaborator(collaborator, knot, repo)) + + isOwner, err := e.IsKnotOwner(owner, knot) + assert.NoError(t, err) + assert.True(t, isOwner) + + err = e.RemoveKnot(knot) + assert.NoError(t, err) + + isOwner, err = e.IsKnotOwner(owner, knot) + assert.NoError(t, err) + assert.False(t, isOwner) + + assert.Empty(t, e.GetPermissionsInRepo(owner, knot, repo)) + assert.Empty(t, e.GetPermissionsInRepo(collaborator, knot, repo)) +} + func TestRemoveSpindleOwner(t *testing.T) { e := setup(t) diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -2099,6 +2099,34 @@ return err }) + conn.ExecContext(ctx, "pragma foreign_keys = off;") + orm.RunMigration(conn, logger, "cascade-notification-entity-fks", func(tx *sql.Tx) error { + _, err := tx.Exec(` + CREATE TABLE notifications_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_did TEXT NOT NULL, + actor_did TEXT NOT NULL, + type TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, + issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE, + pull_id INTEGER REFERENCES pulls(id) ON DELETE CASCADE + ); + INSERT INTO notifications_new (id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id) + SELECT id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id + FROM notifications; + DROP TABLE notifications; + ALTER TABLE notifications_new RENAME TO notifications; + CREATE INDEX idx_notifications_recipient_created ON notifications(recipient_did, created DESC); + CREATE INDEX idx_notifications_recipient_read ON notifications(recipient_did, read); + `) + return err + }) + conn.ExecContext(ctx, "pragma foreign_keys = on;") + return &DB{ db, logger, diff --git a/appview/db/repos.go b/appview/db/repos.go --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -561,6 +561,11 @@ return err } +func RemoveReposByKnot(e Execer, knot string) error { + _, err := e.Exec(`delete from repos where knot = ?`, knot) + return err +} + func GetRepoSource(e Execer, repoDid string) (string, error) { var nullableSource sql.NullString err := e.QueryRow(`select source from repos where repo_did = ?`, repoDid).Scan(&nullableSource) diff --git a/appview/db/repos_test.go b/appview/db/repos_test.go new file mode 100644 --- /dev/null +++ b/appview/db/repos_test.go @@ -0,0 +1,112 @@ +package db + +import ( + "context" + "path/filepath" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/appview/models" +) + +func TestRemoveReposByKnotCascadesEntities(t *testing.T) { + d := newTestDB(t) + + knot := "kelp.example" + repo := seedRepo(t, d, "did:plc:akshay", knot, "anemone", "anemone", "did:plc:anemone") + + starNotif := &models.Notification{ + RecipientDid: "did:plc:akshay", + ActorDid: "did:plc:boltless", + Type: models.NotificationTypeRepoStarred, + EntityType: "repo", + EntityId: repo.RepoAt().String(), + RepoId: &repo.Id, + } + if err := CreateNotification(d, starNotif); err != nil { + t.Fatalf("CreateNotification repo: %v", err) + } + + tx, err := d.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + issue := &models.Issue{ + Did: "did:plc:akshay", + Rkey: "issue1", + RepoDid: syntax.DID(repo.RepoDid), + Title: "title", + Body: "body", + Open: true, + } + if err := PutIssue(tx, issue); err != nil { + t.Fatalf("PutIssue: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + issueNotif := &models.Notification{ + RecipientDid: "did:plc:akshay", + ActorDid: "did:plc:boltless", + Type: models.NotificationTypeIssueCommented, + EntityType: "issue", + EntityId: issue.AtUri().String(), + IssueId: &issue.Id, + } + if err := CreateNotification(d, issueNotif); err != nil { + t.Fatalf("CreateNotification issue: %v", err) + } + + if err := RemoveReposByKnot(d, knot); err != nil { + t.Fatalf("RemoveReposByKnot: %v", err) + } + + if got := countRows(t, d, "select count(*) from repos where knot = ?", knot); got != 0 { + t.Errorf("repos remaining: got %d, want 0", got) + } + if got := countRows(t, d, "select count(*) from issues where repo_did = ?", repo.RepoDid); got != 0 { + t.Errorf("issues remaining: got %d, want 0", got) + } + if got := countRows(t, d, "select count(*) from notifications"); got != 0 { + t.Errorf("notifications remaining: got %d, want 0", got) + } +} + +func TestMakeReopenPreservesOrphanData(t *testing.T) { + path := filepath.Join(t.TempDir(), "reopen.db") + + d, err := Make(context.Background(), path) + if err != nil { + t.Fatalf("first Make: %v", err) + } + + repo := seedRepo(t, d, "did:plc:akshay", "ghost.example", "anemone", "anemone", "did:plc:anemone") + notif := &models.Notification{ + RecipientDid: "did:plc:akshay", + ActorDid: "did:plc:boltless", + Type: models.NotificationTypeRepoStarred, + EntityType: "repo", + EntityId: repo.RepoAt().String(), + RepoId: &repo.Id, + } + if err := CreateNotification(d, notif); err != nil { + t.Fatalf("CreateNotification: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + d2, err := Make(context.Background(), path) + if err != nil { + t.Fatalf("second Make: %v", err) + } + t.Cleanup(func() { d2.Close() }) + + if got := countRows(t, d2, "select count(*) from repos where knot = ?", "ghost.example"); got != 1 { + t.Errorf("orphan repo lost across reopen: got %d, want 1", got) + } + if got := countRows(t, d2, "select count(*) from notifications"); got != 1 { + t.Errorf("notification lost across reopen: got %d, want 1", got) + } +} diff --git a/appview/knots/knots.go b/appview/knots/knots.go --- a/appview/knots/knots.go +++ b/appview/knots/knots.go @@ -70,9 +70,24 @@ return } + knots := make([]pages.KnotListingParams, 0, len(registrations)) + for i := range registrations { + registration := ®istrations[i] + count, err := db.CountRepos(k.Db, orm.FilterEq("knot", registration.Domain)) + if err != nil { + k.Logger.Error("failed to count knot repos", "err", err, "domain", registration.Domain) + w.WriteHeader(http.StatusInternalServerError) + return + } + knots = append(knots, pages.KnotListingParams{ + Registration: registration, + RepoCount: int(count), + }) + } + k.Pages.Knots(w, pages.KnotsParams{ - LoggedInUser: user, - Registrations: registrations, + LoggedInUser: user, + Knots: knots, }) } @@ -134,6 +149,7 @@ Members: members, Repos: repoMap, IsOwner: true, + RepoCount: len(repos), }) } @@ -269,6 +285,13 @@ ) if err != nil { l.Error("failed to delete registration", "err", err) + fail() + return + } + + err = db.RemoveReposByKnot(tx, domain) + if err != nil { + l.Error("failed to delete repos", "err", err) fail() return } @@ -451,9 +474,17 @@ } updatedRegistration := registrations[0] + count, err := db.CountRepos(k.Db, orm.FilterEq("knot", domain)) + if err != nil { + l.Error("failed to count knot repos", "err", err) + fail() + return + } + w.Header().Set("HX-Reswap", "outerHTML") k.Pages.KnotListing(w, pages.KnotListingParams{ Registration: &updatedRegistration, + RepoCount: int(count), }) } diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -541,9 +541,9 @@ } type KnotsParams struct { - LoggedInUser *oauth.MultiAccountUser - Registrations []models.Registration - Tab string + LoggedInUser *oauth.MultiAccountUser + Knots []KnotListingParams + Tab string } func (p *Pages) Knots(w io.Writer, params KnotsParams) error { @@ -557,6 +557,7 @@ Members []string Repos map[string][]models.Repo IsOwner bool + RepoCount int Tab string } @@ -566,6 +567,7 @@ type KnotListingParams struct { *models.Registration + RepoCount int } func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { diff --git a/appview/pages/templates/knots/dashboard.html b/appview/pages/templates/knots/dashboard.html --- a/appview/pages/templates/knots/dashboard.html +++ b/appview/pages/templates/knots/dashboard.html @@ -43,7 +43,7 @@ {{ end }} {{ if $isOwner }} - {{ block "deleteButton" .Registration }} {{ end }} + {{ block "deleteButton" (dict "Domain" .Registration.Domain "RepoCount" .RepoCount) }} {{ end }} {{ end }} @@ -97,7 +97,7 @@ title="Delete knot" hx-delete="/settings/knots/{{ .Domain }}" hx-swap="outerHTML" - hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" + hx-confirm="{{ template "knots/fragments/deleteConfirm" . }}" hx-headers='{"shouldRedirect": "true"}' > {{ i "trash-2" "w-5 h-5" }} diff --git a/appview/pages/templates/knots/index.html b/appview/pages/templates/knots/index.html --- a/appview/pages/templates/knots/index.html +++ b/appview/pages/templates/knots/index.html @@ -50,7 +50,7 @@

your knots

- {{ range $registration := .Registrations }} + {{ range .Knots }} {{ template "knots/fragments/knotListing" . }} {{ else }}
diff --git a/appview/pages/templates/knots/fragments/knotListing.html b/appview/pages/templates/knots/fragments/knotListing.html --- a/appview/pages/templates/knots/fragments/knotListing.html +++ b/appview/pages/templates/knots/fragments/knotListing.html @@ -59,7 +59,7 @@ hx-delete="/settings/knots/{{ .Domain }}" hx-swap="outerHTML" hx-target="#knot-{{.Id}}" - hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" + hx-confirm="{{ template "knots/fragments/deleteConfirm" . }}" > {{ i "trash-2" "w-5 h-5" }} @@ -80,4 +80,12 @@ {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} +{{ end }} + +{{ define "knots/fragments/deleteConfirm" -}} +{{ if .RepoCount -}} +Unregistering '{{ .Domain }}' will remove {{ plural .RepoCount "repository" "" }} from Tangled. The git data will stay on the knot, but these repos will disappear from Tangled. Are you sure? +{{- else -}} +Are you sure you want to delete the knot '{{ .Domain }}'? +{{- end -}} {{ end }}