A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

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

begin delete my account implementation

+1799 -1
+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 + }