This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/gdpr.go
Jeffrey Smith d16bb93177 Changeset 0.34.0 (#208)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-20 00:02:37 +00:00

131 lines
3.7 KiB
Go

package handlers
// gdpr.go — v0.34.0 CS2
//
// GDPR delete endpoint: DELETE /api/v1/me
// Allows a user to delete their own account and all associated data.
import (
"crypto/sha256"
"fmt"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"chat-switchboard/models"
"chat-switchboard/store"
)
// GDPRHandler serves account deletion endpoints.
type GDPRHandler struct {
stores store.Stores
}
// NewGDPRHandler creates a new handler for GDPR operations.
func NewGDPRHandler(s store.Stores) *GDPRHandler {
return &GDPRHandler{stores: s}
}
// deleteAccountRequest is the body for DELETE /api/v1/me.
type deleteAccountRequest struct {
Confirm string `json:"confirm"`
Password string `json:"password"`
}
// DeleteMyAccount permanently deletes the requesting user's account.
// DELETE /api/v1/me
func (h *GDPRHandler) DeleteMyAccount(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
var req deleteAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Require explicit confirmation
if req.Confirm != "DELETE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "confirm field must be \"DELETE\""})
return
}
// Fetch user
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
return
}
// Prevent deleting last admin
if user.Role == "admin" {
adminCount, err := h.stores.Export.CountActiveAdmins(ctx)
if err != nil {
slog.Error("gdpr: count admins", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin count"})
return
}
if adminCount <= 1 {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete the last admin account"})
return
}
}
// Generate anonymized hash from user ID
hash := sha256.Sum256([]byte(userID))
anonHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars
// Step 1: Soft-delete/hard-delete user data
counts, err := h.stores.Export.SoftDeleteUserData(ctx, userID)
if err != nil {
slog.Error("gdpr: delete user data", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user data"})
return
}
// Step 2: Revoke tokens
if err := h.stores.Export.DeleteUserTokens(ctx, userID); err != nil {
slog.Error("gdpr: delete tokens", "error", err, "user_id", userID)
// Continue — tokens will expire naturally
}
// Step 3: Delete personal provider configs
if deleted, err := h.stores.Providers.DeletePersonalByOwner(ctx, userID); err != nil {
slog.Error("gdpr: delete provider configs", "error", err)
} else {
counts["provider_configs"] = deleted
}
// Step 4: Anonymize user record
if err := h.stores.Export.AnonymizeUser(ctx, userID, anonHash); err != nil {
slog.Error("gdpr: anonymize user", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to anonymize user"})
return
}
// Audit log (before the user record is fully anonymized)
anonUsername := "deleted-user-" + anonHash
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.gdpr_delete",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"anonymized_as": anonUsername, "deleted_counts": counts},
})
slog.Info("gdpr: account deleted", "user_id", userID, "anonymized_as", anonUsername)
c.JSON(http.StatusOK, gin.H{
"message": "account deleted",
"anonymized_as": anonUsername,
})
}