A community based topic aggregation platform built on atproto
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}