Files
phishingclub/backend/service/backup.go
Ronni Skansing 4d8aac54a1 add manual backup
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-09-18 14:05:23 +02:00

583 lines
16 KiB
Go

package service
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"gorm.io/gorm"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/validate"
)
// BackupFile represents a backup file available for download
type BackupFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"`
RelativePath string `json:"relativePath"`
}
type Backup struct {
Common
OptionService *Option
DB *gorm.DB
FilePath string // base file path for application data
}
// BackupStatus represents the status of a backup operation
type BackupStatus struct {
IsRunning bool `json:"isRunning"`
IsComplete bool `json:"isComplete"`
HasError bool `json:"hasError"`
ErrorMessage string `json:"errorMessage"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
BackupPath string `json:"backupPath"`
Progress string `json:"progress"`
}
// BackupResult represents the result of a backup operation
type BackupResult struct {
BackupPath string `json:"backupPath"`
DatabaseSize int64 `json:"databaseSize"`
FilesSize int64 `json:"filesSize"`
TotalSize int64 `json:"totalSize"`
Duration time.Duration `json:"duration"`
}
var (
currentBackupStatus *BackupStatus
)
// CreateBackup creates a backup of the database and files
func (b *Backup) CreateBackup(
ctx context.Context,
session *model.Session,
) error {
ae := NewAuditEvent("Backup.CreateBackup", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
b.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return errors.New("unauthorized")
}
// check if backup is already running
if currentBackupStatus != nil && currentBackupStatus.IsRunning {
return errors.New("backup already in progress")
}
// initialize backup status
currentBackupStatus = &BackupStatus{
IsRunning: true,
IsComplete: false,
HasError: false,
StartTime: time.Now(),
Progress: "starting backup",
}
// run backup synchronously to lock interface
err = b.performBackup(ctx)
currentBackupStatus.IsRunning = false
currentBackupStatus.EndTime = time.Now()
if err != nil {
currentBackupStatus.HasError = true
currentBackupStatus.ErrorMessage = err.Error()
b.Logger.Errorw("backup failed", "error", err)
b.AuditLogAuthorized(ae)
return errs.Wrap(err)
} else {
currentBackupStatus.IsComplete = true
currentBackupStatus.Progress = "backup completed"
b.Logger.Infow("backup completed successfully", "path", currentBackupStatus.BackupPath)
// automatically cleanup old backups to maintain maximum of 3
currentBackupStatus.Progress = "cleaning up old backups"
cleanupErr := b.CleanupOldBackups(ctx, session, 3)
if cleanupErr != nil {
b.Logger.Warnw("failed to cleanup old backups", "error", cleanupErr)
// don't fail the backup operation if cleanup fails
} else {
b.Logger.Debugw("cleaned up old backups, keeping latest 3")
}
ae.Details["backupPath"] = currentBackupStatus.BackupPath
}
if currentBackupStatus.HasError {
ae.Details["error"] = currentBackupStatus.ErrorMessage
b.AuditLogAuthorized(ae)
return errs.Wrap(errors.New(currentBackupStatus.ErrorMessage))
}
b.AuditLogAuthorized(ae)
return nil
}
// performBackup performs the actual backup operation
func (b *Backup) performBackup(ctx context.Context) error {
timestamp := time.Now().Format("20060102-150405")
filesPath := strings.TrimSuffix(b.FilePath, "/")
backupDir := filepath.Join(filesPath, "backups", fmt.Sprintf("backup-%s", timestamp))
// create backup directory
if err := os.MkdirAll(backupDir, 0755); err != nil {
return errs.Wrap(err)
}
currentBackupStatus.Progress = "backing up database"
b.Logger.Debugw("starting database backup")
// backup database directly to backup root
if err := b.backupDatabase(ctx, backupDir); err != nil {
return errs.Wrap(err)
}
currentBackupStatus.Progress = "backing up files"
b.Logger.Debugw("starting files backup")
// backup files directly to backup root (preserving directory structure)
if err := b.backupFiles(backupDir); err != nil {
return errs.Wrap(err)
}
currentBackupStatus.Progress = "compressing backup"
b.Logger.Debugw("compressing backup")
// compress backup
backupArchive := backupDir + ".tar.gz"
if err := b.compressBackup(backupDir, backupArchive); err != nil {
return errs.Wrap(err)
}
// remove uncompressed backup directory
if err := os.RemoveAll(backupDir); err != nil {
b.Logger.Warnw("failed to remove uncompressed backup directory", "error", err)
}
currentBackupStatus.BackupPath = backupArchive
return nil
}
// backupDatabase creates a backup of the sqlite database
func (b *Backup) backupDatabase(ctx context.Context, backupPath string) error {
// get the underlying sql.DB
sqlDB, err := b.DB.DB()
if err != nil {
return errs.Wrap(err)
}
// execute wal checkpoint to ensure all data is written to main db file
_, err = sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
if err != nil {
b.Logger.Warnw("failed to checkpoint wal", "error", err)
// continue anyway as this is not critical
}
// extract database path from DSN
dbPath := b.extractDatabasePath()
// copy main database file
if err := b.copyFile(dbPath, filepath.Join(backupPath, "db.sqlite3")); err != nil {
return errs.Wrap(err)
}
// copy wal file if it exists
walPath := dbPath + "-wal"
if _, err := os.Stat(walPath); err == nil {
if err := b.copyFile(walPath, filepath.Join(backupPath, "db.sqlite3-wal")); err != nil {
b.Logger.Debugw("failed to copy wal file", "error", err)
}
}
// copy shm file if it exists
shmPath := dbPath + "-shm"
if _, err := os.Stat(shmPath); err == nil {
if err := b.copyFile(shmPath, filepath.Join(backupPath, "db.sqlite3-shm")); err != nil {
b.Logger.Debugw("failed to copy shm file", "error", err)
}
}
return nil
}
// backupFiles creates a backup of application files
func (b *Backup) backupFiles(backupPath string) error {
// files are stored in the path specified by --files flag
// remove trailing slash if present for consistent path joining
filesPath := strings.TrimSuffix(b.FilePath, "/")
filesToBackup := []string{"assets", "attachments", "certs"}
for _, dir := range filesToBackup {
srcPath := filepath.Join(filesPath, dir)
dstPath := filepath.Join(backupPath, dir)
// check if source directory exists
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
b.Logger.Debugw("directory does not exist, skipping", "path", srcPath)
continue
}
// copy directory
if err := b.copyDir(srcPath, dstPath); err != nil {
return errs.Wrap(err)
}
}
return nil
}
// extractDatabasePath extracts the database file path from the GORM DSN
func (b *Backup) extractDatabasePath() string {
// get the underlying sql.DB to access the data source name
sqlDB, err := b.DB.DB()
if err != nil {
b.Logger.Debugw("failed to get sql.DB, using default path", "error", err)
return "./db.sqlite3"
}
// try to get database list to find the actual file path
rows, err := sqlDB.Query("PRAGMA database_list")
if err != nil {
b.Logger.Debugw("failed to query database list, using default path", "error", err)
return "./db.sqlite3"
}
defer rows.Close()
for rows.Next() {
var seq int
var name, file string
err := rows.Scan(&seq, &name, &file)
if err != nil {
continue
}
// main database has seq=0 and name="main"
if seq == 0 && name == "main" && file != "" {
b.Logger.Debugw("found database path from PRAGMA database_list", "path", file)
return file
}
}
// fallback to default
b.Logger.Debugw("could not determine database path from PRAGMA, using default")
return "./db.sqlite3"
}
// compressBackup compresses the backup directory into a tar.gz file
func (b *Backup) compressBackup(srcDir, dstFile string) error {
file, err := os.Create(dstFile)
if err != nil {
return errs.Wrap(err)
}
defer file.Close()
gzipWriter := gzip.NewWriter(file)
defer gzipWriter.Close()
tarWriter := tar.NewWriter(gzipWriter)
defer tarWriter.Close()
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// get relative path
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
// create tar header
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
// write header
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
// write file content if it's a regular file
if info.Mode().IsRegular() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(tarWriter, file)
return err
}
return nil
})
}
// copyFile copies a file from src to dst
func (b *Backup) copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return errs.Wrap(err)
}
defer sourceFile.Close()
// create destination directory if it doesn't exist
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return errs.Wrap(err)
}
destFile, err := os.Create(dst)
if err != nil {
return errs.Wrap(err)
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return errs.Wrap(err)
}
// copyDir recursively copies a directory from src to dst
func (b *Backup) copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// get relative path
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
return b.copyFile(path, dstPath)
})
}
// CleanupOldBackups removes old backup files to save disk space
func (b *Backup) CleanupOldBackups(
ctx context.Context,
session *model.Session,
keepCount int,
) error {
ae := NewAuditEvent("Backup.CleanupOldBackups", session)
ae.Details["keepCount"] = keepCount
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
b.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
filesPath := strings.TrimSuffix(b.FilePath, "/")
backupDir := filepath.Join(filesPath, "backups")
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
return nil // no backups directory
}
// get all backup files
files, err := os.ReadDir(backupDir)
if err != nil {
return errs.Wrap(err)
}
// filter backup files and sort by modification time
var backupFiles []os.FileInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") {
info, err := file.Info()
if err != nil {
continue
}
backupFiles = append(backupFiles, info)
}
}
// if we have more backups than we want to keep, delete the oldest ones
if len(backupFiles) > keepCount {
// sort by modification time (oldest first)
for i := 0; i < len(backupFiles)-1; i++ {
for j := i + 1; j < len(backupFiles); j++ {
if backupFiles[i].ModTime().After(backupFiles[j].ModTime()) {
backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i]
}
}
}
// delete oldest files
filesToDelete := len(backupFiles) - keepCount
deletedFiles := []string{}
for i := 0; i < filesToDelete; i++ {
filePath := filepath.Join(backupDir, backupFiles[i].Name())
if err := os.Remove(filePath); err != nil {
b.Logger.Warnw("failed to delete old backup", "file", filePath, "error", err)
} else {
b.Logger.Debugw("deleted old backup", "file", filePath)
deletedFiles = append(deletedFiles, backupFiles[i].Name())
}
}
ae.Details["deletedFiles"] = deletedFiles
ae.Details["deletedCount"] = len(deletedFiles)
}
ae.Details["totalBackups"] = len(backupFiles)
b.AuditLogAuthorized(ae)
return nil
}
// ListBackups returns a list of available backup files
func (b *Backup) ListBackups(
ctx context.Context,
session *model.Session,
) ([]BackupFile, error) {
ae := NewAuditEvent("Backup.ListBackups", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
b.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
filesPath := strings.TrimSuffix(b.FilePath, "/")
backupDir := filepath.Join(filesPath, "backups")
// check if backup directory exists
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
return []BackupFile{}, nil // return empty list if no backups directory
}
// read backup directory
files, err := os.ReadDir(backupDir)
if err != nil {
return nil, errs.Wrap(err)
}
var backupFiles []BackupFile
for _, file := range files {
if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") {
info, err := file.Info()
if err != nil {
continue
}
backupFiles = append(backupFiles, BackupFile{
Name: file.Name(),
Size: info.Size(),
CreatedAt: info.ModTime(),
RelativePath: filepath.Join("backups", file.Name()),
})
}
}
// sort by creation time (newest first)
for i := 0; i < len(backupFiles)-1; i++ {
for j := i + 1; j < len(backupFiles); j++ {
if backupFiles[i].CreatedAt.Before(backupFiles[j].CreatedAt) {
backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i]
}
}
}
ae.Details["backupCount"] = len(backupFiles)
b.AuditLogAuthorized(ae)
return backupFiles, nil
}
// GetBackupFile returns a file handle to a backup file if it exists and is valid
func (b *Backup) GetBackupFile(
ctx context.Context,
session *model.Session,
filename string,
) (*os.File, error) {
ae := NewAuditEvent("Backup.DownloadBackup", session)
ae.Details["filename"] = filename
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
b.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// validate filename - must be a backup file
if !strings.HasPrefix(filename, "backup-") || !strings.HasSuffix(filename, ".tar.gz") {
b.Logger.Debugw("invalid backup filename format", "filename", filename)
return nil, validate.WrapErrorWithField(errors.New("invalid backup filename"), "filename")
}
// get backup directory path
filesPath := strings.TrimSuffix(b.FilePath, "/")
backupDir := filepath.Join(filesPath, "backups")
// use os.OpenRoot for secure file access within backup directory
root, err := os.OpenRoot(backupDir)
if err != nil {
return nil, errs.Wrap(err)
}
defer root.Close()
// try to stat the file using the secure root - this prevents directory traversal
info, err := root.Stat(filename)
if err != nil {
if os.IsNotExist(err) {
b.Logger.Debugw("backup file not found", "filename", filename)
return nil, gorm.ErrRecordNotFound
}
return nil, errs.Wrap(err)
}
if !info.Mode().IsRegular() {
b.Logger.Debugw("requested file is not a regular file", "filename", filename)
return nil, validate.WrapErrorWithField(errors.New("not a regular file"), "filename")
}
// open the file using the secure root - this maintains security throughout
file, err := root.Open(filename)
if err != nil {
return nil, errs.Wrap(err)
}
ae.Details["backupSize"] = info.Size()
b.AuditLogAuthorized(ae)
return file, nil
}