feat: account deletion

This commit is contained in:
juancwu 2026-05-17 15:01:04 +00:00
commit 2db260f849
20 changed files with 785 additions and 29 deletions

View file

@ -64,7 +64,7 @@ func CSRFProtection(next http.Handler) http.HandlerFunc {
slog.Warn("csrf validation failed",
"path", r.URL.Path,
"method", r.Method,
"ip", getClientIP(r),
"ip", GetClientIP(r),
)
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return

View file

@ -64,7 +64,7 @@ func RequestLogging(next http.Handler) http.HandlerFunc {
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", duration.Milliseconds(),
"remote_addr", getClientIP(r),
"remote_addr", GetClientIP(r),
)
})
}

View file

@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strings"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
// pendingDeletionAllowedPaths is the small set of endpoints a user marked
// for deletion is still allowed to reach. Everything else is redirected (GET)
// or rejected (mutation) so no further data can be created or changed while
// the deletion job is in flight.
var pendingDeletionAllowedPaths = map[string]struct{}{
"/account-pending-deletion": {},
"/auth/logout": {},
"/healthz": {},
"/privacy": {},
"/terms": {},
"/forbidden": {},
}
// BlockPendingDeletion locks out users whose accounts are pending deletion.
// Runs after AuthMiddleware so it can read the user from context. For
// unauthenticated requests and static assets it is a no-op.
func BlockPendingDeletion(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
if user == nil || !user.IsPendingDeletion() {
next.ServeHTTP(w, r)
return
}
// Always permit static assets so the pending page can render.
if strings.HasPrefix(r.URL.Path, "/assets/") {
next.ServeHTTP(w, r)
return
}
if _, ok := pendingDeletionAllowedPaths[r.URL.Path]; ok {
next.ServeHTTP(w, r)
return
}
// Mutations are hard-rejected so the client gets a clear signal.
// Safe methods are redirected to the pending-deletion landing page.
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusForbidden)
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
return
}
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
}
}

View file

@ -101,7 +101,7 @@ func (rl *RateLimiter) cleanup() {
func (rl *RateLimiter) Middleware() Middleware {
return func(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
ip := GetClientIP(r)
if !rl.Allow(ip) {
slog.Warn("rate limit exceeded",
"ip", ip,
@ -115,8 +115,8 @@ func (rl *RateLimiter) Middleware() Middleware {
}
}
// getClientIP extracts real client IP from request
func getClientIP(r *http.Request) string {
// GetClientIP extracts real client IP from request
func GetClientIP(r *http.Request) string {
// Check X-Forwarded-For header (proxy/load balancer)
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {