Files

352 lines
10 KiB
Go

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 <handle>" 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, err := atproto.ResolveHoldURL(ctx, holdDID)
result := HoldDeleteResult{
HoldDID: holdDID,
Relationship: relationship,
Status: "failed",
}
if err != nil {
slog.Warn("Failed to resolve hold URL for deletion", "holdDid", holdDID, "error", err)
result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err)
return result
}
endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
// 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
}