package handlers import ( "context" "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" ) // 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"` PostsDeleted int `json:"posts_deleted,omitempty"` } // DeleteAccountHandler handles GDPR account deletion requests type DeleteAccountHandler struct { BaseUIHandler } 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"` PostsDeleted int `json:"posts_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 result.PostsDeleted = holdResponse.PostsDeleted 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, "posts_deleted", holdResponse.PostsDeleted) 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 }