A community based topic aggregation platform built on atproto
at main 151 lines 5.0 kB view raw
1package user 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "log/slog" 8 "net/http" 9 10 "Coves/internal/api/middleware" 11 "Coves/internal/core/users" 12) 13 14// DeleteHandler handles account deletion requests 15type DeleteHandler struct { 16 userService users.UserService 17} 18 19// NewDeleteHandler creates a new delete handler 20func NewDeleteHandler(userService users.UserService) *DeleteHandler { 21 return &DeleteHandler{ 22 userService: userService, 23 } 24} 25 26// DeleteAccountResponse represents the response for account deletion 27type DeleteAccountResponse struct { 28 Success bool `json:"success"` 29 Message string `json:"message,omitempty"` 30} 31 32// HandleDeleteAccount handles POST /xrpc/social.coves.actor.deleteAccount 33// Deletes the authenticated user's account from the Coves AppView. 34// This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 35// The user's identity remains intact for use with other atProto apps. 36// 37// Security: 38// - Requires OAuth authentication 39// - Users can ONLY delete their own account (DID from auth context) 40// - No request body required - DID is derived from authenticated session 41func (h *DeleteHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) { 42 // 1. Check HTTP method 43 if r.Method != http.MethodPost { 44 writeJSONError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 45 return 46 } 47 48 // 2. Extract authenticated user DID from request context (injected by auth middleware) 49 // SECURITY: This ensures users can ONLY delete their own account 50 userDID := middleware.GetUserDID(r) 51 if userDID == "" { 52 writeJSONError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 53 return 54 } 55 56 // 3. Delete the account 57 // The service handles validation, logging, and atomic deletion 58 err := h.userService.DeleteAccount(r.Context(), userDID) 59 if err != nil { 60 handleServiceError(w, err, userDID, "account deletion") 61 return 62 } 63 64 // 4. Return success response 65 // Marshal JSON before writing headers to catch encoding errors early 66 response := DeleteAccountResponse{ 67 Success: true, 68 Message: "Account deleted successfully. Your atProto identity remains intact on your PDS.", 69 } 70 71 responseBytes, err := json.Marshal(response) 72 if err != nil { 73 slog.Error("failed to marshal delete account response", 74 slog.String("did", userDID), 75 slog.String("error", err.Error()), 76 ) 77 writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 78 return 79 } 80 81 w.Header().Set("Content-Type", "application/json") 82 w.WriteHeader(http.StatusOK) 83 if _, writeErr := w.Write(responseBytes); writeErr != nil { 84 slog.Warn("failed to write delete account response", 85 slog.String("did", userDID), 86 slog.String("error", writeErr.Error()), 87 ) 88 } 89} 90 91// writeJSONError writes a JSON error response 92// Marshals JSON before writing headers to catch encoding errors 93func writeJSONError(w http.ResponseWriter, statusCode int, errorType, message string) { 94 responseBytes, err := json.Marshal(map[string]interface{}{ 95 "error": errorType, 96 "message": message, 97 }) 98 if err != nil { 99 // Fallback to plain text if JSON encoding fails (should never happen with simple strings) 100 slog.Error("failed to marshal error response", slog.String("error", err.Error())) 101 w.Header().Set("Content-Type", "text/plain") 102 w.WriteHeader(statusCode) 103 _, _ = w.Write([]byte(message)) 104 return 105 } 106 107 w.Header().Set("Content-Type", "application/json") 108 w.WriteHeader(statusCode) 109 if _, writeErr := w.Write(responseBytes); writeErr != nil { 110 slog.Warn("failed to write error response", slog.String("error", writeErr.Error())) 111 } 112} 113 114// handleServiceError maps service errors to HTTP responses. 115// operation is a human-readable label for log messages (e.g. "account deletion", "get profile"). 116func handleServiceError(w http.ResponseWriter, err error, userDID, operation string) { 117 // Check for specific error types 118 switch { 119 case errors.Is(err, users.ErrUserNotFound): 120 writeJSONError(w, http.StatusNotFound, "AccountNotFound", "Account not found") 121 122 case errors.Is(err, context.DeadlineExceeded): 123 slog.Error(operation+" timed out", 124 slog.String("did", userDID), 125 slog.String("error", err.Error()), 126 ) 127 writeJSONError(w, http.StatusGatewayTimeout, "Timeout", "Request timed out") 128 129 case errors.Is(err, context.Canceled): 130 slog.Info(operation+" canceled", 131 slog.String("did", userDID), 132 slog.String("error", err.Error()), 133 ) 134 writeJSONError(w, http.StatusBadRequest, "RequestCanceled", "Request was canceled") 135 136 default: 137 // Check for InvalidDIDError 138 var invalidDIDErr *users.InvalidDIDError 139 if errors.As(err, &invalidDIDErr) { 140 writeJSONError(w, http.StatusBadRequest, "InvalidDID", invalidDIDErr.Error()) 141 return 142 } 143 144 // Internal server error - don't leak details 145 slog.Error(operation+" failed", 146 slog.String("did", userDID), 147 slog.String("error", err.Error()), 148 ) 149 writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 150 } 151}