mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-01 13:35:46 +00:00
259 lines
7.2 KiB
Go
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
|
|
}
|