package lib import ( "encoding/hex" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // BackupInfo describes a single backup file. type BackupInfo struct { Name string `json:"name"` Size int64 `json:"size"` CreatedAt int64 `json:"created_at"` // unix seconds } // BackupDir returns the backup directory for a given data dir, creating it if needed. func BackupDir(dataDir string) string { dir := filepath.Join(dataDir, "backups") os.MkdirAll(dir, 0700) return dir } // RunBackups backs up vaults whose ID falls in this hour's byte range. // First byte of vault ID (0x00–0xFF) is mapped across the 168 hours of a week, // so a POP with thousands of vaults spreads the work evenly. // Retention: delete backups older than 3 months. func RunBackups(dataDir string) { backupDir := BackupDir(dataDir) now := time.Now() // Current hour of the week: Sun 0:00 = 0, Sat 23:00 = 167 weekHour := int(now.Weekday())*24 + now.Hour() // This hour's first-byte range: [lo, hi) lo := weekHour * 256 / 168 hi := (weekHour + 1) * 256 / 168 dbs, _ := filepath.Glob(filepath.Join(dataDir, "*.db")) for _, dbPath := range dbs { name := strings.TrimSuffix(filepath.Base(dbPath), ".db") if len(name) < 2 { continue } b, err := hex.DecodeString(name[:2]) if err != nil { continue } slot := int(b[0]) if slot < lo || slot >= hi { continue } // Skip if already backed up in the last 6 days if hasRecentBackup(backupDir, name, 6*24*time.Hour) { continue } createBackup(dbPath, backupDir, now) } pruneBackups(backupDir, 90*24*time.Hour) } // hasRecentBackup checks if a vault has a backup newer than maxAge. func hasRecentBackup(backupDir, vaultID string, maxAge time.Duration) bool { files, _ := filepath.Glob(filepath.Join(backupDir, vaultID+"_*.db")) for _, f := range files { info, err := os.Stat(f) if err != nil { continue } if time.Since(info.ModTime()) < maxAge { return true } } return false } // createBackup copies a DB using VACUUM INTO (consistent, compacted snapshot). func createBackup(dbPath, backupDir string, now time.Time) error { name := strings.TrimSuffix(filepath.Base(dbPath), ".db") stamp := now.Format("20060102-150405") dest := filepath.Join(backupDir, fmt.Sprintf("%s_%s.db", name, stamp)) src, err := OpenDB(dbPath) if err != nil { return err } defer src.Close() _, err = src.Conn.Exec(fmt.Sprintf("VACUUM INTO '%s'", dest)) return err } // pruneBackups deletes all backup files older than maxAge. func pruneBackups(backupDir string, maxAge time.Duration) { files, _ := filepath.Glob(filepath.Join(backupDir, "*.db")) cutoff := time.Now().Add(-maxAge) for _, f := range files { info, err := os.Stat(f) if err != nil { continue } if info.ModTime().Before(cutoff) { os.Remove(f) } } } // ListBackups returns all backup files sorted newest first. func ListBackups(dataDir string) []BackupInfo { backupDir := BackupDir(dataDir) files, _ := filepath.Glob(filepath.Join(backupDir, "*.db")) var backups []BackupInfo for _, f := range files { info, err := os.Stat(f) if err != nil { continue } backups = append(backups, BackupInfo{ Name: filepath.Base(f), Size: info.Size(), CreatedAt: info.ModTime().Unix(), }) } sort.Slice(backups, func(i, j int) bool { return backups[i].CreatedAt > backups[j].CreatedAt }) return backups } // RestoreBackup replaces the active DB with a backup file. // The current DB is saved as a pre-restore backup first. func RestoreBackup(dataDir, backupName string) error { backupDir := BackupDir(dataDir) backupPath := filepath.Join(backupDir, backupName) if strings.Contains(backupName, "/") || strings.Contains(backupName, "\\") || strings.Contains(backupName, "..") { return fmt.Errorf("invalid backup name") } if _, err := os.Stat(backupPath); err != nil { return fmt.Errorf("backup not found") } parts := strings.SplitN(backupName, "_", 2) if len(parts) < 2 { return fmt.Errorf("invalid backup name format") } dbPath := filepath.Join(dataDir, parts[0]+".db") // Save current DB as pre-restore backup if _, err := os.Stat(dbPath); err == nil { preRestore := filepath.Join(backupDir, fmt.Sprintf("%s_prerestore_%s.db", parts[0], time.Now().Format("20060102-150405"))) src, err := OpenDB(dbPath) if err == nil { src.Conn.Exec(fmt.Sprintf("VACUUM INTO '%s'", preRestore)) src.Close() } } data, err := os.ReadFile(backupPath) if err != nil { return fmt.Errorf("read backup: %w", err) } return os.WriteFile(dbPath, data, 0600) } // StartBackupTimer checks for due backups every hour. func StartBackupTimer(dataDir string) { go func() { for { RunBackups(dataDir) time.Sleep(1 * time.Hour) } }() }