Add delete account endpoint and file encryption at rest

Delete Account (Plan #2):
- DELETE /api/auth/account/ with password or "DELETE" confirmation
- Cascade delete across 15+ tables in correct FK order
- Auth provider detection (email/apple/google) for /auth/me/
- File cleanup after account deletion
- Handler + repository tests (12 tests)

Encryption at Rest (Plan #3):
- AES-256-GCM envelope encryption (per-file DEK wrapped by KEK)
- Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile()
- MediaHandler serves decrypted files via c.Blob()
- TaskService email image loading uses ReadFile()
- cmd/migrate-encrypt CLI tool with --dry-run for existing files
- Encryption service + storage service tests (18 tests)
This commit is contained in:
Trey T
2026-03-26 10:41:01 -05:00
parent 72866e935e
commit 4abc57535e
22 changed files with 1675 additions and 82 deletions

View File

@@ -1,6 +1,8 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"strings"
@@ -60,15 +62,18 @@ func (h *MediaHandler) ServeDocument(c echo.Context) error {
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(doc.FileURL)
if filePath == "" {
// Serve the file (supports encrypted files transparently)
data, mimeType, err := h.storageSvc.ReadFile(doc.FileURL)
if err != nil {
return apperrors.NotFound("error.file_not_found")
}
// Set caching headers (private, 1 hour)
// Set caching and disposition headers
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
if doc.FileName != "" {
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+doc.FileName+"\"")
}
return c.Blob(http.StatusOK, mimeType, data)
}
// ServeDocumentImage serves a document image with access control
@@ -102,14 +107,15 @@ func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
// Serve the file (supports encrypted files transparently)
data, mimeType, err := h.storageSvc.ReadFile(img.ImageURL)
if err != nil {
return apperrors.NotFound("error.file_not_found")
}
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
return c.Blob(http.StatusOK, mimeType, data)
}
// ServeCompletionImage serves a task completion image with access control
@@ -149,14 +155,15 @@ func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
// Serve the file (supports encrypted files transparently)
data, mimeType, err := h.storageSvc.ReadFile(img.ImageURL)
if err != nil {
return apperrors.NotFound("error.file_not_found")
}
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
return c.Blob(http.StatusOK, mimeType, data)
}
// resolveFilePath converts a stored URL to an actual file path.