Files
Crussell/backend/handlers/user/account.go
T
popertots a8d54f1e2a Fix review findings: aggregated-refund/saved-card/legacy-refund idempotency keys, structured Square error classification, CSP for Square SDK
Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
  set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
  a new row completed against an old smaller refund; >45-char chargeIDs use a
  hashed prefix instead of verbatim truncation (which would collide charges on
  Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
  per-attempt UUID preferred (distinct identical charges no longer collapse),
  deterministic booking+type+amount+card fallback for no-key retry safety.
  PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
  the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
  so a lost-response retry reuses the key and never double-refunds. Wired into
  resumeManualPendingRefund and the sweep's manual-retry loop.

Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
  when present; message-substring matching only for non-structured errors
  (dev mock, client-side status errors). Fixes fragile string-matching driving
  sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
  (was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
  inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
  time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
  payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
  "till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
  Payments SDK + card iframe can tokenize behind the proxy.

Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
2026-08-22 00:34:49 +01:00

239 lines
8.4 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/internal/square"
"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)
}
// Snapshot the Square card IDs AND customer IDs synchronously BEFORE the
// SQL anonymization below NULLs square_card_id/square_customer_id, so the
// background cleanup still has the external Square references it needs
// (previously the goroutine read the rows itself, racing the anonymize
// step which wiped them mid-flight). Distinct non-null customer IDs only:
// a user's saved cards share one provisioned Square customer, so
// DeleteCustomer runs once per customer. NULL customer IDs (users with
// no saved cards) are skipped.
var cardIDs []string
customerSeen := map[string]bool{}
var customerIDs []string
// Capture the client synchronously so the async cleanup goroutine never
// reads the global payments.SquareClient (which tests swap per-account).
sqClient := payments.SquareClient
if sqClient != nil {
rows, err := db.Conn.Query(r.Context(),
`SELECT square_card_id, square_customer_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)
} else {
for rows.Next() {
var cardID, customerID sql.NullString
if err := rows.Scan(&cardID, &customerID); err != nil {
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
continue
}
if cardID.Valid && cardID.String != "" {
cardIDs = append(cardIDs, cardID.String)
}
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
customerSeen[customerID.String] = true
customerIDs = append(customerIDs, customerID.String)
}
}
if err := rows.Err(); err != nil {
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
}
rows.Close()
}
}
// --- 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
}
// The local erasure committed: drop the user's cached Square customer id so
// the erased identity cannot resurface from the process-local cache on a
// later save-card flow (the Square customer is deleted below and the DB
// columns are NULLed, but the cache is never touched by either).
payments.InvalidateSquareCustomerCache(userID)
// external Square cleanup fires only after local anonymization/deletion
// commits, so a failed local tx leaves external state intact for retry.
if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) {
// #nosec G118 — intentional background goroutine for async account deletion
go func(client square.SquareClient, cards, customers []string) {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in Square cleanup: %v", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, cardID := range cards {
if err := client.DeleteCardOnFile(ctx, cardID); err != nil {
// TokenPrefix redacts the ccof: card token — the full ID must
// never reach logs.
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err)
}
}
for _, customerID := range customers {
// Square customers are provisioned per-user from a deterministic
// email-derived key, but the UNIQUE(email) index excludes guest
// accounts — so a guest and a registered user sharing an email can
// end up on the SAME Square customer profile (Square dedups within
// its idempotency window). Deleting it would break the other
// account's saved-card charges, so skip the deletion when any OTHER
// non-deleted saved card still references the customer.
var stillReferenced bool
if err := db.Conn.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL)
`, customerID, userID).Scan(&stillReferenced); err != nil {
log.Printf("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
continue
}
if stillReferenced {
// PII-redacted customer id — the full id never reaches logs.
log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID))
continue
}
if err := client.DeleteCustomer(ctx, customerID); err != nil {
log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err)
}
}
}(sqClient, cardIDs, customerIDs)
}
// 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)
}