mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
352 lines
10 KiB
Go
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
|
|
}
|