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:
190
cmd/migrate-encrypt/main.go
Normal file
190
cmd/migrate-encrypt/main.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// migrate-encrypt is a standalone CLI tool that encrypts existing uploaded files at rest.
|
||||
//
|
||||
// It walks the uploads directory, encrypts each unencrypted file, updates the
|
||||
// corresponding database record, and removes the original plaintext file.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/migrate-encrypt --dry-run # Preview changes
|
||||
// go run ./cmd/migrate-encrypt # Apply changes
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
)
|
||||
|
||||
// dbTable represents a table with a URL column that may reference uploaded files.
|
||||
type dbTable struct {
|
||||
table string
|
||||
column string
|
||||
}
|
||||
|
||||
// tables lists all database tables and columns that store file URLs.
|
||||
var tables = []dbTable{
|
||||
{table: "task_document", column: "file_url"},
|
||||
{table: "task_documentimage", column: "image_url"},
|
||||
{table: "task_taskcompletionimage", column: "image_url"},
|
||||
}
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "Preview changes without modifying files or database")
|
||||
flag.Parse()
|
||||
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to load config")
|
||||
}
|
||||
|
||||
if cfg.Storage.EncryptionKey == "" {
|
||||
log.Fatal().Msg("STORAGE_ENCRYPTION_KEY is not set — cannot encrypt files")
|
||||
}
|
||||
|
||||
encSvc, err := services.NewEncryptionService(cfg.Storage.EncryptionKey)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to create encryption service")
|
||||
}
|
||||
|
||||
dsn := cfg.Database.DSN()
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
|
||||
uploadDir := cfg.Storage.UploadDir
|
||||
absUploadDir, err := filepath.Abs(uploadDir)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Str("upload_dir", uploadDir).Msg("Failed to resolve upload directory")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Bool("dry_run", *dryRun).
|
||||
Str("upload_dir", absUploadDir).
|
||||
Msg("Starting file encryption migration")
|
||||
|
||||
var totalFiles, encrypted, skipped, errCount int
|
||||
|
||||
// Walk the uploads directory
|
||||
err = filepath.Walk(absUploadDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
log.Warn().Err(walkErr).Str("path", path).Msg("Error accessing path")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files already encrypted
|
||||
if strings.HasSuffix(path, ".enc") {
|
||||
skipped++
|
||||
return nil
|
||||
}
|
||||
|
||||
totalFiles++
|
||||
|
||||
// Compute the relative path from upload dir
|
||||
relPath, err := filepath.Rel(absUploadDir, path)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("path", path).Msg("Failed to compute relative path")
|
||||
errCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
log.Info().Str("file", relPath).Msg("[DRY RUN] Would encrypt")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process within a transaction
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Read plaintext file
|
||||
plaintext, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
ciphertext, encErr := encSvc.Encrypt(plaintext)
|
||||
if encErr != nil {
|
||||
return encErr
|
||||
}
|
||||
|
||||
// Write encrypted file
|
||||
encPath := path + ".enc"
|
||||
if writeErr := os.WriteFile(encPath, ciphertext, 0644); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
// Update database records that reference this file
|
||||
// The stored URL uses the BaseURL prefix + relative path
|
||||
// We need to match against the relative path portion
|
||||
for _, t := range tables {
|
||||
// Match URLs ending with the relative path (handles both /uploads/... and bare paths)
|
||||
result := tx.Table(t.table).
|
||||
Where(t.column+" LIKE ?", "%"+relPath).
|
||||
Where(t.column+" NOT LIKE ?", "%.enc").
|
||||
Update(t.column, gorm.Expr(t.column+" || '.enc'"))
|
||||
if result.Error != nil {
|
||||
// Roll back: remove the encrypted file
|
||||
os.Remove(encPath)
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Info().
|
||||
Str("table", t.table).
|
||||
Str("column", t.column).
|
||||
Int64("rows", result.RowsAffected).
|
||||
Str("file", relPath).
|
||||
Msg("Updated database records")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the original plaintext file
|
||||
if removeErr := os.Remove(path); removeErr != nil {
|
||||
log.Warn().Err(removeErr).Str("path", path).Msg("Failed to remove original file (encrypted copy exists)")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if txErr != nil {
|
||||
log.Error().Err(txErr).Str("file", relPath).Msg("Failed to encrypt file")
|
||||
errCount++
|
||||
} else {
|
||||
encrypted++
|
||||
log.Info().Str("file", relPath).Msg("Encrypted successfully")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to walk upload directory")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Bool("dry_run", *dryRun).
|
||||
Int("total_files", totalFiles).
|
||||
Int("encrypted", encrypted).
|
||||
Int("skipped_already_enc", skipped).
|
||||
Int("errors", errCount).
|
||||
Msg("Encryption migration complete")
|
||||
}
|
||||
Reference in New Issue
Block a user