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

@@ -18,8 +18,9 @@ import (
// StorageService handles file uploads to local filesystem
type StorageService struct {
cfg *config.StorageConfig
allowedTypes map[string]struct{} // P-12: Parsed once at init for O(1) lookups
cfg *config.StorageConfig
allowedTypes map[string]struct{} // P-12: Parsed once at init for O(1) lookups
encryptionSvc *EncryptionService
}
// UploadResult contains information about an uploaded file
@@ -124,28 +125,39 @@ func (s *StorageService) Upload(file *multipart.FileHeader, category string) (*U
subdir = "completions"
}
// If encryption is enabled, append .enc suffix to the stored filename
storedFilename := newFilename
if s.encryptionSvc.IsEnabled() {
storedFilename = newFilename + ".enc"
}
// S-18: Sanitize path to prevent traversal attacks
destPath, err := SafeResolvePath(s.cfg.UploadDir, filepath.Join(subdir, newFilename))
destPath, err := SafeResolvePath(s.cfg.UploadDir, filepath.Join(subdir, storedFilename))
if err != nil {
return nil, fmt.Errorf("invalid upload path: %w", err)
}
// Create destination file
dst, err := os.Create(destPath)
// Read all file content into memory for potential encryption
fileData, err := io.ReadAll(src)
if err != nil {
return nil, fmt.Errorf("failed to create destination file: %w", err)
return nil, fmt.Errorf("failed to read file content: %w", err)
}
defer dst.Close()
// Copy file content
written, err := io.Copy(dst, src)
if err != nil {
// Clean up on error
os.Remove(destPath)
// Encrypt if encryption is enabled
if s.encryptionSvc.IsEnabled() {
fileData, err = s.encryptionSvc.Encrypt(fileData)
if err != nil {
return nil, fmt.Errorf("failed to encrypt file: %w", err)
}
}
// Write file content to disk
if err := os.WriteFile(destPath, fileData, 0644); err != nil {
return nil, fmt.Errorf("failed to save file: %w", err)
}
written := int64(len(fileData))
// Generate URL
// Generate URL (always uses the original filename without .enc suffix for the public URL)
url := fmt.Sprintf("%s/%s/%s", s.cfg.BaseURL, subdir, newFilename)
log.Info().
@@ -163,7 +175,61 @@ func (s *StorageService) Upload(file *multipart.FileHeader, category string) (*U
}, nil
}
// Delete removes a file from storage
// ReadFile reads and optionally decrypts a stored file. It returns the plaintext
// bytes and the detected MIME type. If the file is stored with an .enc suffix,
// it is decrypted automatically.
func (s *StorageService) ReadFile(storedURL string) ([]byte, string, error) {
if storedURL == "" {
return nil, "", fmt.Errorf("empty file URL")
}
// Strip base URL prefix to get relative path
relativePath := strings.TrimPrefix(storedURL, s.cfg.BaseURL)
relativePath = strings.TrimPrefix(relativePath, "/")
// Try .enc variant first, then plain file
var fullPath string
var encrypted bool
encPath, err := SafeResolvePath(s.cfg.UploadDir, relativePath+".enc")
if err == nil {
if _, statErr := os.Stat(encPath); statErr == nil {
fullPath = encPath
encrypted = true
}
}
if fullPath == "" {
plainPath, err := SafeResolvePath(s.cfg.UploadDir, relativePath)
if err != nil {
return nil, "", fmt.Errorf("invalid file path: %w", err)
}
fullPath = plainPath
}
data, err := os.ReadFile(fullPath)
if err != nil {
return nil, "", fmt.Errorf("failed to read file: %w", err)
}
// Decrypt if this is an encrypted file
if encrypted {
if s.encryptionSvc == nil || !s.encryptionSvc.IsEnabled() {
return nil, "", fmt.Errorf("encrypted file found but encryption service is not configured")
}
data, err = s.encryptionSvc.Decrypt(data)
if err != nil {
return nil, "", fmt.Errorf("failed to decrypt file: %w", err)
}
}
// Detect MIME type from decrypted content
mimeType := http.DetectContentType(data)
return data, mimeType, nil
}
// Delete removes a file from storage, handling both plain and .enc variants
func (s *StorageService) Delete(fileURL string) error {
// Convert URL to file path
relativePath := strings.TrimPrefix(fileURL, s.cfg.BaseURL)
@@ -175,14 +241,35 @@ func (s *StorageService) Delete(fileURL string) error {
return fmt.Errorf("invalid file path: %w", err)
}
// Try to delete the plain file
plainDeleted := false
if err := os.Remove(fullPath); err != nil {
if os.IsNotExist(err) {
return nil // File already doesn't exist
if !os.IsNotExist(err) {
return fmt.Errorf("failed to delete file: %w", err)
}
return fmt.Errorf("failed to delete file: %w", err)
} else {
plainDeleted = true
log.Info().Str("path", fullPath).Msg("File deleted")
}
// Also try to delete the .enc variant
encPath, err := SafeResolvePath(s.cfg.UploadDir, relativePath+".enc")
if err == nil {
if err := os.Remove(encPath); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to delete encrypted file: %w", err)
}
} else {
log.Info().Str("path", encPath).Msg("Encrypted file deleted")
return nil
}
}
if !plainDeleted {
// Neither file existed — that's OK
return nil
}
log.Info().Str("path", fullPath).Msg("File deleted")
return nil
}
@@ -225,6 +312,11 @@ func (s *StorageService) GetUploadDir() string {
return s.cfg.UploadDir
}
// SetEncryptionService sets the encryption service for encrypting files at rest
func (s *StorageService) SetEncryptionService(svc *EncryptionService) {
s.encryptionSvc = svc
}
// NewStorageServiceForTest creates a StorageService without creating directories.
// This is intended only for unit tests that need a StorageService with a known config.
func NewStorageServiceForTest(cfg *config.StorageConfig) *StorageService {