mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
begin delete my account implementation
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// DeleteUserDataFull performs complete user deletion including non-cascading tables.
|
||||
// This is the main function for GDPR account deletion.
|
||||
//
|
||||
// Order of operations:
|
||||
// 1. Delete hold membership data (non-cascading tables)
|
||||
// 2. Delete OAuth sessions
|
||||
// 3. Delete user (cascades to manifests, tags, stars, repo_pages, etc.)
|
||||
//
|
||||
// This should be called AFTER remote cleanup (hold services, PDS records)
|
||||
// since we need the OAuth tokens to authenticate those requests.
|
||||
func DeleteUserDataFull(db *sql.DB, oauthStore *OAuthStore, did string) error {
|
||||
slog.Info("Starting full user data deletion", "did", did)
|
||||
|
||||
// 1. Delete non-cascading hold membership tables
|
||||
if err := deleteHoldMembershipData(db, did); err != nil {
|
||||
slog.Error("Failed to delete hold membership data", "did", did, "error", err)
|
||||
return fmt.Errorf("failed to delete hold membership data: %w", err)
|
||||
}
|
||||
|
||||
// 2. Delete OAuth sessions
|
||||
if oauthStore != nil {
|
||||
if err := oauthStore.DeleteSessionsForDID(context.Background(), did); err != nil {
|
||||
slog.Warn("Failed to delete OAuth sessions", "did", did, "error", err)
|
||||
// Continue - not critical
|
||||
} else {
|
||||
slog.Debug("Deleted OAuth sessions", "did", did)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete user (cascades to manifests, tags, stars, annotations, etc.)
|
||||
if err := DeleteUserData(db, did); err != nil {
|
||||
slog.Error("Failed to delete user data", "did", did, "error", err)
|
||||
return fmt.Errorf("failed to delete user data: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("User data deletion completed", "did", did)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteHoldMembershipData deletes non-cascading hold membership tables.
|
||||
// These tables don't have foreign keys to the users table.
|
||||
func deleteHoldMembershipData(db *sql.DB, did string) error {
|
||||
// Delete from hold_crew_approvals (where user is the approved member)
|
||||
result, err := db.Exec(`DELETE FROM hold_crew_approvals WHERE user_did = ?`, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew approvals: %w", err)
|
||||
}
|
||||
approvalsDeleted, _ := result.RowsAffected()
|
||||
|
||||
// Delete from hold_crew_denials (where user was denied)
|
||||
result, err = db.Exec(`DELETE FROM hold_crew_denials WHERE user_did = ?`, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew denials: %w", err)
|
||||
}
|
||||
denialsDeleted, _ := result.RowsAffected()
|
||||
|
||||
// Delete from hold_crew_members (cached crew memberships)
|
||||
result, err = db.Exec(`DELETE FROM hold_crew_members WHERE member_did = ?`, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew members: %w", err)
|
||||
}
|
||||
membersDeleted, _ := result.RowsAffected()
|
||||
|
||||
slog.Debug("Deleted hold membership data",
|
||||
"did", did,
|
||||
"approvals_deleted", approvalsDeleted,
|
||||
"denials_deleted", denialsDeleted,
|
||||
"members_deleted", membersDeleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeleteUserDataFull_DeletesAllData(t *testing.T) {
|
||||
db, err := InitDB(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create test user
|
||||
testUser := &User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := UpsertUser(db, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
|
||||
"application/vnd.oci.image.manifest.v1+json", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manifest: %v", err)
|
||||
}
|
||||
|
||||
// Create tag
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO tags (did, repository, tag, digest, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, testUser.DID, "myapp", "latest", "sha256:abc123", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tag: %v", err)
|
||||
}
|
||||
|
||||
// Create hold membership data (non-cascading)
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create crew approval: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create crew member: %v", err)
|
||||
}
|
||||
|
||||
// Create OAuth store
|
||||
oauthStore := NewOAuthStore(db)
|
||||
|
||||
// Delete all user data
|
||||
err = DeleteUserDataFull(db, oauthStore, testUser.DID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserDataFull failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify user was deleted
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query users: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
|
||||
// Verify manifests were cascade deleted
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query manifests: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected manifests to be cascade deleted")
|
||||
}
|
||||
|
||||
// Verify tags were cascade deleted
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM tags WHERE did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query tags: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected tags to be cascade deleted")
|
||||
}
|
||||
|
||||
// Verify hold membership data was deleted
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query crew approvals: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected crew approvals to be deleted")
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query crew members: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected crew members to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserDataFull_DoesNotAffectOtherUsers(t *testing.T) {
|
||||
db, err := InitDB(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create two users
|
||||
user1 := &User{
|
||||
DID: "did:plc:user1",
|
||||
Handle: "user1.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
user2 := &User{
|
||||
DID: "did:plc:user2",
|
||||
Handle: "user2.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := UpsertUser(db, user1); err != nil {
|
||||
t.Fatalf("Failed to create user1: %v", err)
|
||||
}
|
||||
if err := UpsertUser(db, user2); err != nil {
|
||||
t.Fatalf("Failed to create user2: %v", err)
|
||||
}
|
||||
|
||||
// Create manifests for both users
|
||||
for _, user := range []*User{user1, user2} {
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, user.DID, "myapp", "sha256:"+user.DID, "did:web:hold.example.com", 2,
|
||||
"application/vnd.oci.image.manifest.v1+json", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manifest for %s: %v", user.Handle, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create hold membership data for both users
|
||||
for i, user := range []*User{user1, user2} {
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, "did:web:hold.example.com", user.DID, fmt.Sprintf("member%d", i+1), `["blob:read"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create crew member for %s: %v", user.Handle, err)
|
||||
}
|
||||
}
|
||||
|
||||
oauthStore := NewOAuthStore(db)
|
||||
|
||||
// Delete only user1's data
|
||||
err = DeleteUserDataFull(db, oauthStore, user1.DID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserDataFull failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify user1 was deleted
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user1.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query users: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected user1 to be deleted")
|
||||
}
|
||||
|
||||
// Verify user2 still exists
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user2.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query users: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Error("Expected user2 to still exist")
|
||||
}
|
||||
|
||||
// Verify user2's manifests still exist
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", user2.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query manifests: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Error("Expected user2's manifest to still exist")
|
||||
}
|
||||
|
||||
// Verify user2's crew membership still exists
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", user2.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query crew members: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Error("Expected user2's crew membership to still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserDataFull_HandlesNonExistentUser(t *testing.T) {
|
||||
db, err := InitDB(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
oauthStore := NewOAuthStore(db)
|
||||
|
||||
// Try to delete non-existent user - should not error
|
||||
err = DeleteUserDataFull(db, oauthStore, "did:plc:nonexistent")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for non-existent user, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserDataFull_WithNilOAuthStore(t *testing.T) {
|
||||
db, err := InitDB(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
testUser := &User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := UpsertUser(db, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Delete with nil OAuth store - should still work
|
||||
err = DeleteUserDataFull(db, nil, testUser.DID)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error with nil OAuth store, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify user was deleted
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query users: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserDataFull_DeletesDenials(t *testing.T) {
|
||||
db, err := InitDB(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
testUser := &User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := UpsertUser(db, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create denial record
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO hold_crew_denials (hold_did, user_did, denial_count, next_retry_at, last_denied_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, "did:web:hold.example.com", testUser.DID, 1, time.Now().Add(24*time.Hour), time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create crew denial: %v", err)
|
||||
}
|
||||
|
||||
oauthStore := NewOAuthStore(db)
|
||||
|
||||
err = DeleteUserDataFull(db, oauthStore, testUser.DID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserDataFull failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify denial was deleted
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_denials WHERE user_did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query crew denials: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected crew denials to be deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"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"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// DeleteAccountHandler handles GDPR account deletion requests
|
||||
type DeleteAccountHandler struct {
|
||||
DB *sql.DB
|
||||
OAuthStore *db.OAuthStore
|
||||
Refresher *oauth.Refresher
|
||||
}
|
||||
|
||||
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 := atproto.ResolveHoldURL(holdDID)
|
||||
endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
|
||||
|
||||
result := HoldDeleteResult{
|
||||
HoldDID: holdDID,
|
||||
Relationship: relationship,
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestDeleteAccountHandler_Unauthorized(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
handler := &DeleteAccountHandler{
|
||||
DB: database,
|
||||
OAuthStore: nil,
|
||||
Refresher: nil,
|
||||
}
|
||||
|
||||
reqBody := DeleteAccountRequest{
|
||||
DeletePDSRecords: false,
|
||||
Confirmation: "DELETE test.bsky.social",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandler_MissingConfirmation(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
// Create test user
|
||||
testUser := &db.User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := db.UpsertUser(database, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
handler := &DeleteAccountHandler{
|
||||
DB: database,
|
||||
OAuthStore: nil,
|
||||
Refresher: nil,
|
||||
}
|
||||
|
||||
// Request without confirmation
|
||||
reqBody := DeleteAccountRequest{
|
||||
DeletePDSRecords: false,
|
||||
Confirmation: "",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = middleware.WithUser(req, testUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandler_WrongConfirmation(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
testUser := &db.User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := db.UpsertUser(database, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
handler := &DeleteAccountHandler{
|
||||
DB: database,
|
||||
OAuthStore: nil,
|
||||
Refresher: nil,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation string
|
||||
}{
|
||||
{"just DELETE", "DELETE"},
|
||||
{"wrong handle", "DELETE wrong.handle"},
|
||||
{"lowercase", "delete test.bsky.social"},
|
||||
{"extra spaces", "DELETE test.bsky.social"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reqBody := DeleteAccountRequest{
|
||||
DeletePDSRecords: false,
|
||||
Confirmation: tt.confirmation,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = middleware.WithUser(req, testUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d for confirmation %q, got %d", http.StatusBadRequest, tt.confirmation, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandler_SuccessfulDeletion(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
// Create test user with some data
|
||||
testUser := &db.User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := db.UpsertUser(database, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create some manifests for the user
|
||||
_, err := database.Exec(`
|
||||
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
|
||||
"application/vnd.oci.image.manifest.v1+json", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manifest: %v", err)
|
||||
}
|
||||
|
||||
// Create OAuth store for testing
|
||||
oauthStore := db.NewOAuthStore(database)
|
||||
|
||||
handler := &DeleteAccountHandler{
|
||||
DB: database,
|
||||
OAuthStore: oauthStore,
|
||||
Refresher: nil, // No remote operations in this test
|
||||
}
|
||||
|
||||
reqBody := DeleteAccountRequest{
|
||||
DeletePDSRecords: false,
|
||||
Confirmation: "DELETE test.bsky.social",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = middleware.WithUser(req, testUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var response DeleteAccountResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Expected success=true")
|
||||
}
|
||||
if !response.AppViewDeleted {
|
||||
t.Error("Expected appview_deleted=true")
|
||||
}
|
||||
|
||||
// Verify user was actually deleted
|
||||
var count int
|
||||
err = database.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query user: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected user to be deleted from database")
|
||||
}
|
||||
|
||||
// Verify manifests were cascade deleted
|
||||
err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query manifests: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected manifests to be cascade deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandler_InvalidJSON(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
testUser := &db.User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := db.UpsertUser(database, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
handler := &DeleteAccountHandler{
|
||||
DB: database,
|
||||
OAuthStore: nil,
|
||||
Refresher: nil,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader([]byte("not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = middleware.WithUser(req, testUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandler_DeletesHoldMembershipData(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
testUser := &db.User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "test.bsky.social",
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := db.UpsertUser(database, testUser); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create hold membership data (these tables don't cascade)
|
||||
_, err := database.Exec(`
|
||||
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create crew approval: %v", err)
|
||||
}
|
||||
|
||||
_, err = database.Exec(`
|
||||
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create crew member: %v", err)
|
||||
}
|
||||
|
||||
oauthStore := db.NewOAuthStore(database)
|
||||
|
||||
handler := &DeleteAccountHandler{
|
||||
DB: database,
|
||||
OAuthStore: oauthStore,
|
||||
Refresher: nil,
|
||||
}
|
||||
|
||||
reqBody := DeleteAccountRequest{
|
||||
DeletePDSRecords: false,
|
||||
Confirmation: "DELETE test.bsky.social",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = middleware.WithUser(req, testUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
// Verify hold membership data was deleted
|
||||
var count int
|
||||
err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query crew approvals: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected crew approvals to be deleted")
|
||||
}
|
||||
|
||||
err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query crew members: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("Expected crew members to be deleted")
|
||||
}
|
||||
}
|
||||
@@ -103,3 +103,10 @@ func GetUser(r *http.Request) *db.User {
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// WithUser returns a new request with the user set in the context.
|
||||
// This is primarily useful for testing.
|
||||
func WithUser(r *http.Request, user *db.User) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
@@ -246,6 +246,13 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
DB: deps.Database,
|
||||
Refresher: deps.Refresher,
|
||||
}).ServeHTTP)
|
||||
|
||||
// GDPR account deletion
|
||||
r.Delete("/api/account", (&uihandlers.DeleteAccountHandler{
|
||||
DB: deps.Database,
|
||||
OAuthStore: deps.OAuthStore,
|
||||
Refresher: deps.Refresher,
|
||||
}).ServeHTTP)
|
||||
})
|
||||
|
||||
// Logout endpoint (supports both GET and POST)
|
||||
|
||||
@@ -194,6 +194,32 @@
|
||||
</small>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone Section -->
|
||||
<section class="settings-section danger-zone">
|
||||
<h2><i data-lucide="alert-triangle"></i> Danger Zone</h2>
|
||||
|
||||
<div class="danger-card">
|
||||
<h3>Delete Account</h3>
|
||||
<p>Permanently delete your ATCR account and all associated data. This action cannot be undone.</p>
|
||||
|
||||
<div class="delete-options">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="delete-pds-records">
|
||||
<span>Also delete all <code>io.atcr.*</code> records from my ATProto PDS</span>
|
||||
</label>
|
||||
<small class="option-help">
|
||||
This will remove manifests, tags, stars, and profile data from your Bluesky account.
|
||||
Your PDS data is always under your control, so this is optional.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="button" id="delete-account-btn" class="btn-danger-large">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -331,6 +357,171 @@
|
||||
// Refresh devices every 30 seconds (to show new authorizations)
|
||||
setInterval(loadDevices, 30000);
|
||||
})();
|
||||
|
||||
// Account Deletion JavaScript
|
||||
(function() {
|
||||
const deleteBtn = document.getElementById('delete-account-btn');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
showDeleteConfirmationModal();
|
||||
});
|
||||
|
||||
function showDeleteConfirmationModal() {
|
||||
// Create modal backdrop
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'delete-modal-backdrop';
|
||||
modal.innerHTML = `
|
||||
<div class="delete-modal">
|
||||
<h2><i data-lucide="alert-triangle"></i> Delete Account</h2>
|
||||
<p class="warning-text">
|
||||
This action <strong>cannot be undone</strong>. This will permanently delete:
|
||||
</p>
|
||||
<ul class="delete-list">
|
||||
<li>Your ATCR account and all settings</li>
|
||||
<li>All authorized devices</li>
|
||||
<li>Your data from all holds you're a member of</li>
|
||||
${document.getElementById('delete-pds-records').checked ?
|
||||
'<li>All io.atcr.* records from your ATProto PDS</li>' : ''}
|
||||
</ul>
|
||||
<p class="confirm-text">Type <strong>DELETE {{ .Profile.Handle }}</strong> to confirm:</p>
|
||||
<input type="text" id="confirm-delete-input" class="confirm-input" placeholder="DELETE {{ .Profile.Handle }}" autocomplete="off">
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-cancel" id="cancel-delete">Cancel</button>
|
||||
<button type="button" class="btn-confirm-delete" id="confirm-delete" disabled>
|
||||
<i data-lucide="trash-2"></i>
|
||||
Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Reinitialize Lucide icons for the modal
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Focus the input
|
||||
const confirmInput = document.getElementById('confirm-delete-input');
|
||||
const confirmBtn = document.getElementById('confirm-delete');
|
||||
const cancelBtn = document.getElementById('cancel-delete');
|
||||
|
||||
setTimeout(() => confirmInput.focus(), 100);
|
||||
|
||||
// Expected confirmation string
|
||||
const expectedConfirmation = 'DELETE {{ .Profile.Handle }}';
|
||||
|
||||
// Enable button only when full confirmation is typed
|
||||
confirmInput.addEventListener('input', function() {
|
||||
confirmBtn.disabled = this.value !== expectedConfirmation;
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
confirmInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && this.value === expectedConfirmation) {
|
||||
performAccountDeletion();
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
cancelBtn.addEventListener('click', function() {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm delete
|
||||
confirmBtn.addEventListener('click', performAccountDeletion);
|
||||
|
||||
async function performAccountDeletion() {
|
||||
const deletePDS = document.getElementById('delete-pds-records').checked;
|
||||
|
||||
// Show loading state
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> Deleting...';
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
cancelBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
delete_pds_records: deletePDS,
|
||||
confirmation: expectedConfirmation
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// Show success and redirect
|
||||
modal.querySelector('.delete-modal').innerHTML = `
|
||||
<h2><i data-lucide="check-circle"></i> Account Deleted</h2>
|
||||
<p>Your account has been successfully deleted.</p>
|
||||
<p>Redirecting to home page...</p>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.href = '/?deleted=true';
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error
|
||||
const errors = result.errors || ['An unknown error occurred'];
|
||||
modal.querySelector('.delete-modal').innerHTML = `
|
||||
<h2><i data-lucide="x-circle"></i> Deletion Failed</h2>
|
||||
<p>There were errors during account deletion:</p>
|
||||
<ul class="error-list">
|
||||
${errors.map(e => '<li>' + escapeHtml(e) + '</li>').join('')}
|
||||
</ul>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-cancel" onclick="this.closest('.delete-modal-backdrop').remove()">Close</button>
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete account error:', err);
|
||||
modal.querySelector('.delete-modal').innerHTML = `
|
||||
<h2><i data-lucide="x-circle"></i> Error</h2>
|
||||
<p>Failed to delete account: ${escapeHtml(err.message)}</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-cancel" onclick="this.closest('.delete-modal-backdrop').remove()">Close</button>
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -658,6 +849,193 @@
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Danger Zone Styles */
|
||||
.danger-zone {
|
||||
margin-top: 3rem;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
background: rgba(220, 53, 69, 0.03);
|
||||
}
|
||||
.danger-zone h2 {
|
||||
color: #dc3545;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.danger-zone h2 svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
.danger-card {
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.danger-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.delete-options {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-label span {
|
||||
flex: 1;
|
||||
}
|
||||
.option-help {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 1.5rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.btn-danger-large {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-danger-large:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.btn-danger-large svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Delete Account Modal */
|
||||
.delete-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
.delete-modal {
|
||||
background: var(--bg);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.delete-modal h2 {
|
||||
margin-top: 0;
|
||||
color: #dc3545;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.delete-modal h2 svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.delete-modal .warning-text {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.delete-modal .delete-list {
|
||||
margin: 1rem 0 1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.delete-modal .delete-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.delete-modal .confirm-text {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.delete-modal .confirm-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.delete-modal .confirm-input:focus {
|
||||
outline: none;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
.delete-modal .modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.delete-modal .btn-cancel {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--code-bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.delete-modal .btn-cancel:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.delete-modal .btn-cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete-modal .btn-confirm-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete-modal .btn-confirm-delete:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
.delete-modal .btn-confirm-delete:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete-modal .btn-confirm-delete svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.delete-modal .error-list {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #dc3545;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -687,3 +687,92 @@ func (c *Client) DID() string {
|
||||
func (c *Client) PDSEndpoint() string {
|
||||
return c.pdsEndpoint
|
||||
}
|
||||
|
||||
// ListRecordsWithCursor lists records in a collection with cursor-based pagination.
|
||||
// Returns records, next cursor (empty if no more), and error.
|
||||
func (c *Client) ListRecordsWithCursor(ctx context.Context, collection string, limit int, cursor string) ([]Record, string, error) {
|
||||
url := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=%d",
|
||||
c.pdsEndpoint, RepoListRecords, c.did, collection, limit)
|
||||
|
||||
if cursor != "" {
|
||||
url += "&cursor=" + cursor
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Records []Record `json:"records"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result.Records, result.Cursor, nil
|
||||
}
|
||||
|
||||
// DeleteAllRecordsInCollection deletes all records in a collection.
|
||||
// Returns the number of records deleted.
|
||||
// This is used for GDPR account deletion to remove all user records from a collection.
|
||||
func (c *Client) DeleteAllRecordsInCollection(ctx context.Context, collection string) (int, error) {
|
||||
deleted := 0
|
||||
cursor := ""
|
||||
|
||||
for {
|
||||
// List records with pagination
|
||||
records, nextCursor, err := c.ListRecordsWithCursor(ctx, collection, 100, cursor)
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
// Extract rkey from URI (at://{did}/{collection}/{rkey})
|
||||
rkey := extractRkeyFromURI(rec.URI)
|
||||
if rkey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
err := c.DeleteRecord(ctx, collection, rkey)
|
||||
if err != nil {
|
||||
// Log but continue with other records
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// extractRkeyFromURI extracts the rkey from an AT URI (at://{did}/{collection}/{rkey})
|
||||
func extractRkeyFromURI(uri string) string {
|
||||
// Format: at://did:plc:xxx/io.atcr.manifest/abc123
|
||||
parts := strings.Split(uri, "/")
|
||||
if len(parts) < 5 {
|
||||
return ""
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// UserDeleteResult contains the results of deleting a user's data from the hold
|
||||
type UserDeleteResult struct {
|
||||
CrewDeleted bool `json:"crew_deleted"`
|
||||
LayersDeleted int `json:"layers_deleted"`
|
||||
StatsDeleted int `json:"stats_deleted"`
|
||||
}
|
||||
|
||||
// DeleteUserData deletes all data for a user from the hold's PDS.
|
||||
// This removes:
|
||||
// - Crew record (if user is a crew member)
|
||||
// - Layer records (where userDid matches)
|
||||
// - Stats records (where ownerDid matches)
|
||||
//
|
||||
// NOTE: This does NOT delete the captain record if the user is the hold owner.
|
||||
// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
|
||||
func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDeleteResult, error) {
|
||||
result := &UserDeleteResult{}
|
||||
|
||||
slog.Info("Deleting user data from hold",
|
||||
"user_did", userDID,
|
||||
"hold_did", p.DID())
|
||||
|
||||
// 1. Delete crew record (if exists)
|
||||
crewDeleted, err := p.deleteCrewRecord(ctx, userDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete crew record",
|
||||
"user_did", userDID,
|
||||
"error", err)
|
||||
// Continue with other deletions
|
||||
}
|
||||
result.CrewDeleted = crewDeleted
|
||||
|
||||
// 2. Delete layer records
|
||||
layersDeleted, err := p.deleteLayerRecords(ctx, userDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete layer records",
|
||||
"user_did", userDID,
|
||||
"error", err)
|
||||
// Continue with other deletions
|
||||
}
|
||||
result.LayersDeleted = layersDeleted
|
||||
|
||||
// 3. Delete stats records
|
||||
statsDeleted, err := p.deleteStatsRecords(ctx, userDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete stats records",
|
||||
"user_did", userDID,
|
||||
"error", err)
|
||||
// Continue with other deletions
|
||||
}
|
||||
result.StatsDeleted = statsDeleted
|
||||
|
||||
slog.Info("User data deletion complete",
|
||||
"user_did", userDID,
|
||||
"hold_did", p.DID(),
|
||||
"crew_deleted", result.CrewDeleted,
|
||||
"layers_deleted", result.LayersDeleted,
|
||||
"stats_deleted", result.StatsDeleted)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// deleteCrewRecord removes a user's crew record from the hold
|
||||
func (p *HoldPDS) deleteCrewRecord(ctx context.Context, userDID string) (bool, error) {
|
||||
// Check if user has a crew record
|
||||
_, _, err := p.GetCrewMemberByDID(ctx, userDID)
|
||||
if err != nil {
|
||||
// No crew record found
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Delete the crew record
|
||||
err = p.RemoveCrewMemberByDID(ctx, userDID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to remove crew member: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Deleted crew record", "user_did", userDID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// deleteLayerRecords removes all layer records for a user
|
||||
func (p *HoldPDS) deleteLayerRecords(ctx context.Context, userDID string) (int, error) {
|
||||
if p.recordsIndex == nil {
|
||||
return 0, fmt.Errorf("records index not available")
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
cursor := ""
|
||||
batchSize := 100
|
||||
|
||||
for {
|
||||
// Get layer records for this user via the DID index
|
||||
records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor)
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("failed to list layer records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
// Delete from repo (MST)
|
||||
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.LayerCollection, rec.Rkey)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete layer record from repo",
|
||||
"rkey", rec.Rkey,
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete from index
|
||||
err = p.recordsIndex.DeleteRecord(atproto.LayerCollection, rec.Rkey)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete layer record from index",
|
||||
"rkey", rec.Rkey,
|
||||
"error", err)
|
||||
}
|
||||
|
||||
deleted++
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
slog.Debug("Deleted layer records", "user_did", userDID, "count", deleted)
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// deleteStatsRecords removes all stats records for a user
|
||||
func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int, error) {
|
||||
if p.recordsIndex == nil {
|
||||
return 0, fmt.Errorf("records index not available")
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
cursor := ""
|
||||
batchSize := 100
|
||||
|
||||
for {
|
||||
// Get stats records for this user via the DID index
|
||||
records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.StatsCollection, userDID, batchSize, cursor)
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("failed to list stats records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
// Delete from repo (MST)
|
||||
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.StatsCollection, rec.Rkey)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete stats record from repo",
|
||||
"rkey", rec.Rkey,
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete from index
|
||||
err = p.recordsIndex.DeleteRecord(atproto.StatsCollection, rec.Rkey)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to delete stats record from index",
|
||||
"rkey", rec.Rkey,
|
||||
"error", err)
|
||||
}
|
||||
|
||||
deleted++
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
slog.Debug("Deleted stats records", "user_did", userDID, "count", deleted)
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
+78
-1
@@ -195,8 +195,10 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAuth)
|
||||
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
|
||||
// GDPR data export endpoint (TODO: implement)
|
||||
// GDPR data export endpoint
|
||||
r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
|
||||
// GDPR data deletion endpoint
|
||||
r.Delete("/xrpc/io.atcr.hold.deleteUserData", h.HandleDeleteUserData)
|
||||
})
|
||||
|
||||
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
|
||||
@@ -1630,3 +1632,78 @@ func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
render.JSON(w, r, export)
|
||||
}
|
||||
|
||||
// HoldUserDeleteResponse represents the result of GDPR data deletion
|
||||
type HoldUserDeleteResponse struct {
|
||||
Success bool `json:"success"`
|
||||
CrewDeleted bool `json:"crew_deleted"`
|
||||
LayersDeleted int `json:"layers_deleted"`
|
||||
StatsDeleted int `json:"stats_deleted"`
|
||||
}
|
||||
|
||||
// HandleDeleteUserData handles GDPR data deletion requests for a specific user.
|
||||
// This endpoint deletes all records stored on this hold's PDS that reference
|
||||
// the authenticated user's DID.
|
||||
//
|
||||
// Deletes:
|
||||
// - io.atcr.hold.crew record for the DID (if exists, and user is NOT captain)
|
||||
// - io.atcr.hold.layer records where userDid matches
|
||||
// - io.atcr.hold.stats records where ownerDid matches
|
||||
//
|
||||
// NOTE: This does NOT delete the captain record if the user is the hold owner.
|
||||
// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
|
||||
//
|
||||
// Authentication: Requires valid service token from user's PDS
|
||||
func (h *XRPCHandler) HandleDeleteUserData(w http.ResponseWriter, r *http.Request) {
|
||||
// Get authenticated user from context
|
||||
user := getUserFromContext(r)
|
||||
if user == nil {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("GDPR data deletion requested",
|
||||
"requester_did", user.DID,
|
||||
"hold_did", h.pds.DID())
|
||||
|
||||
// Check if user is captain - if so, skip crew deletion but continue with layer/stats
|
||||
isCaptain := false
|
||||
_, captain, err := h.pds.GetCaptainRecord(r.Context())
|
||||
if err == nil && captain != nil && captain.Owner == user.DID {
|
||||
isCaptain = true
|
||||
slog.Info("User is captain of this hold, will not delete captain record",
|
||||
"user_did", user.DID,
|
||||
"hold_did", h.pds.DID())
|
||||
}
|
||||
|
||||
// Delete user data from hold
|
||||
result, err := h.pds.DeleteUserData(r.Context(), user.DID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to delete user data",
|
||||
"user_did", user.DID,
|
||||
"hold_did", h.pds.DID(),
|
||||
"error", err)
|
||||
http.Error(w, fmt.Sprintf("failed to delete user data: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If user is captain, they shouldn't have a crew record deleted (they're the owner)
|
||||
// The DeleteUserData function handles crew deletion, but we report it appropriately
|
||||
if isCaptain {
|
||||
result.CrewDeleted = false
|
||||
}
|
||||
|
||||
slog.Info("GDPR data deletion completed",
|
||||
"user_did", user.DID,
|
||||
"hold_did", h.pds.DID(),
|
||||
"crew_deleted", result.CrewDeleted,
|
||||
"layers_deleted", result.LayersDeleted,
|
||||
"stats_deleted", result.StatsDeleted)
|
||||
|
||||
render.JSON(w, r, HoldUserDeleteResponse{
|
||||
Success: true,
|
||||
CrewDeleted: result.CrewDeleted,
|
||||
LayersDeleted: result.LayersDeleted,
|
||||
StatsDeleted: result.StatsDeleted,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user