Files
at-container-registry/pkg/appview/handlers/export.go
2026-02-15 14:20:02 -06:00

259 lines
7.2 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"
)
// 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
}