+80
pkg/appview/db/delete.go
+80
pkg/appview/db/delete.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"fmt"
7
+
"log/slog"
8
+
)
9
+
10
+
// DeleteUserDataFull performs complete user deletion including non-cascading tables.
11
+
// This is the main function for GDPR account deletion.
12
+
//
13
+
// Order of operations:
14
+
// 1. Delete hold membership data (non-cascading tables)
15
+
// 2. Delete OAuth sessions
16
+
// 3. Delete user (cascades to manifests, tags, stars, repo_pages, etc.)
17
+
//
18
+
// This should be called AFTER remote cleanup (hold services, PDS records)
19
+
// since we need the OAuth tokens to authenticate those requests.
20
+
func DeleteUserDataFull(db *sql.DB, oauthStore *OAuthStore, did string) error {
21
+
slog.Info("Starting full user data deletion", "did", did)
22
+
23
+
// 1. Delete non-cascading hold membership tables
24
+
if err := deleteHoldMembershipData(db, did); err != nil {
25
+
slog.Error("Failed to delete hold membership data", "did", did, "error", err)
26
+
return fmt.Errorf("failed to delete hold membership data: %w", err)
27
+
}
28
+
29
+
// 2. Delete OAuth sessions
30
+
if oauthStore != nil {
31
+
if err := oauthStore.DeleteSessionsForDID(context.Background(), did); err != nil {
32
+
slog.Warn("Failed to delete OAuth sessions", "did", did, "error", err)
33
+
// Continue - not critical
34
+
} else {
35
+
slog.Debug("Deleted OAuth sessions", "did", did)
36
+
}
37
+
}
38
+
39
+
// 3. Delete user (cascades to manifests, tags, stars, annotations, etc.)
40
+
if err := DeleteUserData(db, did); err != nil {
41
+
slog.Error("Failed to delete user data", "did", did, "error", err)
42
+
return fmt.Errorf("failed to delete user data: %w", err)
43
+
}
44
+
45
+
slog.Info("User data deletion completed", "did", did)
46
+
return nil
47
+
}
48
+
49
+
// deleteHoldMembershipData deletes non-cascading hold membership tables.
50
+
// These tables don't have foreign keys to the users table.
51
+
func deleteHoldMembershipData(db *sql.DB, did string) error {
52
+
// Delete from hold_crew_approvals (where user is the approved member)
53
+
result, err := db.Exec(`DELETE FROM hold_crew_approvals WHERE user_did = ?`, did)
54
+
if err != nil {
55
+
return fmt.Errorf("failed to delete crew approvals: %w", err)
56
+
}
57
+
approvalsDeleted, _ := result.RowsAffected()
58
+
59
+
// Delete from hold_crew_denials (where user was denied)
60
+
result, err = db.Exec(`DELETE FROM hold_crew_denials WHERE user_did = ?`, did)
61
+
if err != nil {
62
+
return fmt.Errorf("failed to delete crew denials: %w", err)
63
+
}
64
+
denialsDeleted, _ := result.RowsAffected()
65
+
66
+
// Delete from hold_crew_members (cached crew memberships)
67
+
result, err = db.Exec(`DELETE FROM hold_crew_members WHERE member_did = ?`, did)
68
+
if err != nil {
69
+
return fmt.Errorf("failed to delete crew members: %w", err)
70
+
}
71
+
membersDeleted, _ := result.RowsAffected()
72
+
73
+
slog.Debug("Deleted hold membership data",
74
+
"did", did,
75
+
"approvals_deleted", approvalsDeleted,
76
+
"denials_deleted", denialsDeleted,
77
+
"members_deleted", membersDeleted)
78
+
79
+
return nil
80
+
}
+306
pkg/appview/db/delete_test.go
+306
pkg/appview/db/delete_test.go
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"testing"
6
+
"time"
7
+
)
8
+
9
+
func TestDeleteUserDataFull_DeletesAllData(t *testing.T) {
10
+
db, err := InitDB(":memory:")
11
+
if err != nil {
12
+
t.Fatalf("Failed to init database: %v", err)
13
+
}
14
+
defer db.Close()
15
+
16
+
// Create test user
17
+
testUser := &User{
18
+
DID: "did:plc:test123",
19
+
Handle: "test.bsky.social",
20
+
PDSEndpoint: "https://bsky.social",
21
+
LastSeen: time.Now(),
22
+
}
23
+
if err := UpsertUser(db, testUser); err != nil {
24
+
t.Fatalf("Failed to create user: %v", err)
25
+
}
26
+
27
+
// Create manifest
28
+
_, err = db.Exec(`
29
+
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
30
+
VALUES (?, ?, ?, ?, ?, ?, ?)
31
+
`, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
32
+
"application/vnd.oci.image.manifest.v1+json", time.Now())
33
+
if err != nil {
34
+
t.Fatalf("Failed to create manifest: %v", err)
35
+
}
36
+
37
+
// Create tag
38
+
_, err = db.Exec(`
39
+
INSERT INTO tags (did, repository, tag, digest, created_at)
40
+
VALUES (?, ?, ?, ?, ?)
41
+
`, testUser.DID, "myapp", "latest", "sha256:abc123", time.Now())
42
+
if err != nil {
43
+
t.Fatalf("Failed to create tag: %v", err)
44
+
}
45
+
46
+
// Create hold membership data (non-cascading)
47
+
_, err = db.Exec(`
48
+
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
49
+
VALUES (?, ?, ?, ?)
50
+
`, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
51
+
if err != nil {
52
+
t.Fatalf("Failed to create crew approval: %v", err)
53
+
}
54
+
55
+
_, err = db.Exec(`
56
+
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
57
+
VALUES (?, ?, ?, ?)
58
+
`, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
59
+
if err != nil {
60
+
t.Fatalf("Failed to create crew member: %v", err)
61
+
}
62
+
63
+
// Create OAuth store
64
+
oauthStore := NewOAuthStore(db)
65
+
66
+
// Delete all user data
67
+
err = DeleteUserDataFull(db, oauthStore, testUser.DID)
68
+
if err != nil {
69
+
t.Fatalf("DeleteUserDataFull failed: %v", err)
70
+
}
71
+
72
+
// Verify user was deleted
73
+
var count int
74
+
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
75
+
if err != nil {
76
+
t.Fatalf("Failed to query users: %v", err)
77
+
}
78
+
if count != 0 {
79
+
t.Error("Expected user to be deleted")
80
+
}
81
+
82
+
// Verify manifests were cascade deleted
83
+
err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
84
+
if err != nil {
85
+
t.Fatalf("Failed to query manifests: %v", err)
86
+
}
87
+
if count != 0 {
88
+
t.Error("Expected manifests to be cascade deleted")
89
+
}
90
+
91
+
// Verify tags were cascade deleted
92
+
err = db.QueryRow("SELECT COUNT(*) FROM tags WHERE did = ?", testUser.DID).Scan(&count)
93
+
if err != nil {
94
+
t.Fatalf("Failed to query tags: %v", err)
95
+
}
96
+
if count != 0 {
97
+
t.Error("Expected tags to be cascade deleted")
98
+
}
99
+
100
+
// Verify hold membership data was deleted
101
+
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
102
+
if err != nil {
103
+
t.Fatalf("Failed to query crew approvals: %v", err)
104
+
}
105
+
if count != 0 {
106
+
t.Error("Expected crew approvals to be deleted")
107
+
}
108
+
109
+
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
110
+
if err != nil {
111
+
t.Fatalf("Failed to query crew members: %v", err)
112
+
}
113
+
if count != 0 {
114
+
t.Error("Expected crew members to be deleted")
115
+
}
116
+
}
117
+
118
+
func TestDeleteUserDataFull_DoesNotAffectOtherUsers(t *testing.T) {
119
+
db, err := InitDB(":memory:")
120
+
if err != nil {
121
+
t.Fatalf("Failed to init database: %v", err)
122
+
}
123
+
defer db.Close()
124
+
125
+
// Create two users
126
+
user1 := &User{
127
+
DID: "did:plc:user1",
128
+
Handle: "user1.bsky.social",
129
+
PDSEndpoint: "https://bsky.social",
130
+
LastSeen: time.Now(),
131
+
}
132
+
user2 := &User{
133
+
DID: "did:plc:user2",
134
+
Handle: "user2.bsky.social",
135
+
PDSEndpoint: "https://bsky.social",
136
+
LastSeen: time.Now(),
137
+
}
138
+
if err := UpsertUser(db, user1); err != nil {
139
+
t.Fatalf("Failed to create user1: %v", err)
140
+
}
141
+
if err := UpsertUser(db, user2); err != nil {
142
+
t.Fatalf("Failed to create user2: %v", err)
143
+
}
144
+
145
+
// Create manifests for both users
146
+
for _, user := range []*User{user1, user2} {
147
+
_, err = db.Exec(`
148
+
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
149
+
VALUES (?, ?, ?, ?, ?, ?, ?)
150
+
`, user.DID, "myapp", "sha256:"+user.DID, "did:web:hold.example.com", 2,
151
+
"application/vnd.oci.image.manifest.v1+json", time.Now())
152
+
if err != nil {
153
+
t.Fatalf("Failed to create manifest for %s: %v", user.Handle, err)
154
+
}
155
+
}
156
+
157
+
// Create hold membership data for both users
158
+
for i, user := range []*User{user1, user2} {
159
+
_, err = db.Exec(`
160
+
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
161
+
VALUES (?, ?, ?, ?)
162
+
`, "did:web:hold.example.com", user.DID, fmt.Sprintf("member%d", i+1), `["blob:read"]`)
163
+
if err != nil {
164
+
t.Fatalf("Failed to create crew member for %s: %v", user.Handle, err)
165
+
}
166
+
}
167
+
168
+
oauthStore := NewOAuthStore(db)
169
+
170
+
// Delete only user1's data
171
+
err = DeleteUserDataFull(db, oauthStore, user1.DID)
172
+
if err != nil {
173
+
t.Fatalf("DeleteUserDataFull failed: %v", err)
174
+
}
175
+
176
+
// Verify user1 was deleted
177
+
var count int
178
+
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user1.DID).Scan(&count)
179
+
if err != nil {
180
+
t.Fatalf("Failed to query users: %v", err)
181
+
}
182
+
if count != 0 {
183
+
t.Error("Expected user1 to be deleted")
184
+
}
185
+
186
+
// Verify user2 still exists
187
+
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user2.DID).Scan(&count)
188
+
if err != nil {
189
+
t.Fatalf("Failed to query users: %v", err)
190
+
}
191
+
if count != 1 {
192
+
t.Error("Expected user2 to still exist")
193
+
}
194
+
195
+
// Verify user2's manifests still exist
196
+
err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", user2.DID).Scan(&count)
197
+
if err != nil {
198
+
t.Fatalf("Failed to query manifests: %v", err)
199
+
}
200
+
if count != 1 {
201
+
t.Error("Expected user2's manifest to still exist")
202
+
}
203
+
204
+
// Verify user2's crew membership still exists
205
+
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", user2.DID).Scan(&count)
206
+
if err != nil {
207
+
t.Fatalf("Failed to query crew members: %v", err)
208
+
}
209
+
if count != 1 {
210
+
t.Error("Expected user2's crew membership to still exist")
211
+
}
212
+
}
213
+
214
+
func TestDeleteUserDataFull_HandlesNonExistentUser(t *testing.T) {
215
+
db, err := InitDB(":memory:")
216
+
if err != nil {
217
+
t.Fatalf("Failed to init database: %v", err)
218
+
}
219
+
defer db.Close()
220
+
221
+
oauthStore := NewOAuthStore(db)
222
+
223
+
// Try to delete non-existent user - should not error
224
+
err = DeleteUserDataFull(db, oauthStore, "did:plc:nonexistent")
225
+
if err != nil {
226
+
t.Errorf("Expected no error for non-existent user, got: %v", err)
227
+
}
228
+
}
229
+
230
+
func TestDeleteUserDataFull_WithNilOAuthStore(t *testing.T) {
231
+
db, err := InitDB(":memory:")
232
+
if err != nil {
233
+
t.Fatalf("Failed to init database: %v", err)
234
+
}
235
+
defer db.Close()
236
+
237
+
testUser := &User{
238
+
DID: "did:plc:test123",
239
+
Handle: "test.bsky.social",
240
+
PDSEndpoint: "https://bsky.social",
241
+
LastSeen: time.Now(),
242
+
}
243
+
if err := UpsertUser(db, testUser); err != nil {
244
+
t.Fatalf("Failed to create user: %v", err)
245
+
}
246
+
247
+
// Delete with nil OAuth store - should still work
248
+
err = DeleteUserDataFull(db, nil, testUser.DID)
249
+
if err != nil {
250
+
t.Errorf("Expected no error with nil OAuth store, got: %v", err)
251
+
}
252
+
253
+
// Verify user was deleted
254
+
var count int
255
+
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
256
+
if err != nil {
257
+
t.Fatalf("Failed to query users: %v", err)
258
+
}
259
+
if count != 0 {
260
+
t.Error("Expected user to be deleted")
261
+
}
262
+
}
263
+
264
+
func TestDeleteUserDataFull_DeletesDenials(t *testing.T) {
265
+
db, err := InitDB(":memory:")
266
+
if err != nil {
267
+
t.Fatalf("Failed to init database: %v", err)
268
+
}
269
+
defer db.Close()
270
+
271
+
testUser := &User{
272
+
DID: "did:plc:test123",
273
+
Handle: "test.bsky.social",
274
+
PDSEndpoint: "https://bsky.social",
275
+
LastSeen: time.Now(),
276
+
}
277
+
if err := UpsertUser(db, testUser); err != nil {
278
+
t.Fatalf("Failed to create user: %v", err)
279
+
}
280
+
281
+
// Create denial record
282
+
_, err = db.Exec(`
283
+
INSERT INTO hold_crew_denials (hold_did, user_did, denial_count, next_retry_at, last_denied_at)
284
+
VALUES (?, ?, ?, ?, ?)
285
+
`, "did:web:hold.example.com", testUser.DID, 1, time.Now().Add(24*time.Hour), time.Now())
286
+
if err != nil {
287
+
t.Fatalf("Failed to create crew denial: %v", err)
288
+
}
289
+
290
+
oauthStore := NewOAuthStore(db)
291
+
292
+
err = DeleteUserDataFull(db, oauthStore, testUser.DID)
293
+
if err != nil {
294
+
t.Fatalf("DeleteUserDataFull failed: %v", err)
295
+
}
296
+
297
+
// Verify denial was deleted
298
+
var count int
299
+
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_denials WHERE user_did = ?", testUser.DID).Scan(&count)
300
+
if err != nil {
301
+
t.Fatalf("Failed to query crew denials: %v", err)
302
+
}
303
+
if count != 0 {
304
+
t.Error("Expected crew denials to be deleted")
305
+
}
306
+
}
+344
pkg/appview/handlers/delete.go
+344
pkg/appview/handlers/delete.go
···
1
+
package handlers
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"encoding/json"
7
+
"fmt"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
"sync"
12
+
"time"
13
+
14
+
"atcr.io/pkg/appview/db"
15
+
"atcr.io/pkg/appview/middleware"
16
+
"atcr.io/pkg/atproto"
17
+
"atcr.io/pkg/auth"
18
+
"atcr.io/pkg/auth/oauth"
19
+
)
20
+
21
+
// DeleteAccountRequest represents the GDPR account deletion request
22
+
type DeleteAccountRequest struct {
23
+
DeletePDSRecords bool `json:"delete_pds_records"`
24
+
Confirmation string `json:"confirmation"` // Must be "DELETE <handle>" to confirm
25
+
}
26
+
27
+
// DeleteAccountResponse represents the result of account deletion
28
+
type DeleteAccountResponse struct {
29
+
Success bool `json:"success"`
30
+
AppViewDeleted bool `json:"appview_deleted"`
31
+
PDSDeleted bool `json:"pds_deleted,omitempty"`
32
+
PDSCollections map[string]int `json:"pds_collections_deleted,omitempty"`
33
+
HoldResults []HoldDeleteResult `json:"hold_results"`
34
+
Errors []string `json:"errors,omitempty"`
35
+
}
36
+
37
+
// HoldDeleteResult represents the result of deleting data from a single hold
38
+
type HoldDeleteResult struct {
39
+
HoldDID string `json:"hold_did"`
40
+
Relationship string `json:"relationship"` // "captain" or "crew_member"
41
+
Status string `json:"status"` // "success", "failed", "offline"
42
+
Error string `json:"error,omitempty"`
43
+
CrewDeleted bool `json:"crew_deleted,omitempty"`
44
+
LayersDeleted int `json:"layers_deleted,omitempty"`
45
+
StatsDeleted int `json:"stats_deleted,omitempty"`
46
+
}
47
+
48
+
// DeleteAccountHandler handles GDPR account deletion requests
49
+
type DeleteAccountHandler struct {
50
+
DB *sql.DB
51
+
OAuthStore *db.OAuthStore
52
+
Refresher *oauth.Refresher
53
+
}
54
+
55
+
func (h *DeleteAccountHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
56
+
// Get authenticated user from middleware
57
+
user := middleware.GetUser(r)
58
+
if user == nil {
59
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
60
+
return
61
+
}
62
+
63
+
// Parse request body
64
+
var req DeleteAccountRequest
65
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
66
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
67
+
return
68
+
}
69
+
70
+
// Require confirmation with handle (e.g., "DELETE alice.bsky.social")
71
+
expectedConfirmation := "DELETE " + user.Handle
72
+
if req.Confirmation != expectedConfirmation {
73
+
http.Error(w, fmt.Sprintf("Confirmation required: must send confirmation='DELETE %s'", user.Handle), http.StatusBadRequest)
74
+
return
75
+
}
76
+
77
+
slog.Info("Processing account deletion request",
78
+
"component", "delete",
79
+
"did", user.DID,
80
+
"delete_pds_records", req.DeletePDSRecords)
81
+
82
+
response := DeleteAccountResponse{
83
+
HoldResults: []HoldDeleteResult{},
84
+
}
85
+
86
+
// 1. Delete from each hold where user is a member
87
+
holdResults := h.deleteFromHolds(r.Context(), user)
88
+
response.HoldResults = holdResults
89
+
90
+
// 2. If requested, delete PDS records
91
+
if req.DeletePDSRecords {
92
+
pdsResults, err := h.deletePDSRecords(r.Context(), user)
93
+
if err != nil {
94
+
slog.Error("Failed to delete PDS records",
95
+
"component", "delete",
96
+
"did", user.DID,
97
+
"error", err)
98
+
response.Errors = append(response.Errors, fmt.Sprintf("PDS deletion error: %v", err))
99
+
} else {
100
+
response.PDSDeleted = true
101
+
response.PDSCollections = pdsResults
102
+
}
103
+
}
104
+
105
+
// 3. Delete from AppView database (last, since we need OAuth tokens for above steps)
106
+
if err := db.DeleteUserDataFull(h.DB, h.OAuthStore, user.DID); err != nil {
107
+
slog.Error("Failed to delete AppView data",
108
+
"component", "delete",
109
+
"did", user.DID,
110
+
"error", err)
111
+
response.Errors = append(response.Errors, fmt.Sprintf("AppView deletion error: %v", err))
112
+
} else {
113
+
response.AppViewDeleted = true
114
+
}
115
+
116
+
// Set success if AppView data was deleted (main requirement)
117
+
response.Success = response.AppViewDeleted
118
+
119
+
slog.Info("Account deletion completed",
120
+
"component", "delete",
121
+
"did", user.DID,
122
+
"success", response.Success,
123
+
"holds_processed", len(response.HoldResults),
124
+
"pds_deleted", response.PDSDeleted)
125
+
126
+
w.Header().Set("Content-Type", "application/json")
127
+
if err := json.NewEncoder(w).Encode(response); err != nil {
128
+
slog.Error("Failed to encode response", "error", err)
129
+
}
130
+
}
131
+
132
+
// deleteFromHolds deletes user data from all holds where they are a member
133
+
func (h *DeleteAccountHandler) deleteFromHolds(ctx context.Context, user *db.User) []HoldDeleteResult {
134
+
var results []HoldDeleteResult
135
+
136
+
// Build metadata map: holdDID → relationship
137
+
holdMeta := make(map[string]string)
138
+
139
+
// Get holds where user is captain
140
+
if h.DB != nil {
141
+
captainHolds, err := db.GetCaptainRecordsForOwner(h.DB, user.DID)
142
+
if err != nil {
143
+
slog.Warn("Failed to get captain records for deletion",
144
+
"component", "delete",
145
+
"did", user.DID,
146
+
"error", err)
147
+
} else {
148
+
for _, hold := range captainHolds {
149
+
holdMeta[hold.HoldDID] = "captain"
150
+
}
151
+
}
152
+
}
153
+
154
+
// Get crew memberships from database
155
+
memberships, err := db.GetCrewMemberships(h.DB, user.DID)
156
+
if err != nil {
157
+
slog.Warn("Failed to get crew memberships for deletion",
158
+
"component", "delete",
159
+
"did", user.DID,
160
+
"error", err)
161
+
} else {
162
+
for _, m := range memberships {
163
+
// Don't overwrite captain relationship
164
+
if _, exists := holdMeta[m.HoldDID]; !exists {
165
+
holdMeta[m.HoldDID] = "crew_member"
166
+
}
167
+
}
168
+
}
169
+
170
+
if len(holdMeta) == 0 {
171
+
return results
172
+
}
173
+
174
+
// Delete from each hold concurrently with timeout
175
+
var wg sync.WaitGroup
176
+
resultChan := make(chan HoldDeleteResult, len(holdMeta))
177
+
178
+
for holdDID, relationship := range holdMeta {
179
+
wg.Add(1)
180
+
go func(holdDID, relationship string) {
181
+
defer wg.Done()
182
+
result := h.deleteFromSingleHold(ctx, user, holdDID, relationship)
183
+
resultChan <- result
184
+
}(holdDID, relationship)
185
+
}
186
+
187
+
// Wait for all goroutines to complete
188
+
wg.Wait()
189
+
close(resultChan)
190
+
191
+
// Collect results
192
+
for result := range resultChan {
193
+
results = append(results, result)
194
+
}
195
+
196
+
return results
197
+
}
198
+
199
+
// deleteFromSingleHold deletes user data from a single hold
200
+
func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *db.User, holdDID, relationship string) HoldDeleteResult {
201
+
// Resolve hold DID to URL
202
+
holdURL := atproto.ResolveHoldURL(holdDID)
203
+
endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
204
+
205
+
result := HoldDeleteResult{
206
+
HoldDID: holdDID,
207
+
Relationship: relationship,
208
+
Status: "failed",
209
+
}
210
+
211
+
// Check if we have OAuth refresher (needed for service tokens)
212
+
if h.Refresher == nil {
213
+
result.Error = "OAuth not configured - cannot authenticate to hold"
214
+
return result
215
+
}
216
+
217
+
// Create context with timeout (10 seconds per hold for deletion)
218
+
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
219
+
defer cancel()
220
+
221
+
// Get service token from user's PDS
222
+
serviceToken, err := auth.GetOrFetchServiceToken(timeoutCtx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
223
+
if err != nil {
224
+
slog.Warn("Failed to get service token for hold deletion",
225
+
"component", "delete",
226
+
"hold_did", holdDID,
227
+
"user_did", user.DID,
228
+
"error", err)
229
+
result.Error = fmt.Sprintf("Failed to authenticate: %v", err)
230
+
return result
231
+
}
232
+
233
+
// Create request
234
+
req, err := http.NewRequestWithContext(timeoutCtx, "DELETE", endpoint, nil)
235
+
if err != nil {
236
+
result.Error = fmt.Sprintf("Failed to create request: %v", err)
237
+
return result
238
+
}
239
+
240
+
// Set auth header
241
+
req.Header.Set("Authorization", "Bearer "+serviceToken)
242
+
243
+
// Make request
244
+
resp, err := http.DefaultClient.Do(req)
245
+
if err != nil {
246
+
slog.Warn("Hold deletion request failed",
247
+
"component", "delete",
248
+
"hold_did", holdDID,
249
+
"endpoint", endpoint,
250
+
"error", err)
251
+
result.Status = "offline"
252
+
result.Error = fmt.Sprintf("Could not contact hold: %v", err)
253
+
return result
254
+
}
255
+
defer resp.Body.Close()
256
+
257
+
// Check response status
258
+
if resp.StatusCode != http.StatusOK {
259
+
body, _ := io.ReadAll(resp.Body)
260
+
result.Error = fmt.Sprintf("Hold returned status %d: %s", resp.StatusCode, string(body))
261
+
return result
262
+
}
263
+
264
+
// Parse response
265
+
var holdResponse struct {
266
+
Success bool `json:"success"`
267
+
CrewDeleted bool `json:"crew_deleted"`
268
+
LayersDeleted int `json:"layers_deleted"`
269
+
StatsDeleted int `json:"stats_deleted"`
270
+
}
271
+
if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil {
272
+
result.Error = fmt.Sprintf("Failed to parse response: %v", err)
273
+
return result
274
+
}
275
+
276
+
// Update result with success data
277
+
result.Status = "success"
278
+
result.CrewDeleted = holdResponse.CrewDeleted
279
+
result.LayersDeleted = holdResponse.LayersDeleted
280
+
result.StatsDeleted = holdResponse.StatsDeleted
281
+
282
+
slog.Debug("Successfully deleted data from hold",
283
+
"component", "delete",
284
+
"hold_did", holdDID,
285
+
"user_did", user.DID,
286
+
"crew_deleted", holdResponse.CrewDeleted,
287
+
"layers_deleted", holdResponse.LayersDeleted,
288
+
"stats_deleted", holdResponse.StatsDeleted)
289
+
290
+
return result
291
+
}
292
+
293
+
// deletePDSRecords deletes all io.atcr.* records from the user's PDS
294
+
func (h *DeleteAccountHandler) deletePDSRecords(ctx context.Context, user *db.User) (map[string]int, error) {
295
+
if h.Refresher == nil {
296
+
return nil, fmt.Errorf("OAuth not configured")
297
+
}
298
+
299
+
results := make(map[string]int)
300
+
301
+
// Create ATProto client with session provider
302
+
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
303
+
304
+
// Collections to delete
305
+
collections := []string{
306
+
atproto.ManifestCollection, // io.atcr.manifest
307
+
atproto.TagCollection, // io.atcr.tag
308
+
atproto.StarCollection, // io.atcr.sailor.star
309
+
atproto.RepoPageCollection, // io.atcr.repo.page
310
+
}
311
+
312
+
for _, collection := range collections {
313
+
deleted, err := client.DeleteAllRecordsInCollection(ctx, collection)
314
+
if err != nil {
315
+
slog.Warn("Failed to delete records in collection",
316
+
"component", "delete",
317
+
"did", user.DID,
318
+
"collection", collection,
319
+
"error", err)
320
+
// Continue with other collections
321
+
}
322
+
results[collection] = deleted
323
+
if deleted > 0 {
324
+
slog.Debug("Deleted records from collection",
325
+
"component", "delete",
326
+
"did", user.DID,
327
+
"collection", collection,
328
+
"count", deleted)
329
+
}
330
+
}
331
+
332
+
// Delete sailor profile (single record at rkey "self")
333
+
err := client.DeleteRecord(ctx, atproto.SailorProfileCollection, "self")
334
+
if err != nil {
335
+
slog.Warn("Failed to delete sailor profile",
336
+
"component", "delete",
337
+
"did", user.DID,
338
+
"error", err)
339
+
} else {
340
+
results[atproto.SailorProfileCollection] = 1
341
+
}
342
+
343
+
return results, nil
344
+
}
+318
pkg/appview/handlers/delete_test.go
+318
pkg/appview/handlers/delete_test.go
···
1
+
package handlers
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/json"
6
+
"net/http"
7
+
"net/http/httptest"
8
+
"testing"
9
+
"time"
10
+
11
+
"atcr.io/pkg/appview/db"
12
+
"atcr.io/pkg/appview/middleware"
13
+
_ "github.com/mattn/go-sqlite3"
14
+
)
15
+
16
+
func TestDeleteAccountHandler_Unauthorized(t *testing.T) {
17
+
database := setupTestDB(t)
18
+
defer database.Close()
19
+
20
+
handler := &DeleteAccountHandler{
21
+
DB: database,
22
+
OAuthStore: nil,
23
+
Refresher: nil,
24
+
}
25
+
26
+
reqBody := DeleteAccountRequest{
27
+
DeletePDSRecords: false,
28
+
Confirmation: "DELETE test.bsky.social",
29
+
}
30
+
body, _ := json.Marshal(reqBody)
31
+
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
32
+
req.Header.Set("Content-Type", "application/json")
33
+
34
+
rr := httptest.NewRecorder()
35
+
handler.ServeHTTP(rr, req)
36
+
37
+
if rr.Code != http.StatusUnauthorized {
38
+
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
39
+
}
40
+
}
41
+
42
+
func TestDeleteAccountHandler_MissingConfirmation(t *testing.T) {
43
+
database := setupTestDB(t)
44
+
defer database.Close()
45
+
46
+
// Create test user
47
+
testUser := &db.User{
48
+
DID: "did:plc:test123",
49
+
Handle: "test.bsky.social",
50
+
PDSEndpoint: "https://bsky.social",
51
+
LastSeen: time.Now(),
52
+
}
53
+
if err := db.UpsertUser(database, testUser); err != nil {
54
+
t.Fatalf("Failed to create user: %v", err)
55
+
}
56
+
57
+
handler := &DeleteAccountHandler{
58
+
DB: database,
59
+
OAuthStore: nil,
60
+
Refresher: nil,
61
+
}
62
+
63
+
// Request without confirmation
64
+
reqBody := DeleteAccountRequest{
65
+
DeletePDSRecords: false,
66
+
Confirmation: "",
67
+
}
68
+
body, _ := json.Marshal(reqBody)
69
+
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
70
+
req.Header.Set("Content-Type", "application/json")
71
+
req = middleware.WithUser(req, testUser)
72
+
73
+
rr := httptest.NewRecorder()
74
+
handler.ServeHTTP(rr, req)
75
+
76
+
if rr.Code != http.StatusBadRequest {
77
+
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
78
+
}
79
+
}
80
+
81
+
func TestDeleteAccountHandler_WrongConfirmation(t *testing.T) {
82
+
database := setupTestDB(t)
83
+
defer database.Close()
84
+
85
+
testUser := &db.User{
86
+
DID: "did:plc:test123",
87
+
Handle: "test.bsky.social",
88
+
PDSEndpoint: "https://bsky.social",
89
+
LastSeen: time.Now(),
90
+
}
91
+
if err := db.UpsertUser(database, testUser); err != nil {
92
+
t.Fatalf("Failed to create user: %v", err)
93
+
}
94
+
95
+
handler := &DeleteAccountHandler{
96
+
DB: database,
97
+
OAuthStore: nil,
98
+
Refresher: nil,
99
+
}
100
+
101
+
tests := []struct {
102
+
name string
103
+
confirmation string
104
+
}{
105
+
{"just DELETE", "DELETE"},
106
+
{"wrong handle", "DELETE wrong.handle"},
107
+
{"lowercase", "delete test.bsky.social"},
108
+
{"extra spaces", "DELETE test.bsky.social"},
109
+
}
110
+
111
+
for _, tt := range tests {
112
+
t.Run(tt.name, func(t *testing.T) {
113
+
reqBody := DeleteAccountRequest{
114
+
DeletePDSRecords: false,
115
+
Confirmation: tt.confirmation,
116
+
}
117
+
body, _ := json.Marshal(reqBody)
118
+
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
119
+
req.Header.Set("Content-Type", "application/json")
120
+
req = middleware.WithUser(req, testUser)
121
+
122
+
rr := httptest.NewRecorder()
123
+
handler.ServeHTTP(rr, req)
124
+
125
+
if rr.Code != http.StatusBadRequest {
126
+
t.Errorf("Expected status %d for confirmation %q, got %d", http.StatusBadRequest, tt.confirmation, rr.Code)
127
+
}
128
+
})
129
+
}
130
+
}
131
+
132
+
func TestDeleteAccountHandler_SuccessfulDeletion(t *testing.T) {
133
+
database := setupTestDB(t)
134
+
defer database.Close()
135
+
136
+
// Create test user with some data
137
+
testUser := &db.User{
138
+
DID: "did:plc:test123",
139
+
Handle: "test.bsky.social",
140
+
PDSEndpoint: "https://bsky.social",
141
+
LastSeen: time.Now(),
142
+
}
143
+
if err := db.UpsertUser(database, testUser); err != nil {
144
+
t.Fatalf("Failed to create user: %v", err)
145
+
}
146
+
147
+
// Create some manifests for the user
148
+
_, err := database.Exec(`
149
+
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
150
+
VALUES (?, ?, ?, ?, ?, ?, ?)
151
+
`, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
152
+
"application/vnd.oci.image.manifest.v1+json", time.Now())
153
+
if err != nil {
154
+
t.Fatalf("Failed to create manifest: %v", err)
155
+
}
156
+
157
+
// Create OAuth store for testing
158
+
oauthStore := db.NewOAuthStore(database)
159
+
160
+
handler := &DeleteAccountHandler{
161
+
DB: database,
162
+
OAuthStore: oauthStore,
163
+
Refresher: nil, // No remote operations in this test
164
+
}
165
+
166
+
reqBody := DeleteAccountRequest{
167
+
DeletePDSRecords: false,
168
+
Confirmation: "DELETE test.bsky.social",
169
+
}
170
+
body, _ := json.Marshal(reqBody)
171
+
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
172
+
req.Header.Set("Content-Type", "application/json")
173
+
req = middleware.WithUser(req, testUser)
174
+
175
+
rr := httptest.NewRecorder()
176
+
handler.ServeHTTP(rr, req)
177
+
178
+
if rr.Code != http.StatusOK {
179
+
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
180
+
}
181
+
182
+
var response DeleteAccountResponse
183
+
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
184
+
t.Fatalf("Failed to decode response: %v", err)
185
+
}
186
+
187
+
if !response.Success {
188
+
t.Error("Expected success=true")
189
+
}
190
+
if !response.AppViewDeleted {
191
+
t.Error("Expected appview_deleted=true")
192
+
}
193
+
194
+
// Verify user was actually deleted
195
+
var count int
196
+
err = database.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
197
+
if err != nil {
198
+
t.Fatalf("Failed to query user: %v", err)
199
+
}
200
+
if count != 0 {
201
+
t.Error("Expected user to be deleted from database")
202
+
}
203
+
204
+
// Verify manifests were cascade deleted
205
+
err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
206
+
if err != nil {
207
+
t.Fatalf("Failed to query manifests: %v", err)
208
+
}
209
+
if count != 0 {
210
+
t.Error("Expected manifests to be cascade deleted")
211
+
}
212
+
}
213
+
214
+
func TestDeleteAccountHandler_InvalidJSON(t *testing.T) {
215
+
database := setupTestDB(t)
216
+
defer database.Close()
217
+
218
+
testUser := &db.User{
219
+
DID: "did:plc:test123",
220
+
Handle: "test.bsky.social",
221
+
PDSEndpoint: "https://bsky.social",
222
+
LastSeen: time.Now(),
223
+
}
224
+
if err := db.UpsertUser(database, testUser); err != nil {
225
+
t.Fatalf("Failed to create user: %v", err)
226
+
}
227
+
228
+
handler := &DeleteAccountHandler{
229
+
DB: database,
230
+
OAuthStore: nil,
231
+
Refresher: nil,
232
+
}
233
+
234
+
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader([]byte("not json")))
235
+
req.Header.Set("Content-Type", "application/json")
236
+
req = middleware.WithUser(req, testUser)
237
+
238
+
rr := httptest.NewRecorder()
239
+
handler.ServeHTTP(rr, req)
240
+
241
+
if rr.Code != http.StatusBadRequest {
242
+
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
243
+
}
244
+
}
245
+
246
+
func TestDeleteAccountHandler_DeletesHoldMembershipData(t *testing.T) {
247
+
database := setupTestDB(t)
248
+
defer database.Close()
249
+
250
+
testUser := &db.User{
251
+
DID: "did:plc:test123",
252
+
Handle: "test.bsky.social",
253
+
PDSEndpoint: "https://bsky.social",
254
+
LastSeen: time.Now(),
255
+
}
256
+
if err := db.UpsertUser(database, testUser); err != nil {
257
+
t.Fatalf("Failed to create user: %v", err)
258
+
}
259
+
260
+
// Create hold membership data (these tables don't cascade)
261
+
_, err := database.Exec(`
262
+
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
263
+
VALUES (?, ?, ?, ?)
264
+
`, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
265
+
if err != nil {
266
+
t.Fatalf("Failed to create crew approval: %v", err)
267
+
}
268
+
269
+
_, err = database.Exec(`
270
+
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
271
+
VALUES (?, ?, ?, ?)
272
+
`, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
273
+
if err != nil {
274
+
t.Fatalf("Failed to create crew member: %v", err)
275
+
}
276
+
277
+
oauthStore := db.NewOAuthStore(database)
278
+
279
+
handler := &DeleteAccountHandler{
280
+
DB: database,
281
+
OAuthStore: oauthStore,
282
+
Refresher: nil,
283
+
}
284
+
285
+
reqBody := DeleteAccountRequest{
286
+
DeletePDSRecords: false,
287
+
Confirmation: "DELETE test.bsky.social",
288
+
}
289
+
body, _ := json.Marshal(reqBody)
290
+
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
291
+
req.Header.Set("Content-Type", "application/json")
292
+
req = middleware.WithUser(req, testUser)
293
+
294
+
rr := httptest.NewRecorder()
295
+
handler.ServeHTTP(rr, req)
296
+
297
+
if rr.Code != http.StatusOK {
298
+
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
299
+
}
300
+
301
+
// Verify hold membership data was deleted
302
+
var count int
303
+
err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
304
+
if err != nil {
305
+
t.Fatalf("Failed to query crew approvals: %v", err)
306
+
}
307
+
if count != 0 {
308
+
t.Error("Expected crew approvals to be deleted")
309
+
}
310
+
311
+
err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
312
+
if err != nil {
313
+
t.Fatalf("Failed to query crew members: %v", err)
314
+
}
315
+
if count != 0 {
316
+
t.Error("Expected crew members to be deleted")
317
+
}
318
+
}
+7
pkg/appview/middleware/auth.go
+7
pkg/appview/middleware/auth.go
···
103
103
}
104
104
return user
105
105
}
106
+
107
+
// WithUser returns a new request with the user set in the context.
108
+
// This is primarily useful for testing.
109
+
func WithUser(r *http.Request, user *db.User) *http.Request {
110
+
ctx := context.WithValue(r.Context(), userKey, user)
111
+
return r.WithContext(ctx)
112
+
}
+7
pkg/appview/routes/routes.go
+7
pkg/appview/routes/routes.go
···
246
246
DB: deps.Database,
247
247
Refresher: deps.Refresher,
248
248
}).ServeHTTP)
249
+
250
+
// GDPR account deletion
251
+
r.Delete("/api/account", (&uihandlers.DeleteAccountHandler{
252
+
DB: deps.Database,
253
+
OAuthStore: deps.OAuthStore,
254
+
Refresher: deps.Refresher,
255
+
}).ServeHTTP)
249
256
})
250
257
251
258
// Logout endpoint (supports both GET and POST)
+378
pkg/appview/templates/pages/settings.html
+378
pkg/appview/templates/pages/settings.html
···
194
194
</small>
195
195
</p>
196
196
</section>
197
+
198
+
<!-- Danger Zone Section -->
199
+
<section class="settings-section danger-zone">
200
+
<h2><i data-lucide="alert-triangle"></i> Danger Zone</h2>
201
+
202
+
<div class="danger-card">
203
+
<h3>Delete Account</h3>
204
+
<p>Permanently delete your ATCR account and all associated data. This action cannot be undone.</p>
205
+
206
+
<div class="delete-options">
207
+
<label class="checkbox-label">
208
+
<input type="checkbox" id="delete-pds-records">
209
+
<span>Also delete all <code>io.atcr.*</code> records from my ATProto PDS</span>
210
+
</label>
211
+
<small class="option-help">
212
+
This will remove manifests, tags, stars, and profile data from your Bluesky account.
213
+
Your PDS data is always under your control, so this is optional.
214
+
</small>
215
+
</div>
216
+
217
+
<button type="button" id="delete-account-btn" class="btn-danger-large">
218
+
<i data-lucide="trash-2"></i>
219
+
Delete My Account
220
+
</button>
221
+
</div>
222
+
</section>
197
223
</div>
198
224
</main>
199
225
···
331
357
// Refresh devices every 30 seconds (to show new authorizations)
332
358
setInterval(loadDevices, 30000);
333
359
})();
360
+
361
+
// Account Deletion JavaScript
362
+
(function() {
363
+
const deleteBtn = document.getElementById('delete-account-btn');
364
+
if (!deleteBtn) return;
365
+
366
+
deleteBtn.addEventListener('click', function() {
367
+
showDeleteConfirmationModal();
368
+
});
369
+
370
+
function showDeleteConfirmationModal() {
371
+
// Create modal backdrop
372
+
const modal = document.createElement('div');
373
+
modal.className = 'delete-modal-backdrop';
374
+
modal.innerHTML = `
375
+
<div class="delete-modal">
376
+
<h2><i data-lucide="alert-triangle"></i> Delete Account</h2>
377
+
<p class="warning-text">
378
+
This action <strong>cannot be undone</strong>. This will permanently delete:
379
+
</p>
380
+
<ul class="delete-list">
381
+
<li>Your ATCR account and all settings</li>
382
+
<li>All authorized devices</li>
383
+
<li>Your data from all holds you're a member of</li>
384
+
${document.getElementById('delete-pds-records').checked ?
385
+
'<li>All io.atcr.* records from your ATProto PDS</li>' : ''}
386
+
</ul>
387
+
<p class="confirm-text">Type <strong>DELETE {{ .Profile.Handle }}</strong> to confirm:</p>
388
+
<input type="text" id="confirm-delete-input" class="confirm-input" placeholder="DELETE {{ .Profile.Handle }}" autocomplete="off">
389
+
<div class="modal-actions">
390
+
<button type="button" class="btn-cancel" id="cancel-delete">Cancel</button>
391
+
<button type="button" class="btn-confirm-delete" id="confirm-delete" disabled>
392
+
<i data-lucide="trash-2"></i>
393
+
Delete My Account
394
+
</button>
395
+
</div>
396
+
</div>
397
+
`;
398
+
document.body.appendChild(modal);
399
+
400
+
// Reinitialize Lucide icons for the modal
401
+
if (typeof lucide !== 'undefined') {
402
+
lucide.createIcons();
403
+
}
404
+
405
+
// Focus the input
406
+
const confirmInput = document.getElementById('confirm-delete-input');
407
+
const confirmBtn = document.getElementById('confirm-delete');
408
+
const cancelBtn = document.getElementById('cancel-delete');
409
+
410
+
setTimeout(() => confirmInput.focus(), 100);
411
+
412
+
// Expected confirmation string
413
+
const expectedConfirmation = 'DELETE {{ .Profile.Handle }}';
414
+
415
+
// Enable button only when full confirmation is typed
416
+
confirmInput.addEventListener('input', function() {
417
+
confirmBtn.disabled = this.value !== expectedConfirmation;
418
+
});
419
+
420
+
// Handle enter key
421
+
confirmInput.addEventListener('keydown', function(e) {
422
+
if (e.key === 'Enter' && this.value === expectedConfirmation) {
423
+
performAccountDeletion();
424
+
}
425
+
});
426
+
427
+
// Cancel button
428
+
cancelBtn.addEventListener('click', function() {
429
+
modal.remove();
430
+
});
431
+
432
+
// Click outside to close
433
+
modal.addEventListener('click', function(e) {
434
+
if (e.target === modal) {
435
+
modal.remove();
436
+
}
437
+
});
438
+
439
+
// Escape key to close
440
+
document.addEventListener('keydown', function escHandler(e) {
441
+
if (e.key === 'Escape') {
442
+
modal.remove();
443
+
document.removeEventListener('keydown', escHandler);
444
+
}
445
+
});
446
+
447
+
// Confirm delete
448
+
confirmBtn.addEventListener('click', performAccountDeletion);
449
+
450
+
async function performAccountDeletion() {
451
+
const deletePDS = document.getElementById('delete-pds-records').checked;
452
+
453
+
// Show loading state
454
+
confirmBtn.disabled = true;
455
+
confirmBtn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> Deleting...';
456
+
if (typeof lucide !== 'undefined') {
457
+
lucide.createIcons();
458
+
}
459
+
cancelBtn.disabled = true;
460
+
461
+
try {
462
+
const response = await fetch('/api/account', {
463
+
method: 'DELETE',
464
+
headers: { 'Content-Type': 'application/json' },
465
+
body: JSON.stringify({
466
+
delete_pds_records: deletePDS,
467
+
confirmation: expectedConfirmation
468
+
})
469
+
});
470
+
471
+
const result = await response.json();
472
+
473
+
if (response.ok && result.success) {
474
+
// Show success and redirect
475
+
modal.querySelector('.delete-modal').innerHTML = `
476
+
<h2><i data-lucide="check-circle"></i> Account Deleted</h2>
477
+
<p>Your account has been successfully deleted.</p>
478
+
<p>Redirecting to home page...</p>
479
+
`;
480
+
if (typeof lucide !== 'undefined') {
481
+
lucide.createIcons();
482
+
}
483
+
setTimeout(() => {
484
+
window.location.href = '/?deleted=true';
485
+
}, 2000);
486
+
} else {
487
+
// Show error
488
+
const errors = result.errors || ['An unknown error occurred'];
489
+
modal.querySelector('.delete-modal').innerHTML = `
490
+
<h2><i data-lucide="x-circle"></i> Deletion Failed</h2>
491
+
<p>There were errors during account deletion:</p>
492
+
<ul class="error-list">
493
+
${errors.map(e => '<li>' + escapeHtml(e) + '</li>').join('')}
494
+
</ul>
495
+
<div class="modal-actions">
496
+
<button type="button" class="btn-cancel" onclick="this.closest('.delete-modal-backdrop').remove()">Close</button>
497
+
</div>
498
+
`;
499
+
if (typeof lucide !== 'undefined') {
500
+
lucide.createIcons();
501
+
}
502
+
}
503
+
} catch (err) {
504
+
console.error('Delete account error:', err);
505
+
modal.querySelector('.delete-modal').innerHTML = `
506
+
<h2><i data-lucide="x-circle"></i> Error</h2>
507
+
<p>Failed to delete account: ${escapeHtml(err.message)}</p>
508
+
<div class="modal-actions">
509
+
<button type="button" class="btn-cancel" onclick="this.closest('.delete-modal-backdrop').remove()">Close</button>
510
+
</div>
511
+
`;
512
+
if (typeof lucide !== 'undefined') {
513
+
lucide.createIcons();
514
+
}
515
+
}
516
+
}
517
+
}
518
+
519
+
function escapeHtml(text) {
520
+
const div = document.createElement('div');
521
+
div.textContent = text;
522
+
return div.innerHTML;
523
+
}
524
+
})();
334
525
</script>
335
526
336
527
<style>
···
657
848
.privacy-section .privacy-note a {
658
849
color: var(--primary);
659
850
text-decoration: underline;
851
+
}
852
+
853
+
/* Danger Zone Styles */
854
+
.danger-zone {
855
+
margin-top: 3rem;
856
+
border: 2px solid #dc3545;
857
+
border-radius: 8px;
858
+
background: rgba(220, 53, 69, 0.03);
859
+
}
860
+
.danger-zone h2 {
861
+
color: #dc3545;
862
+
display: flex;
863
+
align-items: center;
864
+
gap: 0.5rem;
865
+
}
866
+
.danger-zone h2 svg {
867
+
width: 1.25rem;
868
+
height: 1.25rem;
869
+
}
870
+
.danger-card {
871
+
padding: 1rem;
872
+
background: var(--bg);
873
+
border-radius: 4px;
874
+
border: 1px solid var(--border);
875
+
}
876
+
.danger-card h3 {
877
+
margin-top: 0;
878
+
margin-bottom: 0.5rem;
879
+
}
880
+
.delete-options {
881
+
margin: 1.5rem 0;
882
+
padding: 1rem;
883
+
background: var(--code-bg);
884
+
border-radius: 4px;
885
+
}
886
+
.checkbox-label {
887
+
display: flex;
888
+
align-items: flex-start;
889
+
gap: 0.5rem;
890
+
cursor: pointer;
891
+
}
892
+
.checkbox-label input[type="checkbox"] {
893
+
margin-top: 0.2rem;
894
+
width: 1rem;
895
+
height: 1rem;
896
+
cursor: pointer;
897
+
}
898
+
.checkbox-label span {
899
+
flex: 1;
900
+
}
901
+
.option-help {
902
+
display: block;
903
+
margin-top: 0.5rem;
904
+
margin-left: 1.5rem;
905
+
color: var(--fg-muted);
906
+
}
907
+
.btn-danger-large {
908
+
display: inline-flex;
909
+
align-items: center;
910
+
gap: 0.5rem;
911
+
padding: 0.75rem 1.5rem;
912
+
background: #dc3545;
913
+
color: white;
914
+
border: none;
915
+
border-radius: 4px;
916
+
font-size: 1rem;
917
+
font-weight: 500;
918
+
cursor: pointer;
919
+
transition: background 0.2s;
920
+
}
921
+
.btn-danger-large:hover {
922
+
background: #c82333;
923
+
}
924
+
.btn-danger-large svg {
925
+
width: 1rem;
926
+
height: 1rem;
927
+
}
928
+
929
+
/* Delete Account Modal */
930
+
.delete-modal-backdrop {
931
+
position: fixed;
932
+
top: 0;
933
+
left: 0;
934
+
width: 100%;
935
+
height: 100%;
936
+
background: rgba(0, 0, 0, 0.6);
937
+
display: flex;
938
+
align-items: center;
939
+
justify-content: center;
940
+
z-index: 1000;
941
+
padding: 1rem;
942
+
}
943
+
.delete-modal {
944
+
background: var(--bg);
945
+
padding: 2rem;
946
+
border-radius: 8px;
947
+
max-width: 480px;
948
+
width: 100%;
949
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
950
+
}
951
+
.delete-modal h2 {
952
+
margin-top: 0;
953
+
color: #dc3545;
954
+
display: flex;
955
+
align-items: center;
956
+
gap: 0.5rem;
957
+
}
958
+
.delete-modal h2 svg {
959
+
width: 1.5rem;
960
+
height: 1.5rem;
961
+
}
962
+
.delete-modal .warning-text {
963
+
margin-bottom: 0.5rem;
964
+
}
965
+
.delete-modal .delete-list {
966
+
margin: 1rem 0 1.5rem;
967
+
padding-left: 1.5rem;
968
+
}
969
+
.delete-modal .delete-list li {
970
+
margin-bottom: 0.5rem;
971
+
color: var(--fg-muted);
972
+
}
973
+
.delete-modal .confirm-text {
974
+
margin-bottom: 0.5rem;
975
+
}
976
+
.delete-modal .confirm-input {
977
+
width: 100%;
978
+
padding: 0.75rem;
979
+
font-size: 1rem;
980
+
border: 2px solid var(--border);
981
+
border-radius: 4px;
982
+
background: var(--bg);
983
+
color: var(--fg);
984
+
margin-bottom: 1.5rem;
985
+
}
986
+
.delete-modal .confirm-input:focus {
987
+
outline: none;
988
+
border-color: #dc3545;
989
+
}
990
+
.delete-modal .modal-actions {
991
+
display: flex;
992
+
gap: 1rem;
993
+
justify-content: flex-end;
994
+
}
995
+
.delete-modal .btn-cancel {
996
+
padding: 0.75rem 1.5rem;
997
+
background: var(--code-bg);
998
+
color: var(--fg);
999
+
border: 1px solid var(--border);
1000
+
border-radius: 4px;
1001
+
cursor: pointer;
1002
+
font-size: 1rem;
1003
+
}
1004
+
.delete-modal .btn-cancel:hover {
1005
+
background: var(--border);
1006
+
}
1007
+
.delete-modal .btn-cancel:disabled {
1008
+
opacity: 0.5;
1009
+
cursor: not-allowed;
1010
+
}
1011
+
.delete-modal .btn-confirm-delete {
1012
+
display: inline-flex;
1013
+
align-items: center;
1014
+
gap: 0.5rem;
1015
+
padding: 0.75rem 1.5rem;
1016
+
background: #dc3545;
1017
+
color: white;
1018
+
border: none;
1019
+
border-radius: 4px;
1020
+
font-size: 1rem;
1021
+
cursor: pointer;
1022
+
}
1023
+
.delete-modal .btn-confirm-delete:hover:not(:disabled) {
1024
+
background: #c82333;
1025
+
}
1026
+
.delete-modal .btn-confirm-delete:disabled {
1027
+
background: #6c757d;
1028
+
cursor: not-allowed;
1029
+
}
1030
+
.delete-modal .btn-confirm-delete svg {
1031
+
width: 1rem;
1032
+
height: 1rem;
1033
+
}
1034
+
.delete-modal .error-list {
1035
+
margin: 1rem 0;
1036
+
padding-left: 1.5rem;
1037
+
color: #dc3545;
660
1038
}
661
1039
</style>
662
1040
</body>
+89
pkg/atproto/client.go
+89
pkg/atproto/client.go
···
687
687
func (c *Client) PDSEndpoint() string {
688
688
return c.pdsEndpoint
689
689
}
690
+
691
+
// ListRecordsWithCursor lists records in a collection with cursor-based pagination.
692
+
// Returns records, next cursor (empty if no more), and error.
693
+
func (c *Client) ListRecordsWithCursor(ctx context.Context, collection string, limit int, cursor string) ([]Record, string, error) {
694
+
url := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=%d",
695
+
c.pdsEndpoint, RepoListRecords, c.did, collection, limit)
696
+
697
+
if cursor != "" {
698
+
url += "&cursor=" + cursor
699
+
}
700
+
701
+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
702
+
if err != nil {
703
+
return nil, "", err
704
+
}
705
+
706
+
if c.accessToken != "" {
707
+
req.Header.Set("Authorization", "Bearer "+c.accessToken)
708
+
}
709
+
710
+
resp, err := c.httpClient.Do(req)
711
+
if err != nil {
712
+
return nil, "", fmt.Errorf("failed to list records: %w", err)
713
+
}
714
+
defer resp.Body.Close()
715
+
716
+
if resp.StatusCode != http.StatusOK {
717
+
bodyBytes, _ := io.ReadAll(resp.Body)
718
+
return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
719
+
}
720
+
721
+
var result struct {
722
+
Records []Record `json:"records"`
723
+
Cursor string `json:"cursor,omitempty"`
724
+
}
725
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
726
+
return nil, "", fmt.Errorf("failed to decode response: %w", err)
727
+
}
728
+
729
+
return result.Records, result.Cursor, nil
730
+
}
731
+
732
+
// DeleteAllRecordsInCollection deletes all records in a collection.
733
+
// Returns the number of records deleted.
734
+
// This is used for GDPR account deletion to remove all user records from a collection.
735
+
func (c *Client) DeleteAllRecordsInCollection(ctx context.Context, collection string) (int, error) {
736
+
deleted := 0
737
+
cursor := ""
738
+
739
+
for {
740
+
// List records with pagination
741
+
records, nextCursor, err := c.ListRecordsWithCursor(ctx, collection, 100, cursor)
742
+
if err != nil {
743
+
return deleted, fmt.Errorf("failed to list records: %w", err)
744
+
}
745
+
746
+
for _, rec := range records {
747
+
// Extract rkey from URI (at://{did}/{collection}/{rkey})
748
+
rkey := extractRkeyFromURI(rec.URI)
749
+
if rkey == "" {
750
+
continue
751
+
}
752
+
753
+
err := c.DeleteRecord(ctx, collection, rkey)
754
+
if err != nil {
755
+
// Log but continue with other records
756
+
continue
757
+
}
758
+
deleted++
759
+
}
760
+
761
+
if nextCursor == "" {
762
+
break
763
+
}
764
+
cursor = nextCursor
765
+
}
766
+
767
+
return deleted, nil
768
+
}
769
+
770
+
// extractRkeyFromURI extracts the rkey from an AT URI (at://{did}/{collection}/{rkey})
771
+
func extractRkeyFromURI(uri string) string {
772
+
// Format: at://did:plc:xxx/io.atcr.manifest/abc123
773
+
parts := strings.Split(uri, "/")
774
+
if len(parts) < 5 {
775
+
return ""
776
+
}
777
+
return parts[len(parts)-1]
778
+
}
+192
pkg/hold/pds/delete.go
+192
pkg/hold/pds/delete.go
···
1
+
package pds
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
8
+
"atcr.io/pkg/atproto"
9
+
)
10
+
11
+
// UserDeleteResult contains the results of deleting a user's data from the hold
12
+
type UserDeleteResult struct {
13
+
CrewDeleted bool `json:"crew_deleted"`
14
+
LayersDeleted int `json:"layers_deleted"`
15
+
StatsDeleted int `json:"stats_deleted"`
16
+
}
17
+
18
+
// DeleteUserData deletes all data for a user from the hold's PDS.
19
+
// This removes:
20
+
// - Crew record (if user is a crew member)
21
+
// - Layer records (where userDid matches)
22
+
// - Stats records (where ownerDid matches)
23
+
//
24
+
// NOTE: This does NOT delete the captain record if the user is the hold owner.
25
+
// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
26
+
func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDeleteResult, error) {
27
+
result := &UserDeleteResult{}
28
+
29
+
slog.Info("Deleting user data from hold",
30
+
"user_did", userDID,
31
+
"hold_did", p.DID())
32
+
33
+
// 1. Delete crew record (if exists)
34
+
crewDeleted, err := p.deleteCrewRecord(ctx, userDID)
35
+
if err != nil {
36
+
slog.Warn("Failed to delete crew record",
37
+
"user_did", userDID,
38
+
"error", err)
39
+
// Continue with other deletions
40
+
}
41
+
result.CrewDeleted = crewDeleted
42
+
43
+
// 2. Delete layer records
44
+
layersDeleted, err := p.deleteLayerRecords(ctx, userDID)
45
+
if err != nil {
46
+
slog.Warn("Failed to delete layer records",
47
+
"user_did", userDID,
48
+
"error", err)
49
+
// Continue with other deletions
50
+
}
51
+
result.LayersDeleted = layersDeleted
52
+
53
+
// 3. Delete stats records
54
+
statsDeleted, err := p.deleteStatsRecords(ctx, userDID)
55
+
if err != nil {
56
+
slog.Warn("Failed to delete stats records",
57
+
"user_did", userDID,
58
+
"error", err)
59
+
// Continue with other deletions
60
+
}
61
+
result.StatsDeleted = statsDeleted
62
+
63
+
slog.Info("User data deletion complete",
64
+
"user_did", userDID,
65
+
"hold_did", p.DID(),
66
+
"crew_deleted", result.CrewDeleted,
67
+
"layers_deleted", result.LayersDeleted,
68
+
"stats_deleted", result.StatsDeleted)
69
+
70
+
return result, nil
71
+
}
72
+
73
+
// deleteCrewRecord removes a user's crew record from the hold
74
+
func (p *HoldPDS) deleteCrewRecord(ctx context.Context, userDID string) (bool, error) {
75
+
// Check if user has a crew record
76
+
_, _, err := p.GetCrewMemberByDID(ctx, userDID)
77
+
if err != nil {
78
+
// No crew record found
79
+
return false, nil
80
+
}
81
+
82
+
// Delete the crew record
83
+
err = p.RemoveCrewMemberByDID(ctx, userDID)
84
+
if err != nil {
85
+
return false, fmt.Errorf("failed to remove crew member: %w", err)
86
+
}
87
+
88
+
slog.Debug("Deleted crew record", "user_did", userDID)
89
+
return true, nil
90
+
}
91
+
92
+
// deleteLayerRecords removes all layer records for a user
93
+
func (p *HoldPDS) deleteLayerRecords(ctx context.Context, userDID string) (int, error) {
94
+
if p.recordsIndex == nil {
95
+
return 0, fmt.Errorf("records index not available")
96
+
}
97
+
98
+
deleted := 0
99
+
cursor := ""
100
+
batchSize := 100
101
+
102
+
for {
103
+
// Get layer records for this user via the DID index
104
+
records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor)
105
+
if err != nil {
106
+
return deleted, fmt.Errorf("failed to list layer records: %w", err)
107
+
}
108
+
109
+
for _, rec := range records {
110
+
// Delete from repo (MST)
111
+
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.LayerCollection, rec.Rkey)
112
+
if err != nil {
113
+
slog.Warn("Failed to delete layer record from repo",
114
+
"rkey", rec.Rkey,
115
+
"error", err)
116
+
continue
117
+
}
118
+
119
+
// Delete from index
120
+
err = p.recordsIndex.DeleteRecord(atproto.LayerCollection, rec.Rkey)
121
+
if err != nil {
122
+
slog.Warn("Failed to delete layer record from index",
123
+
"rkey", rec.Rkey,
124
+
"error", err)
125
+
}
126
+
127
+
deleted++
128
+
}
129
+
130
+
if nextCursor == "" {
131
+
break
132
+
}
133
+
cursor = nextCursor
134
+
}
135
+
136
+
if deleted > 0 {
137
+
slog.Debug("Deleted layer records", "user_did", userDID, "count", deleted)
138
+
}
139
+
140
+
return deleted, nil
141
+
}
142
+
143
+
// deleteStatsRecords removes all stats records for a user
144
+
func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int, error) {
145
+
if p.recordsIndex == nil {
146
+
return 0, fmt.Errorf("records index not available")
147
+
}
148
+
149
+
deleted := 0
150
+
cursor := ""
151
+
batchSize := 100
152
+
153
+
for {
154
+
// Get stats records for this user via the DID index
155
+
records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.StatsCollection, userDID, batchSize, cursor)
156
+
if err != nil {
157
+
return deleted, fmt.Errorf("failed to list stats records: %w", err)
158
+
}
159
+
160
+
for _, rec := range records {
161
+
// Delete from repo (MST)
162
+
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.StatsCollection, rec.Rkey)
163
+
if err != nil {
164
+
slog.Warn("Failed to delete stats record from repo",
165
+
"rkey", rec.Rkey,
166
+
"error", err)
167
+
continue
168
+
}
169
+
170
+
// Delete from index
171
+
err = p.recordsIndex.DeleteRecord(atproto.StatsCollection, rec.Rkey)
172
+
if err != nil {
173
+
slog.Warn("Failed to delete stats record from index",
174
+
"rkey", rec.Rkey,
175
+
"error", err)
176
+
}
177
+
178
+
deleted++
179
+
}
180
+
181
+
if nextCursor == "" {
182
+
break
183
+
}
184
+
cursor = nextCursor
185
+
}
186
+
187
+
if deleted > 0 {
188
+
slog.Debug("Deleted stats records", "user_did", userDID, "count", deleted)
189
+
}
190
+
191
+
return deleted, nil
192
+
}
+78
-1
pkg/hold/pds/xrpc.go
+78
-1
pkg/hold/pds/xrpc.go
···
195
195
r.Group(func(r chi.Router) {
196
196
r.Use(h.requireAuth)
197
197
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
198
-
// GDPR data export endpoint (TODO: implement)
198
+
// GDPR data export endpoint
199
199
r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
200
+
// GDPR data deletion endpoint
201
+
r.Delete("/xrpc/io.atcr.hold.deleteUserData", h.HandleDeleteUserData)
200
202
})
201
203
202
204
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
···
1630
1632
1631
1633
render.JSON(w, r, export)
1632
1634
}
1635
+
1636
+
// HoldUserDeleteResponse represents the result of GDPR data deletion
1637
+
type HoldUserDeleteResponse struct {
1638
+
Success bool `json:"success"`
1639
+
CrewDeleted bool `json:"crew_deleted"`
1640
+
LayersDeleted int `json:"layers_deleted"`
1641
+
StatsDeleted int `json:"stats_deleted"`
1642
+
}
1643
+
1644
+
// HandleDeleteUserData handles GDPR data deletion requests for a specific user.
1645
+
// This endpoint deletes all records stored on this hold's PDS that reference
1646
+
// the authenticated user's DID.
1647
+
//
1648
+
// Deletes:
1649
+
// - io.atcr.hold.crew record for the DID (if exists, and user is NOT captain)
1650
+
// - io.atcr.hold.layer records where userDid matches
1651
+
// - io.atcr.hold.stats records where ownerDid matches
1652
+
//
1653
+
// NOTE: This does NOT delete the captain record if the user is the hold owner.
1654
+
// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
1655
+
//
1656
+
// Authentication: Requires valid service token from user's PDS
1657
+
func (h *XRPCHandler) HandleDeleteUserData(w http.ResponseWriter, r *http.Request) {
1658
+
// Get authenticated user from context
1659
+
user := getUserFromContext(r)
1660
+
if user == nil {
1661
+
http.Error(w, "authentication required", http.StatusUnauthorized)
1662
+
return
1663
+
}
1664
+
1665
+
slog.Info("GDPR data deletion requested",
1666
+
"requester_did", user.DID,
1667
+
"hold_did", h.pds.DID())
1668
+
1669
+
// Check if user is captain - if so, skip crew deletion but continue with layer/stats
1670
+
isCaptain := false
1671
+
_, captain, err := h.pds.GetCaptainRecord(r.Context())
1672
+
if err == nil && captain != nil && captain.Owner == user.DID {
1673
+
isCaptain = true
1674
+
slog.Info("User is captain of this hold, will not delete captain record",
1675
+
"user_did", user.DID,
1676
+
"hold_did", h.pds.DID())
1677
+
}
1678
+
1679
+
// Delete user data from hold
1680
+
result, err := h.pds.DeleteUserData(r.Context(), user.DID)
1681
+
if err != nil {
1682
+
slog.Error("Failed to delete user data",
1683
+
"user_did", user.DID,
1684
+
"hold_did", h.pds.DID(),
1685
+
"error", err)
1686
+
http.Error(w, fmt.Sprintf("failed to delete user data: %v", err), http.StatusInternalServerError)
1687
+
return
1688
+
}
1689
+
1690
+
// If user is captain, they shouldn't have a crew record deleted (they're the owner)
1691
+
// The DeleteUserData function handles crew deletion, but we report it appropriately
1692
+
if isCaptain {
1693
+
result.CrewDeleted = false
1694
+
}
1695
+
1696
+
slog.Info("GDPR data deletion completed",
1697
+
"user_did", user.DID,
1698
+
"hold_did", h.pds.DID(),
1699
+
"crew_deleted", result.CrewDeleted,
1700
+
"layers_deleted", result.LayersDeleted,
1701
+
"stats_deleted", result.StatsDeleted)
1702
+
1703
+
render.JSON(w, r, HoldUserDeleteResponse{
1704
+
Success: true,
1705
+
CrewDeleted: result.CrewDeleted,
1706
+
LayersDeleted: result.LayersDeleted,
1707
+
StatsDeleted: result.StatsDeleted,
1708
+
})
1709
+
}