A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

begin delete my account implementation

evan.jarrett.net 64cdb669 51f69174

verified
Changed files
+1799 -1
pkg
appview
db
handlers
middleware
routes
templates
atproto
hold
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }