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

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -848,39 +847,33 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
}
}
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding.
// Uses StorageService.ReadFile to transparently handle encrypted files.
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
var emailImages []EmbeddedImage
uploadDir := s.storageService.GetUploadDir()
for i, img := range images {
// Resolve file path from stored URL
filePath := s.resolveImageFilePath(img.ImageURL, uploadDir)
if filePath == "" {
log.Warn().Str("image_url", img.ImageURL).Msg("Could not resolve image file path")
continue
}
// Read file from disk
data, err := os.ReadFile(filePath)
// Read file via storage service (handles encryption transparently)
data, mimeType, err := s.storageService.ReadFile(img.ImageURL)
if err != nil {
log.Warn().Err(err).Str("path", filePath).Msg("Failed to read completion image for email")
log.Warn().Err(err).Str("image_url", img.ImageURL).Msg("Failed to read completion image for email")
continue
}
// Determine content type from extension
contentType := s.getContentTypeFromPath(filePath)
// Use detected MIME type, fall back to extension-based detection
if mimeType == "application/octet-stream" {
mimeType = s.getContentTypeFromPath(img.ImageURL)
}
// Create embedded image with unique Content-ID
emailImages = append(emailImages, EmbeddedImage{
ContentID: fmt.Sprintf("completion-image-%d", i+1),
Filename: filepath.Base(filePath),
ContentType: contentType,
Filename: filepath.Base(img.ImageURL),
ContentType: mimeType,
Data: data,
})
log.Debug().Str("path", filePath).Int("size", len(data)).Msg("Loaded completion image for email")
log.Debug().Str("image_url", img.ImageURL).Int("size", len(data)).Msg("Loaded completion image for email")
}
return emailImages