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" ) // HoldExportResult represents the result of fetching export from a hold type HoldExportResult struct { HoldDID string `json:"hold_did"` Endpoint string `json:"endpoint"` Relationship string `json:"relationship"` // "captain", "crew_member" FirstSeen time.Time `json:"first_seen"` Status string `json:"status"` // "success", "failed", "offline" Error string `json:"error,omitempty"` Data json.RawMessage `json:"data,omitempty"` // Raw JSON from hold } // FullUserDataExport represents the complete GDPR export including hold data type FullUserDataExport struct { AppViewData *db.UserDataExport `json:"appview_data"` HoldExports []HoldExportResult `json:"hold_exports"` } // ExportUserDataHandler handles GDPR data export requests type ExportUserDataHandler struct { BaseUIHandler } func (h *ExportUserDataHandler) 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 } slog.Info("Processing data export request", "component", "export", "did", user.DID) // Export all user data from database appViewData, err := db.ExportUserData(h.DB, user.DID) if err != nil { slog.Error("Failed to export user data", "component", "export", "did", user.DID, "error", err) http.Error(w, "Failed to export data", http.StatusInternalServerError) return } // Get all holds where user is a member (from cached crew memberships) holdExports := h.fetchHoldExports(r.Context(), user) // Build full export fullExport := FullUserDataExport{ AppViewData: appViewData, HoldExports: holdExports, } // Set headers for file download filename := fmt.Sprintf("atcr-data-export-%s.json", time.Now().Format("2006-01-02")) w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) // Write JSON with indentation for readability encoder := json.NewEncoder(w) encoder.SetIndent("", " ") if err := encoder.Encode(fullExport); err != nil { slog.Error("Failed to encode export data", "component", "export", "did", user.DID, "error", err) // Can't send error response at this point, headers already sent return } slog.Info("Data export completed successfully", "component", "export", "did", user.DID, "hold_count", len(holdExports)) } // holdMetadata stores relationship info for a hold type holdMetadata struct { relationship string firstSeen time.Time } // fetchHoldExports fetches export data from all holds where user is a member func (h *ExportUserDataHandler) fetchHoldExports(ctx context.Context, user *db.User) []HoldExportResult { var results []HoldExportResult // Build metadata map: holdDID → (relationship, firstSeen) holdMeta := make(map[string]holdMetadata) // 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 export", "component", "export", "did", user.DID, "error", err) } else { for _, hold := range captainHolds { holdMeta[hold.HoldDID] = holdMetadata{ relationship: "captain", firstSeen: hold.UpdatedAt, } } } } // Get crew memberships from database memberships, err := db.GetCrewMemberships(h.DB, user.DID) if err != nil { slog.Warn("Failed to get crew memberships for export", "component", "export", "did", user.DID, "error", err) } else { for _, m := range memberships { // Don't overwrite captain relationship if _, exists := holdMeta[m.HoldDID]; !exists { holdMeta[m.HoldDID] = holdMetadata{ relationship: "crew_member", firstSeen: m.CreatedAt, } } } } if len(holdMeta) == 0 { return results } // Fetch from each hold concurrently with timeout var wg sync.WaitGroup resultChan := make(chan HoldExportResult, len(holdMeta)) for holdDID, meta := range holdMeta { wg.Add(1) go func(holdDID string, meta holdMetadata) { defer wg.Done() result := h.fetchSingleHoldExport(ctx, user, holdDID, meta) resultChan <- result }(holdDID, meta) } // Wait for all goroutines to complete wg.Wait() close(resultChan) // Collect results for result := range resultChan { results = append(results, result) } return results } // fetchSingleHoldExport fetches export data from a single hold func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string, meta holdMetadata) HoldExportResult { // Resolve hold DID to URL holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) result := HoldExportResult{ HoldDID: holdDID, Relationship: meta.relationship, FirstSeen: meta.firstSeen, Status: "failed", } if err != nil { slog.Warn("Failed to resolve hold URL for export", "holdDid", holdDID, "error", err) result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err) return result } endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData" result.Endpoint = endpoint // 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 (5 seconds per hold) timeoutCtx, cancel := context.WithTimeout(ctx, 5*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 export", "component", "export", "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, "GET", 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 export request failed", "component", "export", "hold_did", holdDID, "endpoint", endpoint, "error", err) result.Status = "offline" result.Error = fmt.Sprintf("Could not contact hold. Please request export directly at: %s", endpoint) 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 } // Read response body body, err := io.ReadAll(resp.Body) if err != nil { result.Error = fmt.Sprintf("Failed to read response: %v", err) return result } // Store raw JSON data result.Status = "success" result.Data = json.RawMessage(body) slog.Debug("Successfully fetched hold export", "component", "export", "hold_did", holdDID, "user_did", user.DID) return result }