begin delete my account implementation

This commit is contained in:
Evan Jarrett
2026-01-08 23:17:38 -06:00
parent 51f6917444
commit 64cdb66957
10 changed files with 1799 additions and 1 deletions
+80
View File
@@ -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
}
+306
View File
@@ -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")
}
}
+344
View File
@@ -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
}
+318
View File
@@ -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")
}
}
+7
View File
@@ -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)
}
+7
View File
@@ -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)
+378
View File
@@ -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>
+89
View File
@@ -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]
}
+192
View File
@@ -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
View File
@@ -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,
})
}