diff --git a/pkg/appview/db/delete.go b/pkg/appview/db/delete.go new file mode 100644 index 0000000..b86fd58 --- /dev/null +++ b/pkg/appview/db/delete.go @@ -0,0 +1,80 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "log/slog" +) + +// DeleteUserDataFull performs complete user deletion including non-cascading tables. +// This is the main function for GDPR account deletion. +// +// Order of operations: +// 1. Delete hold membership data (non-cascading tables) +// 2. Delete OAuth sessions +// 3. Delete user (cascades to manifests, tags, stars, repo_pages, etc.) +// +// This should be called AFTER remote cleanup (hold services, PDS records) +// since we need the OAuth tokens to authenticate those requests. +func DeleteUserDataFull(db *sql.DB, oauthStore *OAuthStore, did string) error { + slog.Info("Starting full user data deletion", "did", did) + + // 1. Delete non-cascading hold membership tables + if err := deleteHoldMembershipData(db, did); err != nil { + slog.Error("Failed to delete hold membership data", "did", did, "error", err) + return fmt.Errorf("failed to delete hold membership data: %w", err) + } + + // 2. Delete OAuth sessions + if oauthStore != nil { + if err := oauthStore.DeleteSessionsForDID(context.Background(), did); err != nil { + slog.Warn("Failed to delete OAuth sessions", "did", did, "error", err) + // Continue - not critical + } else { + slog.Debug("Deleted OAuth sessions", "did", did) + } + } + + // 3. Delete user (cascades to manifests, tags, stars, annotations, etc.) + if err := DeleteUserData(db, did); err != nil { + slog.Error("Failed to delete user data", "did", did, "error", err) + return fmt.Errorf("failed to delete user data: %w", err) + } + + slog.Info("User data deletion completed", "did", did) + return nil +} + +// deleteHoldMembershipData deletes non-cascading hold membership tables. +// These tables don't have foreign keys to the users table. +func deleteHoldMembershipData(db *sql.DB, did string) error { + // Delete from hold_crew_approvals (where user is the approved member) + result, err := db.Exec(`DELETE FROM hold_crew_approvals WHERE user_did = ?`, did) + if err != nil { + return fmt.Errorf("failed to delete crew approvals: %w", err) + } + approvalsDeleted, _ := result.RowsAffected() + + // Delete from hold_crew_denials (where user was denied) + result, err = db.Exec(`DELETE FROM hold_crew_denials WHERE user_did = ?`, did) + if err != nil { + return fmt.Errorf("failed to delete crew denials: %w", err) + } + denialsDeleted, _ := result.RowsAffected() + + // Delete from hold_crew_members (cached crew memberships) + result, err = db.Exec(`DELETE FROM hold_crew_members WHERE member_did = ?`, did) + if err != nil { + return fmt.Errorf("failed to delete crew members: %w", err) + } + membersDeleted, _ := result.RowsAffected() + + slog.Debug("Deleted hold membership data", + "did", did, + "approvals_deleted", approvalsDeleted, + "denials_deleted", denialsDeleted, + "members_deleted", membersDeleted) + + return nil +} diff --git a/pkg/appview/db/delete_test.go b/pkg/appview/db/delete_test.go new file mode 100644 index 0000000..3781d2b --- /dev/null +++ b/pkg/appview/db/delete_test.go @@ -0,0 +1,306 @@ +package db + +import ( + "fmt" + "testing" + "time" +) + +func TestDeleteUserDataFull_DeletesAllData(t *testing.T) { + db, err := InitDB(":memory:") + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + // Create test user + testUser := &User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := UpsertUser(db, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + // Create manifest + _, err = db.Exec(` + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, + "application/vnd.oci.image.manifest.v1+json", time.Now()) + if err != nil { + t.Fatalf("Failed to create manifest: %v", err) + } + + // Create tag + _, err = db.Exec(` + INSERT INTO tags (did, repository, tag, digest, created_at) + VALUES (?, ?, ?, ?, ?) + `, testUser.DID, "myapp", "latest", "sha256:abc123", time.Now()) + if err != nil { + t.Fatalf("Failed to create tag: %v", err) + } + + // Create hold membership data (non-cascading) + _, err = db.Exec(` + INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at) + VALUES (?, ?, ?, ?) + `, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("Failed to create crew approval: %v", err) + } + + _, err = db.Exec(` + INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions) + VALUES (?, ?, ?, ?) + `, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`) + if err != nil { + t.Fatalf("Failed to create crew member: %v", err) + } + + // Create OAuth store + oauthStore := NewOAuthStore(db) + + // Delete all user data + err = DeleteUserDataFull(db, oauthStore, testUser.DID) + if err != nil { + t.Fatalf("DeleteUserDataFull failed: %v", err) + } + + // Verify user was deleted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query users: %v", err) + } + if count != 0 { + t.Error("Expected user to be deleted") + } + + // Verify manifests were cascade deleted + err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query manifests: %v", err) + } + if count != 0 { + t.Error("Expected manifests to be cascade deleted") + } + + // Verify tags were cascade deleted + err = db.QueryRow("SELECT COUNT(*) FROM tags WHERE did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query tags: %v", err) + } + if count != 0 { + t.Error("Expected tags to be cascade deleted") + } + + // Verify hold membership data was deleted + err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query crew approvals: %v", err) + } + if count != 0 { + t.Error("Expected crew approvals to be deleted") + } + + err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query crew members: %v", err) + } + if count != 0 { + t.Error("Expected crew members to be deleted") + } +} + +func TestDeleteUserDataFull_DoesNotAffectOtherUsers(t *testing.T) { + db, err := InitDB(":memory:") + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + // Create two users + user1 := &User{ + DID: "did:plc:user1", + Handle: "user1.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + user2 := &User{ + DID: "did:plc:user2", + Handle: "user2.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := UpsertUser(db, user1); err != nil { + t.Fatalf("Failed to create user1: %v", err) + } + if err := UpsertUser(db, user2); err != nil { + t.Fatalf("Failed to create user2: %v", err) + } + + // Create manifests for both users + for _, user := range []*User{user1, user2} { + _, err = db.Exec(` + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, user.DID, "myapp", "sha256:"+user.DID, "did:web:hold.example.com", 2, + "application/vnd.oci.image.manifest.v1+json", time.Now()) + if err != nil { + t.Fatalf("Failed to create manifest for %s: %v", user.Handle, err) + } + } + + // Create hold membership data for both users + for i, user := range []*User{user1, user2} { + _, err = db.Exec(` + INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions) + VALUES (?, ?, ?, ?) + `, "did:web:hold.example.com", user.DID, fmt.Sprintf("member%d", i+1), `["blob:read"]`) + if err != nil { + t.Fatalf("Failed to create crew member for %s: %v", user.Handle, err) + } + } + + oauthStore := NewOAuthStore(db) + + // Delete only user1's data + err = DeleteUserDataFull(db, oauthStore, user1.DID) + if err != nil { + t.Fatalf("DeleteUserDataFull failed: %v", err) + } + + // Verify user1 was deleted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user1.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query users: %v", err) + } + if count != 0 { + t.Error("Expected user1 to be deleted") + } + + // Verify user2 still exists + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user2.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query users: %v", err) + } + if count != 1 { + t.Error("Expected user2 to still exist") + } + + // Verify user2's manifests still exist + err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", user2.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query manifests: %v", err) + } + if count != 1 { + t.Error("Expected user2's manifest to still exist") + } + + // Verify user2's crew membership still exists + err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", user2.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query crew members: %v", err) + } + if count != 1 { + t.Error("Expected user2's crew membership to still exist") + } +} + +func TestDeleteUserDataFull_HandlesNonExistentUser(t *testing.T) { + db, err := InitDB(":memory:") + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + oauthStore := NewOAuthStore(db) + + // Try to delete non-existent user - should not error + err = DeleteUserDataFull(db, oauthStore, "did:plc:nonexistent") + if err != nil { + t.Errorf("Expected no error for non-existent user, got: %v", err) + } +} + +func TestDeleteUserDataFull_WithNilOAuthStore(t *testing.T) { + db, err := InitDB(":memory:") + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + testUser := &User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := UpsertUser(db, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + // Delete with nil OAuth store - should still work + err = DeleteUserDataFull(db, nil, testUser.DID) + if err != nil { + t.Errorf("Expected no error with nil OAuth store, got: %v", err) + } + + // Verify user was deleted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query users: %v", err) + } + if count != 0 { + t.Error("Expected user to be deleted") + } +} + +func TestDeleteUserDataFull_DeletesDenials(t *testing.T) { + db, err := InitDB(":memory:") + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + testUser := &User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := UpsertUser(db, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + // Create denial record + _, err = db.Exec(` + INSERT INTO hold_crew_denials (hold_did, user_did, denial_count, next_retry_at, last_denied_at) + VALUES (?, ?, ?, ?, ?) + `, "did:web:hold.example.com", testUser.DID, 1, time.Now().Add(24*time.Hour), time.Now()) + if err != nil { + t.Fatalf("Failed to create crew denial: %v", err) + } + + oauthStore := NewOAuthStore(db) + + err = DeleteUserDataFull(db, oauthStore, testUser.DID) + if err != nil { + t.Fatalf("DeleteUserDataFull failed: %v", err) + } + + // Verify denial was deleted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_denials WHERE user_did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query crew denials: %v", err) + } + if count != 0 { + t.Error("Expected crew denials to be deleted") + } +} diff --git a/pkg/appview/handlers/delete.go b/pkg/appview/handlers/delete.go new file mode 100644 index 0000000..7edf359 --- /dev/null +++ b/pkg/appview/handlers/delete.go @@ -0,0 +1,344 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/middleware" + "atcr.io/pkg/atproto" + "atcr.io/pkg/auth" + "atcr.io/pkg/auth/oauth" +) + +// DeleteAccountRequest represents the GDPR account deletion request +type DeleteAccountRequest struct { + DeletePDSRecords bool `json:"delete_pds_records"` + Confirmation string `json:"confirmation"` // Must be "DELETE " to confirm +} + +// DeleteAccountResponse represents the result of account deletion +type DeleteAccountResponse struct { + Success bool `json:"success"` + AppViewDeleted bool `json:"appview_deleted"` + PDSDeleted bool `json:"pds_deleted,omitempty"` + PDSCollections map[string]int `json:"pds_collections_deleted,omitempty"` + HoldResults []HoldDeleteResult `json:"hold_results"` + Errors []string `json:"errors,omitempty"` +} + +// HoldDeleteResult represents the result of deleting data from a single hold +type HoldDeleteResult struct { + HoldDID string `json:"hold_did"` + Relationship string `json:"relationship"` // "captain" or "crew_member" + Status string `json:"status"` // "success", "failed", "offline" + Error string `json:"error,omitempty"` + CrewDeleted bool `json:"crew_deleted,omitempty"` + LayersDeleted int `json:"layers_deleted,omitempty"` + StatsDeleted int `json:"stats_deleted,omitempty"` +} + +// DeleteAccountHandler handles GDPR account deletion requests +type DeleteAccountHandler struct { + DB *sql.DB + OAuthStore *db.OAuthStore + Refresher *oauth.Refresher +} + +func (h *DeleteAccountHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Get authenticated user from middleware + user := middleware.GetUser(r) + if user == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req DeleteAccountRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Require confirmation with handle (e.g., "DELETE alice.bsky.social") + expectedConfirmation := "DELETE " + user.Handle + if req.Confirmation != expectedConfirmation { + http.Error(w, fmt.Sprintf("Confirmation required: must send confirmation='DELETE %s'", user.Handle), http.StatusBadRequest) + return + } + + slog.Info("Processing account deletion request", + "component", "delete", + "did", user.DID, + "delete_pds_records", req.DeletePDSRecords) + + response := DeleteAccountResponse{ + HoldResults: []HoldDeleteResult{}, + } + + // 1. Delete from each hold where user is a member + holdResults := h.deleteFromHolds(r.Context(), user) + response.HoldResults = holdResults + + // 2. If requested, delete PDS records + if req.DeletePDSRecords { + pdsResults, err := h.deletePDSRecords(r.Context(), user) + if err != nil { + slog.Error("Failed to delete PDS records", + "component", "delete", + "did", user.DID, + "error", err) + response.Errors = append(response.Errors, fmt.Sprintf("PDS deletion error: %v", err)) + } else { + response.PDSDeleted = true + response.PDSCollections = pdsResults + } + } + + // 3. Delete from AppView database (last, since we need OAuth tokens for above steps) + if err := db.DeleteUserDataFull(h.DB, h.OAuthStore, user.DID); err != nil { + slog.Error("Failed to delete AppView data", + "component", "delete", + "did", user.DID, + "error", err) + response.Errors = append(response.Errors, fmt.Sprintf("AppView deletion error: %v", err)) + } else { + response.AppViewDeleted = true + } + + // Set success if AppView data was deleted (main requirement) + response.Success = response.AppViewDeleted + + slog.Info("Account deletion completed", + "component", "delete", + "did", user.DID, + "success", response.Success, + "holds_processed", len(response.HoldResults), + "pds_deleted", response.PDSDeleted) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("Failed to encode response", "error", err) + } +} + +// deleteFromHolds deletes user data from all holds where they are a member +func (h *DeleteAccountHandler) deleteFromHolds(ctx context.Context, user *db.User) []HoldDeleteResult { + var results []HoldDeleteResult + + // Build metadata map: holdDID → relationship + holdMeta := make(map[string]string) + + // Get holds where user is captain + if h.DB != nil { + captainHolds, err := db.GetCaptainRecordsForOwner(h.DB, user.DID) + if err != nil { + slog.Warn("Failed to get captain records for deletion", + "component", "delete", + "did", user.DID, + "error", err) + } else { + for _, hold := range captainHolds { + holdMeta[hold.HoldDID] = "captain" + } + } + } + + // Get crew memberships from database + memberships, err := db.GetCrewMemberships(h.DB, user.DID) + if err != nil { + slog.Warn("Failed to get crew memberships for deletion", + "component", "delete", + "did", user.DID, + "error", err) + } else { + for _, m := range memberships { + // Don't overwrite captain relationship + if _, exists := holdMeta[m.HoldDID]; !exists { + holdMeta[m.HoldDID] = "crew_member" + } + } + } + + if len(holdMeta) == 0 { + return results + } + + // Delete from each hold concurrently with timeout + var wg sync.WaitGroup + resultChan := make(chan HoldDeleteResult, len(holdMeta)) + + for holdDID, relationship := range holdMeta { + wg.Add(1) + go func(holdDID, relationship string) { + defer wg.Done() + result := h.deleteFromSingleHold(ctx, user, holdDID, relationship) + resultChan <- result + }(holdDID, relationship) + } + + // Wait for all goroutines to complete + wg.Wait() + close(resultChan) + + // Collect results + for result := range resultChan { + results = append(results, result) + } + + return results +} + +// deleteFromSingleHold deletes user data from a single hold +func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *db.User, holdDID, relationship string) HoldDeleteResult { + // Resolve hold DID to URL + holdURL := atproto.ResolveHoldURL(holdDID) + endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData" + + result := HoldDeleteResult{ + HoldDID: holdDID, + Relationship: relationship, + Status: "failed", + } + + // Check if we have OAuth refresher (needed for service tokens) + if h.Refresher == nil { + result.Error = "OAuth not configured - cannot authenticate to hold" + return result + } + + // Create context with timeout (10 seconds per hold for deletion) + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // Get service token from user's PDS + serviceToken, err := auth.GetOrFetchServiceToken(timeoutCtx, h.Refresher, user.DID, holdDID, user.PDSEndpoint) + if err != nil { + slog.Warn("Failed to get service token for hold deletion", + "component", "delete", + "hold_did", holdDID, + "user_did", user.DID, + "error", err) + result.Error = fmt.Sprintf("Failed to authenticate: %v", err) + return result + } + + // Create request + req, err := http.NewRequestWithContext(timeoutCtx, "DELETE", endpoint, nil) + if err != nil { + result.Error = fmt.Sprintf("Failed to create request: %v", err) + return result + } + + // Set auth header + req.Header.Set("Authorization", "Bearer "+serviceToken) + + // Make request + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Warn("Hold deletion request failed", + "component", "delete", + "hold_did", holdDID, + "endpoint", endpoint, + "error", err) + result.Status = "offline" + result.Error = fmt.Sprintf("Could not contact hold: %v", err) + return result + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + result.Error = fmt.Sprintf("Hold returned status %d: %s", resp.StatusCode, string(body)) + return result + } + + // Parse response + var holdResponse struct { + Success bool `json:"success"` + CrewDeleted bool `json:"crew_deleted"` + LayersDeleted int `json:"layers_deleted"` + StatsDeleted int `json:"stats_deleted"` + } + if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil { + result.Error = fmt.Sprintf("Failed to parse response: %v", err) + return result + } + + // Update result with success data + result.Status = "success" + result.CrewDeleted = holdResponse.CrewDeleted + result.LayersDeleted = holdResponse.LayersDeleted + result.StatsDeleted = holdResponse.StatsDeleted + + slog.Debug("Successfully deleted data from hold", + "component", "delete", + "hold_did", holdDID, + "user_did", user.DID, + "crew_deleted", holdResponse.CrewDeleted, + "layers_deleted", holdResponse.LayersDeleted, + "stats_deleted", holdResponse.StatsDeleted) + + return result +} + +// deletePDSRecords deletes all io.atcr.* records from the user's PDS +func (h *DeleteAccountHandler) deletePDSRecords(ctx context.Context, user *db.User) (map[string]int, error) { + if h.Refresher == nil { + return nil, fmt.Errorf("OAuth not configured") + } + + results := make(map[string]int) + + // Create ATProto client with session provider + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) + + // Collections to delete + collections := []string{ + atproto.ManifestCollection, // io.atcr.manifest + atproto.TagCollection, // io.atcr.tag + atproto.StarCollection, // io.atcr.sailor.star + atproto.RepoPageCollection, // io.atcr.repo.page + } + + for _, collection := range collections { + deleted, err := client.DeleteAllRecordsInCollection(ctx, collection) + if err != nil { + slog.Warn("Failed to delete records in collection", + "component", "delete", + "did", user.DID, + "collection", collection, + "error", err) + // Continue with other collections + } + results[collection] = deleted + if deleted > 0 { + slog.Debug("Deleted records from collection", + "component", "delete", + "did", user.DID, + "collection", collection, + "count", deleted) + } + } + + // Delete sailor profile (single record at rkey "self") + err := client.DeleteRecord(ctx, atproto.SailorProfileCollection, "self") + if err != nil { + slog.Warn("Failed to delete sailor profile", + "component", "delete", + "did", user.DID, + "error", err) + } else { + results[atproto.SailorProfileCollection] = 1 + } + + return results, nil +} diff --git a/pkg/appview/handlers/delete_test.go b/pkg/appview/handlers/delete_test.go new file mode 100644 index 0000000..8fb019b --- /dev/null +++ b/pkg/appview/handlers/delete_test.go @@ -0,0 +1,318 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/middleware" + _ "github.com/mattn/go-sqlite3" +) + +func TestDeleteAccountHandler_Unauthorized(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + handler := &DeleteAccountHandler{ + DB: database, + OAuthStore: nil, + Refresher: nil, + } + + reqBody := DeleteAccountRequest{ + DeletePDSRecords: false, + Confirmation: "DELETE test.bsky.social", + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } +} + +func TestDeleteAccountHandler_MissingConfirmation(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + // Create test user + testUser := &db.User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := db.UpsertUser(database, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + handler := &DeleteAccountHandler{ + DB: database, + OAuthStore: nil, + Refresher: nil, + } + + // Request without confirmation + reqBody := DeleteAccountRequest{ + DeletePDSRecords: false, + Confirmation: "", + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = middleware.WithUser(req, testUser) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code) + } +} + +func TestDeleteAccountHandler_WrongConfirmation(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + testUser := &db.User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := db.UpsertUser(database, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + handler := &DeleteAccountHandler{ + DB: database, + OAuthStore: nil, + Refresher: nil, + } + + tests := []struct { + name string + confirmation string + }{ + {"just DELETE", "DELETE"}, + {"wrong handle", "DELETE wrong.handle"}, + {"lowercase", "delete test.bsky.social"}, + {"extra spaces", "DELETE test.bsky.social"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := DeleteAccountRequest{ + DeletePDSRecords: false, + Confirmation: tt.confirmation, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = middleware.WithUser(req, testUser) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected status %d for confirmation %q, got %d", http.StatusBadRequest, tt.confirmation, rr.Code) + } + }) + } +} + +func TestDeleteAccountHandler_SuccessfulDeletion(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + // Create test user with some data + testUser := &db.User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := db.UpsertUser(database, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + // Create some manifests for the user + _, err := database.Exec(` + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, + "application/vnd.oci.image.manifest.v1+json", time.Now()) + if err != nil { + t.Fatalf("Failed to create manifest: %v", err) + } + + // Create OAuth store for testing + oauthStore := db.NewOAuthStore(database) + + handler := &DeleteAccountHandler{ + DB: database, + OAuthStore: oauthStore, + Refresher: nil, // No remote operations in this test + } + + reqBody := DeleteAccountRequest{ + DeletePDSRecords: false, + Confirmation: "DELETE test.bsky.social", + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = middleware.WithUser(req, testUser) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var response DeleteAccountResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success=true") + } + if !response.AppViewDeleted { + t.Error("Expected appview_deleted=true") + } + + // Verify user was actually deleted + var count int + err = database.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query user: %v", err) + } + if count != 0 { + t.Error("Expected user to be deleted from database") + } + + // Verify manifests were cascade deleted + err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query manifests: %v", err) + } + if count != 0 { + t.Error("Expected manifests to be cascade deleted") + } +} + +func TestDeleteAccountHandler_InvalidJSON(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + testUser := &db.User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := db.UpsertUser(database, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + handler := &DeleteAccountHandler{ + DB: database, + OAuthStore: nil, + Refresher: nil, + } + + req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader([]byte("not json"))) + req.Header.Set("Content-Type", "application/json") + req = middleware.WithUser(req, testUser) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code) + } +} + +func TestDeleteAccountHandler_DeletesHoldMembershipData(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + testUser := &db.User{ + DID: "did:plc:test123", + Handle: "test.bsky.social", + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + if err := db.UpsertUser(database, testUser); err != nil { + t.Fatalf("Failed to create user: %v", err) + } + + // Create hold membership data (these tables don't cascade) + _, err := database.Exec(` + INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at) + VALUES (?, ?, ?, ?) + `, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("Failed to create crew approval: %v", err) + } + + _, err = database.Exec(` + INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions) + VALUES (?, ?, ?, ?) + `, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`) + if err != nil { + t.Fatalf("Failed to create crew member: %v", err) + } + + oauthStore := db.NewOAuthStore(database) + + handler := &DeleteAccountHandler{ + DB: database, + OAuthStore: oauthStore, + Refresher: nil, + } + + reqBody := DeleteAccountRequest{ + DeletePDSRecords: false, + Confirmation: "DELETE test.bsky.social", + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = middleware.WithUser(req, testUser) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + + // Verify hold membership data was deleted + var count int + err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query crew approvals: %v", err) + } + if count != 0 { + t.Error("Expected crew approvals to be deleted") + } + + err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count) + if err != nil { + t.Fatalf("Failed to query crew members: %v", err) + } + if count != 0 { + t.Error("Expected crew members to be deleted") + } +} diff --git a/pkg/appview/middleware/auth.go b/pkg/appview/middleware/auth.go index 786624a..573781e 100644 --- a/pkg/appview/middleware/auth.go +++ b/pkg/appview/middleware/auth.go @@ -103,3 +103,10 @@ func GetUser(r *http.Request) *db.User { } return user } + +// WithUser returns a new request with the user set in the context. +// This is primarily useful for testing. +func WithUser(r *http.Request, user *db.User) *http.Request { + ctx := context.WithValue(r.Context(), userKey, user) + return r.WithContext(ctx) +} diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index b99de0e..7495c4b 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -246,6 +246,13 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { DB: deps.Database, Refresher: deps.Refresher, }).ServeHTTP) + + // GDPR account deletion + r.Delete("/api/account", (&uihandlers.DeleteAccountHandler{ + DB: deps.Database, + OAuthStore: deps.OAuthStore, + Refresher: deps.Refresher, + }).ServeHTTP) }) // Logout endpoint (supports both GET and POST) diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index b8c5538..f58f77c 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -194,6 +194,32 @@

+ + +
+

Danger Zone

+ +
+

Delete Account

+

Permanently delete your ATCR account and all associated data. This action cannot be undone.

+ +
+ + + This will remove manifests, tags, stars, and profile data from your Bluesky account. + Your PDS data is always under your control, so this is optional. + +
+ + +
+
@@ -331,6 +357,171 @@ // Refresh devices every 30 seconds (to show new authorizations) setInterval(loadDevices, 30000); })(); + + // Account Deletion JavaScript + (function() { + const deleteBtn = document.getElementById('delete-account-btn'); + if (!deleteBtn) return; + + deleteBtn.addEventListener('click', function() { + showDeleteConfirmationModal(); + }); + + function showDeleteConfirmationModal() { + // Create modal backdrop + const modal = document.createElement('div'); + modal.className = 'delete-modal-backdrop'; + modal.innerHTML = ` +
+

Delete Account

+

+ This action cannot be undone. This will permanently delete: +

+ +

Type DELETE {{ .Profile.Handle }} to confirm:

+ + +
+ `; + document.body.appendChild(modal); + + // Reinitialize Lucide icons for the modal + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + + // Focus the input + const confirmInput = document.getElementById('confirm-delete-input'); + const confirmBtn = document.getElementById('confirm-delete'); + const cancelBtn = document.getElementById('cancel-delete'); + + setTimeout(() => confirmInput.focus(), 100); + + // Expected confirmation string + const expectedConfirmation = 'DELETE {{ .Profile.Handle }}'; + + // Enable button only when full confirmation is typed + confirmInput.addEventListener('input', function() { + confirmBtn.disabled = this.value !== expectedConfirmation; + }); + + // Handle enter key + confirmInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && this.value === expectedConfirmation) { + performAccountDeletion(); + } + }); + + // Cancel button + cancelBtn.addEventListener('click', function() { + modal.remove(); + }); + + // Click outside to close + modal.addEventListener('click', function(e) { + if (e.target === modal) { + modal.remove(); + } + }); + + // Escape key to close + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { + modal.remove(); + document.removeEventListener('keydown', escHandler); + } + }); + + // Confirm delete + confirmBtn.addEventListener('click', performAccountDeletion); + + async function performAccountDeletion() { + const deletePDS = document.getElementById('delete-pds-records').checked; + + // Show loading state + confirmBtn.disabled = true; + confirmBtn.innerHTML = ' Deleting...'; + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + cancelBtn.disabled = true; + + try { + const response = await fetch('/api/account', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + delete_pds_records: deletePDS, + confirmation: expectedConfirmation + }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // Show success and redirect + modal.querySelector('.delete-modal').innerHTML = ` +

Account Deleted

+

Your account has been successfully deleted.

+

Redirecting to home page...

+ `; + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + setTimeout(() => { + window.location.href = '/?deleted=true'; + }, 2000); + } else { + // Show error + const errors = result.errors || ['An unknown error occurred']; + modal.querySelector('.delete-modal').innerHTML = ` +

Deletion Failed

+

There were errors during account deletion:

+ + + `; + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + } + } catch (err) { + console.error('Delete account error:', err); + modal.querySelector('.delete-modal').innerHTML = ` +

Error

+

Failed to delete account: ${escapeHtml(err.message)}

+ + `; + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + } + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + })(); diff --git a/pkg/atproto/client.go b/pkg/atproto/client.go index fa1d74f..f4bd63e 100644 --- a/pkg/atproto/client.go +++ b/pkg/atproto/client.go @@ -687,3 +687,92 @@ func (c *Client) DID() string { func (c *Client) PDSEndpoint() string { return c.pdsEndpoint } + +// ListRecordsWithCursor lists records in a collection with cursor-based pagination. +// Returns records, next cursor (empty if no more), and error. +func (c *Client) ListRecordsWithCursor(ctx context.Context, collection string, limit int, cursor string) ([]Record, string, error) { + url := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=%d", + c.pdsEndpoint, RepoListRecords, c.did, collection, limit) + + if cursor != "" { + url += "&cursor=" + cursor + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", err + } + + if c.accessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("failed to list records: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result struct { + Records []Record `json:"records"` + Cursor string `json:"cursor,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, "", fmt.Errorf("failed to decode response: %w", err) + } + + return result.Records, result.Cursor, nil +} + +// DeleteAllRecordsInCollection deletes all records in a collection. +// Returns the number of records deleted. +// This is used for GDPR account deletion to remove all user records from a collection. +func (c *Client) DeleteAllRecordsInCollection(ctx context.Context, collection string) (int, error) { + deleted := 0 + cursor := "" + + for { + // List records with pagination + records, nextCursor, err := c.ListRecordsWithCursor(ctx, collection, 100, cursor) + if err != nil { + return deleted, fmt.Errorf("failed to list records: %w", err) + } + + for _, rec := range records { + // Extract rkey from URI (at://{did}/{collection}/{rkey}) + rkey := extractRkeyFromURI(rec.URI) + if rkey == "" { + continue + } + + err := c.DeleteRecord(ctx, collection, rkey) + if err != nil { + // Log but continue with other records + continue + } + deleted++ + } + + if nextCursor == "" { + break + } + cursor = nextCursor + } + + return deleted, nil +} + +// extractRkeyFromURI extracts the rkey from an AT URI (at://{did}/{collection}/{rkey}) +func extractRkeyFromURI(uri string) string { + // Format: at://did:plc:xxx/io.atcr.manifest/abc123 + parts := strings.Split(uri, "/") + if len(parts) < 5 { + return "" + } + return parts[len(parts)-1] +} diff --git a/pkg/hold/pds/delete.go b/pkg/hold/pds/delete.go new file mode 100644 index 0000000..48809c5 --- /dev/null +++ b/pkg/hold/pds/delete.go @@ -0,0 +1,192 @@ +package pds + +import ( + "context" + "fmt" + "log/slog" + + "atcr.io/pkg/atproto" +) + +// UserDeleteResult contains the results of deleting a user's data from the hold +type UserDeleteResult struct { + CrewDeleted bool `json:"crew_deleted"` + LayersDeleted int `json:"layers_deleted"` + StatsDeleted int `json:"stats_deleted"` +} + +// DeleteUserData deletes all data for a user from the hold's PDS. +// This removes: +// - Crew record (if user is a crew member) +// - Layer records (where userDid matches) +// - Stats records (where ownerDid matches) +// +// NOTE: This does NOT delete the captain record if the user is the hold owner. +// NOTE: This does NOT delete actual blob data from S3 - only the PDS records. +func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDeleteResult, error) { + result := &UserDeleteResult{} + + slog.Info("Deleting user data from hold", + "user_did", userDID, + "hold_did", p.DID()) + + // 1. Delete crew record (if exists) + crewDeleted, err := p.deleteCrewRecord(ctx, userDID) + if err != nil { + slog.Warn("Failed to delete crew record", + "user_did", userDID, + "error", err) + // Continue with other deletions + } + result.CrewDeleted = crewDeleted + + // 2. Delete layer records + layersDeleted, err := p.deleteLayerRecords(ctx, userDID) + if err != nil { + slog.Warn("Failed to delete layer records", + "user_did", userDID, + "error", err) + // Continue with other deletions + } + result.LayersDeleted = layersDeleted + + // 3. Delete stats records + statsDeleted, err := p.deleteStatsRecords(ctx, userDID) + if err != nil { + slog.Warn("Failed to delete stats records", + "user_did", userDID, + "error", err) + // Continue with other deletions + } + result.StatsDeleted = statsDeleted + + slog.Info("User data deletion complete", + "user_did", userDID, + "hold_did", p.DID(), + "crew_deleted", result.CrewDeleted, + "layers_deleted", result.LayersDeleted, + "stats_deleted", result.StatsDeleted) + + return result, nil +} + +// deleteCrewRecord removes a user's crew record from the hold +func (p *HoldPDS) deleteCrewRecord(ctx context.Context, userDID string) (bool, error) { + // Check if user has a crew record + _, _, err := p.GetCrewMemberByDID(ctx, userDID) + if err != nil { + // No crew record found + return false, nil + } + + // Delete the crew record + err = p.RemoveCrewMemberByDID(ctx, userDID) + if err != nil { + return false, fmt.Errorf("failed to remove crew member: %w", err) + } + + slog.Debug("Deleted crew record", "user_did", userDID) + return true, nil +} + +// deleteLayerRecords removes all layer records for a user +func (p *HoldPDS) deleteLayerRecords(ctx context.Context, userDID string) (int, error) { + if p.recordsIndex == nil { + return 0, fmt.Errorf("records index not available") + } + + deleted := 0 + cursor := "" + batchSize := 100 + + for { + // Get layer records for this user via the DID index + records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor) + if err != nil { + return deleted, fmt.Errorf("failed to list layer records: %w", err) + } + + for _, rec := range records { + // Delete from repo (MST) + err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.LayerCollection, rec.Rkey) + if err != nil { + slog.Warn("Failed to delete layer record from repo", + "rkey", rec.Rkey, + "error", err) + continue + } + + // Delete from index + err = p.recordsIndex.DeleteRecord(atproto.LayerCollection, rec.Rkey) + if err != nil { + slog.Warn("Failed to delete layer record from index", + "rkey", rec.Rkey, + "error", err) + } + + deleted++ + } + + if nextCursor == "" { + break + } + cursor = nextCursor + } + + if deleted > 0 { + slog.Debug("Deleted layer records", "user_did", userDID, "count", deleted) + } + + return deleted, nil +} + +// deleteStatsRecords removes all stats records for a user +func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int, error) { + if p.recordsIndex == nil { + return 0, fmt.Errorf("records index not available") + } + + deleted := 0 + cursor := "" + batchSize := 100 + + for { + // Get stats records for this user via the DID index + records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.StatsCollection, userDID, batchSize, cursor) + if err != nil { + return deleted, fmt.Errorf("failed to list stats records: %w", err) + } + + for _, rec := range records { + // Delete from repo (MST) + err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.StatsCollection, rec.Rkey) + if err != nil { + slog.Warn("Failed to delete stats record from repo", + "rkey", rec.Rkey, + "error", err) + continue + } + + // Delete from index + err = p.recordsIndex.DeleteRecord(atproto.StatsCollection, rec.Rkey) + if err != nil { + slog.Warn("Failed to delete stats record from index", + "rkey", rec.Rkey, + "error", err) + } + + deleted++ + } + + if nextCursor == "" { + break + } + cursor = nextCursor + } + + if deleted > 0 { + slog.Debug("Deleted stats records", "user_did", userDID, "count", deleted) + } + + return deleted, nil +} diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index a36e808..7561198 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -195,8 +195,10 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) { r.Group(func(r chi.Router) { r.Use(h.requireAuth) r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew) - // GDPR data export endpoint (TODO: implement) + // GDPR data export endpoint r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData) + // GDPR data deletion endpoint + r.Delete("/xrpc/io.atcr.hold.deleteUserData", h.HandleDeleteUserData) }) // Public quota endpoint (no auth - quota is per-user, just needs userDid param) @@ -1630,3 +1632,78 @@ func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Reques render.JSON(w, r, export) } + +// HoldUserDeleteResponse represents the result of GDPR data deletion +type HoldUserDeleteResponse struct { + Success bool `json:"success"` + CrewDeleted bool `json:"crew_deleted"` + LayersDeleted int `json:"layers_deleted"` + StatsDeleted int `json:"stats_deleted"` +} + +// HandleDeleteUserData handles GDPR data deletion requests for a specific user. +// This endpoint deletes all records stored on this hold's PDS that reference +// the authenticated user's DID. +// +// Deletes: +// - io.atcr.hold.crew record for the DID (if exists, and user is NOT captain) +// - io.atcr.hold.layer records where userDid matches +// - io.atcr.hold.stats records where ownerDid matches +// +// NOTE: This does NOT delete the captain record if the user is the hold owner. +// NOTE: This does NOT delete actual blob data from S3 - only the PDS records. +// +// Authentication: Requires valid service token from user's PDS +func (h *XRPCHandler) HandleDeleteUserData(w http.ResponseWriter, r *http.Request) { + // Get authenticated user from context + user := getUserFromContext(r) + if user == nil { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + + slog.Info("GDPR data deletion requested", + "requester_did", user.DID, + "hold_did", h.pds.DID()) + + // Check if user is captain - if so, skip crew deletion but continue with layer/stats + isCaptain := false + _, captain, err := h.pds.GetCaptainRecord(r.Context()) + if err == nil && captain != nil && captain.Owner == user.DID { + isCaptain = true + slog.Info("User is captain of this hold, will not delete captain record", + "user_did", user.DID, + "hold_did", h.pds.DID()) + } + + // Delete user data from hold + result, err := h.pds.DeleteUserData(r.Context(), user.DID) + if err != nil { + slog.Error("Failed to delete user data", + "user_did", user.DID, + "hold_did", h.pds.DID(), + "error", err) + http.Error(w, fmt.Sprintf("failed to delete user data: %v", err), http.StatusInternalServerError) + return + } + + // If user is captain, they shouldn't have a crew record deleted (they're the owner) + // The DeleteUserData function handles crew deletion, but we report it appropriately + if isCaptain { + result.CrewDeleted = false + } + + slog.Info("GDPR data deletion completed", + "user_did", user.DID, + "hold_did", h.pds.DID(), + "crew_deleted", result.CrewDeleted, + "layers_deleted", result.LayersDeleted, + "stats_deleted", result.StatsDeleted) + + render.JSON(w, r, HoldUserDeleteResponse{ + Success: true, + CrewDeleted: result.CrewDeleted, + LayersDeleted: result.LayersDeleted, + StatsDeleted: result.StatsDeleted, + }) +}