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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user