CI / Env docs check (push) Successful in 25s
CI / Docker compose check (push) Successful in 24s
CI / Frontend deps check (push) Successful in 30s
CI / Frontend major deps (push) Successful in 33s
CI / Nginx config check (push) Successful in 59s
CI / Go build (push) Successful in 1m15s
CI / Secrets scan (push) Successful in 1m16s
CI / Knip (push) Successful in 56s
CI / Frontend a11y check (push) Successful in 55s
CI / Frontend build (push) Successful in 1m27s
CI / go mod tidy (push) Successful in 17s
CI / Go vulnerabilities (push) Successful in 2m17s
CI / Go vet (prod) (push) Successful in 2m46s
CI / Go vet (dev) (push) Successful in 2m50s
CI / Staticcheck (prod) (push) Successful in 2m55s
CI / Staticcheck (dev) (push) Successful in 3m13s
CI / Frontend QC (audit) (push) Successful in 41s
CI / golangci-lint (push) Failing after 3m37s
CI / Frontend QC (typecheck) (push) Successful in 1m30s
CI / Frontend QC (lint) (push) Successful in 2m4s
CI / Security scan (prod) (push) Successful in 4m43s
CI / Security scan (dev) (push) Successful in 4m44s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Svelte strict check (push) Successful in 33s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
179 lines
5.2 KiB
Go
179 lines
5.2 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/handlers/payments"
|
|
"crussell/internal/dav"
|
|
"crussell/internal/s3"
|
|
"crussell/mw"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// DELETE /api/user/account
|
|
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var accountRole string
|
|
var profilePicURL sql.NullString
|
|
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
|
|
Scan(&accountRole, &profilePicURL)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "user not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch user for deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// --- External system scrubbing (BEFORE SQL anonymize) ---
|
|
|
|
// Delete profile picture from S3/R2
|
|
if profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil {
|
|
// #nosec G118 — intentional background goroutine for async profile pic cleanup
|
|
go func(picURL string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in S3 profile picture deletion: %v", r)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
bucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
|
|
if bucket == "" {
|
|
bucket = "crussell-profile-pics"
|
|
}
|
|
// profiles/{userID}.jpg — matches UploadProfilePictureHandler key format
|
|
key := fmt.Sprintf("profiles/%s.jpg", userID)
|
|
if err := s3.Client.Delete(ctx, bucket, key); err != nil {
|
|
log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err)
|
|
}
|
|
}(profilePicURL.String)
|
|
}
|
|
|
|
if payments.SquareClient != nil {
|
|
// #nosec G118 — intentional background goroutine for async account deletion
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in Square saved card cleanup: %v", r)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
rows, err := db.Conn.Query(ctx,
|
|
`SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var cardID string
|
|
if err := rows.Scan(&cardID); err != nil {
|
|
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
|
|
continue
|
|
}
|
|
if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil {
|
|
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", cardID, userID, err)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
|
|
return
|
|
}
|
|
}()
|
|
}
|
|
|
|
// --- SQL-level anonymization/deletion ---
|
|
|
|
if accountRole == "guest" {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for guest user deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete guest user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction for guest user deletion: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for user anonymization: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to anonymize user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction for user anonymization: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Create 'user_anonymized' notification for admin audit trail
|
|
}
|
|
|
|
// Delete CardDAV contact (non-blocking, best-effort)
|
|
if dav.Service != nil {
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic recovered in CardDAV contact deletion: %v", r)
|
|
}
|
|
}()
|
|
uri := fmt.Sprintf("%s.vcf", userID)
|
|
if err := dav.Service.DeleteContact(1, uri); err != nil {
|
|
log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|