270 lines
6.7 KiB
Go
270 lines
6.7 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/config"
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
type BackupService struct {
|
|
DataDir string
|
|
BackupDir string
|
|
DatabaseName string
|
|
Cron *cron.Cron
|
|
}
|
|
|
|
type BackupFile struct {
|
|
Filename string `json:"filename"`
|
|
Size int64 `json:"size"`
|
|
Time time.Time `json:"time"`
|
|
}
|
|
|
|
func NewBackupService(cfg *config.Config) *BackupService {
|
|
// Ensure backup directory exists
|
|
backupDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "backups")
|
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
|
logger.Log().WithError(err).Error("Failed to create backup directory")
|
|
}
|
|
|
|
s := &BackupService{
|
|
DataDir: filepath.Dir(cfg.DatabasePath), // e.g. /app/data
|
|
BackupDir: backupDir,
|
|
DatabaseName: filepath.Base(cfg.DatabasePath),
|
|
Cron: cron.New(),
|
|
}
|
|
|
|
// Schedule daily backup at 3 AM
|
|
_, err := s.Cron.AddFunc("0 3 * * *", s.RunScheduledBackup)
|
|
if err != nil {
|
|
logger.Log().WithError(err).Error("Failed to schedule backup")
|
|
}
|
|
s.Cron.Start()
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *BackupService) RunScheduledBackup() {
|
|
logger.Log().Info("Starting scheduled backup")
|
|
if name, err := s.CreateBackup(); err != nil {
|
|
logger.Log().WithError(err).Error("Scheduled backup failed")
|
|
} else {
|
|
logger.Log().WithField("backup", name).Info("Scheduled backup created")
|
|
}
|
|
}
|
|
|
|
// ListBackups returns all backup files sorted by time (newest first)
|
|
func (s *BackupService) ListBackups() ([]BackupFile, error) {
|
|
entries, err := os.ReadDir(s.BackupDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []BackupFile{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var backups []BackupFile
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".zip") {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
backups = append(backups, BackupFile{
|
|
Filename: entry.Name(),
|
|
Size: info.Size(),
|
|
Time: info.ModTime(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort newest first
|
|
sort.Slice(backups, func(i, j int) bool {
|
|
return backups[i].Time.After(backups[j].Time)
|
|
})
|
|
|
|
return backups, nil
|
|
}
|
|
|
|
// CreateBackup creates a zip archive of the database and caddy data
|
|
func (s *BackupService) CreateBackup() (string, error) {
|
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
|
filename := fmt.Sprintf("backup_%s.zip", timestamp)
|
|
zipPath := filepath.Join(s.BackupDir, filename)
|
|
|
|
outFile, err := os.Create(zipPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
w := zip.NewWriter(outFile)
|
|
|
|
// Files/Dirs to backup
|
|
// 1. Database
|
|
dbPath := filepath.Join(s.DataDir, s.DatabaseName)
|
|
// Ensure DB exists before backing up
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return "", fmt.Errorf("database file not found: %s", dbPath)
|
|
}
|
|
if err := s.addToZip(w, dbPath, s.DatabaseName); err != nil {
|
|
return "", fmt.Errorf("backup db: %w", err)
|
|
}
|
|
|
|
// 2. Caddy Data (Certificates, etc)
|
|
// We walk the 'caddy' subdirectory
|
|
caddyDir := filepath.Join(s.DataDir, "caddy")
|
|
if err := s.addDirToZip(w, caddyDir, "caddy"); err != nil {
|
|
// It's possible caddy dir doesn't exist yet, which is fine
|
|
logger.Log().WithError(err).Warn("Warning: could not backup caddy dir")
|
|
}
|
|
|
|
// Close zip writer and check for errors (important for zip integrity)
|
|
if err := w.Close(); err != nil {
|
|
return "", fmt.Errorf("failed to finalize backup: %w", err)
|
|
}
|
|
|
|
return filename, nil
|
|
}
|
|
|
|
func (s *BackupService) addToZip(w *zip.Writer, srcPath, zipPath string) error {
|
|
file, err := os.Open(srcPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
f, err := w.Create(zipPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = io.Copy(f, file)
|
|
return err
|
|
}
|
|
|
|
func (s *BackupService) addDirToZip(w *zip.Writer, srcDir, zipBase string) error {
|
|
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
relPath, err := filepath.Rel(srcDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
zipPath := filepath.Join(zipBase, relPath)
|
|
return s.addToZip(w, path, zipPath)
|
|
})
|
|
}
|
|
|
|
// DeleteBackup removes a backup file
|
|
func (s *BackupService) DeleteBackup(filename string) error {
|
|
cleanName := filepath.Base(filename)
|
|
if filename != cleanName {
|
|
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
|
}
|
|
path := filepath.Join(s.BackupDir, cleanName)
|
|
if !strings.HasPrefix(path, filepath.Clean(s.BackupDir)) {
|
|
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
|
}
|
|
return os.Remove(path)
|
|
}
|
|
|
|
// GetBackupPath returns the full path to a backup file (for downloading)
|
|
func (s *BackupService) GetBackupPath(filename string) (string, error) {
|
|
cleanName := filepath.Base(filename)
|
|
if filename != cleanName {
|
|
return "", fmt.Errorf("invalid filename: path traversal attempt detected")
|
|
}
|
|
path := filepath.Join(s.BackupDir, cleanName)
|
|
if !strings.HasPrefix(path, filepath.Clean(s.BackupDir)) {
|
|
return "", fmt.Errorf("invalid filename: path traversal attempt detected")
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// RestoreBackup restores the database and caddy data from a zip archive
|
|
func (s *BackupService) RestoreBackup(filename string) error {
|
|
cleanName := filepath.Base(filename)
|
|
if filename != cleanName {
|
|
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
|
}
|
|
// 1. Verify backup exists
|
|
srcPath := filepath.Join(s.BackupDir, cleanName)
|
|
if !strings.HasPrefix(srcPath, filepath.Clean(s.BackupDir)) {
|
|
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
|
}
|
|
if _, err := os.Stat(srcPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Unzip to DataDir (overwriting)
|
|
return s.unzip(srcPath, s.DataDir)
|
|
}
|
|
|
|
func (s *BackupService) unzip(src, dest string) error {
|
|
r, err := zip.OpenReader(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
|
|
for _, f := range r.File {
|
|
fpath := filepath.Join(dest, f.Name)
|
|
|
|
// Check for ZipSlip
|
|
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
|
return fmt.Errorf("illegal file path: %s", fpath)
|
|
}
|
|
|
|
if f.FileInfo().IsDir() {
|
|
_ = os.MkdirAll(fpath, os.ModePerm)
|
|
continue
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
|
|
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
_ = outFile.Close()
|
|
return err
|
|
}
|
|
|
|
_, err = io.Copy(outFile, rc)
|
|
|
|
// Check for close errors on writable file
|
|
if closeErr := outFile.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
rc.Close()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|