mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 16:26:36 +08:00
233 lines
6.8 KiB
Go
233 lines
6.8 KiB
Go
// Package service — database hot backup and restore.
|
||
//
|
||
// Backup files are stored under {data_dir}/backups/ with a timestamp suffix.
|
||
// The admin UI can list / download / delete / restore them.
|
||
//
|
||
// 两种方言的备份形态不同:
|
||
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
|
||
// 扩展名 .db;恢复即「替换库文件 + 重启」。
|
||
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
|
||
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
)
|
||
|
||
// BackupService manages database backups.
|
||
type BackupService struct {
|
||
cfg *config.Config
|
||
log *zap.Logger
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewBackupService is the constructor.
|
||
func NewBackupService(cfg *config.Config, log *zap.Logger, db *gorm.DB) *BackupService {
|
||
return &BackupService{cfg: cfg, log: log, db: db}
|
||
}
|
||
|
||
// BackupInfo describes one stored backup file.
|
||
type BackupInfo struct {
|
||
Filename string `json:"filename"`
|
||
Path string `json:"path"`
|
||
Size int64 `json:"size"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
func (b *BackupService) backupDir() string {
|
||
return filepath.Join(b.cfg.App.DataDir, "backups")
|
||
}
|
||
|
||
func (b *BackupService) backupFilePath(filename string) (string, error) {
|
||
if !isValidBackupFilename(filename) {
|
||
return "", errors.New("invalid filename")
|
||
}
|
||
dir, err := filepath.Abs(b.backupDir())
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
path, err := filepath.Abs(filepath.Join(dir, filename))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
rel, err := filepath.Rel(dir, path)
|
||
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||
return "", errors.New("invalid filename")
|
||
}
|
||
return path, nil
|
||
}
|
||
|
||
// isPostgres reports whether the service is backed by PostgreSQL.
|
||
//
|
||
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
|
||
// 因此备份/恢复必须按方言分支,不能共用一条路径。
|
||
func (b *BackupService) isPostgres() bool {
|
||
return b.db != nil && b.db.Dialector != nil && b.db.Dialector.Name() == "postgres"
|
||
}
|
||
|
||
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
|
||
// a logical dump.
|
||
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
||
dir := b.backupDir()
|
||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||
return nil, err
|
||
}
|
||
ts := time.Now().Format("20060102_150405")
|
||
|
||
var (
|
||
dst string
|
||
err error
|
||
)
|
||
if b.isPostgres() {
|
||
dst, err = b.createPostgresBackup(ctx, ts)
|
||
} else {
|
||
dst, err = b.createSQLiteBackup(ctx, ts)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
stat, err := os.Stat(dst)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
b.log.Info("backup created", zap.String("path", dst), zap.Int64("size", stat.Size()))
|
||
return &BackupInfo{
|
||
Filename: filepath.Base(dst),
|
||
Path: dst,
|
||
Size: stat.Size(),
|
||
CreatedAt: stat.ModTime(),
|
||
}, nil
|
||
}
|
||
|
||
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
|
||
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
|
||
// VACUUM INTO creates a clean, non-WAL copy atomically.
|
||
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
|
||
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
|
||
}
|
||
return dst, nil
|
||
}
|
||
|
||
// List returns every backup file in the backup directory, newest first.
|
||
func (b *BackupService) List() ([]BackupInfo, error) {
|
||
dir := b.backupDir()
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
out := make([]BackupInfo, 0, len(entries))
|
||
for _, e := range entries {
|
||
if e.IsDir() || !isBackupFilename(e.Name()) {
|
||
continue
|
||
}
|
||
info, err := e.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, BackupInfo{
|
||
Filename: e.Name(),
|
||
Path: filepath.Join(dir, e.Name()),
|
||
Size: info.Size(),
|
||
CreatedAt: info.ModTime(),
|
||
})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||
})
|
||
return out, nil
|
||
}
|
||
|
||
// Delete removes a single backup file.
|
||
func (b *BackupService) Delete(filename string) error {
|
||
path, err := b.backupFilePath(filename)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return os.Remove(path)
|
||
}
|
||
|
||
// Restore replaces the live database with the given backup.
|
||
// WARNING: this is destructive. SQLite restores by swapping the database file
|
||
// (the caller should shut down the server afterwards); PostgreSQL restores by
|
||
// rebuilding every table inside a single transaction.
|
||
func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
||
src, err := b.backupFilePath(filename)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := os.Stat(src); err != nil {
|
||
return fmt.Errorf("backup not found: %s", filename)
|
||
}
|
||
if b.isPostgres() {
|
||
if !strings.HasSuffix(filename, postgresBackupSuffix) {
|
||
return errors.New("该备份是 SQLite 文件备份,当前运行在 PostgreSQL 上,无法恢复")
|
||
}
|
||
return b.restorePostgresBackup(ctx, src)
|
||
}
|
||
if !strings.HasSuffix(filename, ".db") {
|
||
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
|
||
}
|
||
return b.restoreSQLiteBackup(filename, src)
|
||
}
|
||
|
||
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
|
||
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
|
||
if err != nil || dbPath == "" {
|
||
return errors.New("invalid database path")
|
||
}
|
||
// Strategy: rename live → live.old, copy backup → live, delete old.
|
||
old := dbPath + ".before_restore"
|
||
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return err
|
||
}
|
||
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
|
||
if err != nil {
|
||
// Revert
|
||
_ = os.Rename(old, dbPath)
|
||
return err
|
||
}
|
||
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
|
||
_ = os.Rename(old, dbPath)
|
||
return err
|
||
}
|
||
_ = os.Remove(old)
|
||
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
|
||
_ = os.Remove(dbPath + "-wal")
|
||
_ = os.Remove(dbPath + "-shm")
|
||
b.log.Warn("database restored from backup — restart the server",
|
||
zap.String("backup", filename))
|
||
return nil
|
||
}
|
||
|
||
// isBackupFilename reports whether name looks like a backup produced by either
|
||
// dialect (SQLite ".db" or PostgreSQL ".json.gz").
|
||
func isBackupFilename(name string) bool {
|
||
return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, postgresBackupSuffix)
|
||
}
|
||
|
||
// isValidBackupFilename rejects path traversal attempts and non-backup files.
|
||
func isValidBackupFilename(name string) bool {
|
||
if name == "" {
|
||
return false
|
||
}
|
||
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
||
return false
|
||
}
|
||
return isBackupFilename(name)
|
||
}
|