// Package backup creates rotating copies of the SQLite logbook so a single // disk failure or accidental delete doesn't wipe the user's QSO history. // The strategy is intentionally simple: a daily snapshot of the .db file // (optionally zipped), keeping the last N. SQLite's atomic-commit format // lets us copy the raw file safely as long as we copy after a checkpoint. package backup import ( "archive/zip" "context" "database/sql" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" ) // Settings holds the user-tweakable backup configuration. Stored as flat // key/value pairs in the settings table (so we don't need a schema change // every time we add a field). type Settings struct { Enabled bool `json:"enabled"` Folder string `json:"folder"` // empty → DefaultFolder Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5 Zip bool `json:"zip"` // compress with deflate LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never } // DefaultFolder returns the folder used when Settings.Folder is empty. // It sits next to the database file (in /hamlog/backups) which // keeps it portable when the user moves the data directory. func DefaultFolder(dataDir string) string { return filepath.Join(dataDir, "backups") } // Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally // zip → rotate. Returns the path of the file that was written so the caller // can surface it to the UI. // // VACUUM INTO produces a transactionally-consistent copy in a single SQL // statement (no torn-copy window while the app keeps writing), and compacts // the destination as a bonus. It replaces the old "checkpoint + raw io.Copy", // which could capture a half-written page during a concurrent write. func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) { if dbConn == nil { return "", fmt.Errorf("nil db connection") } if rotation <= 0 { rotation = 5 } if folder == "" { return "", fmt.Errorf("backup folder not set") } if err := os.MkdirAll(folder, 0o755); err != nil { return "", fmt.Errorf("create backup folder: %w", err) } stamp := time.Now().Format("2006-01-02") base := fmt.Sprintf("opslog-%s", stamp) // VACUUM INTO requires a non-existent target → use a temp file, then // move/zip it into place. tmp := filepath.Join(folder, base+".vacuum.tmp") _ = os.Remove(tmp) if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil { _ = os.Remove(tmp) return "", fmt.Errorf("vacuum into: %w", err) } var dstPath string if doZip { dstPath = filepath.Join(folder, base+".db.zip") // Inner name = the live DB's base so unzip restores e.g. "opslog.db". if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil { _ = os.Remove(tmp) return "", err } _ = os.Remove(tmp) } else { dstPath = filepath.Join(folder, base+".db") _ = os.Remove(dstPath) if err := os.Rename(tmp, dstPath); err != nil { // Rename can fail across filesystems — fall back to a copy. if cerr := copyFile(tmp, dstPath); cerr != nil { _ = os.Remove(tmp) return "", cerr } _ = os.Remove(tmp) } } if err := rotate(folder, rotation); err != nil { // Rotation errors are non-fatal — the backup itself succeeded. return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath) } return dstPath, nil } // RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the // log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an // ADIF export is a portable, re-importable backup of the actual contacts. // writeADIF must write the full ADIF to the path it's handed. func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) { if rotation <= 0 { rotation = 5 } if folder == "" { return "", fmt.Errorf("backup folder not set") } if err := os.MkdirAll(folder, 0o755); err != nil { return "", fmt.Errorf("create backup folder: %w", err) } base := "opslog-log-" + time.Now().Format("2006-01-02") tmp := filepath.Join(folder, base+".adi.tmp") _ = os.Remove(tmp) if err := writeADIF(tmp); err != nil { _ = os.Remove(tmp) return "", fmt.Errorf("export adif: %w", err) } var dstPath string if doZip { dstPath = filepath.Join(folder, base+".adi.zip") if err := copyZipped(tmp, dstPath, base+".adi"); err != nil { _ = os.Remove(tmp) return "", err } _ = os.Remove(tmp) } else { dstPath = filepath.Join(folder, base+".adi") _ = os.Remove(dstPath) if err := os.Rename(tmp, dstPath); err != nil { if cerr := copyFile(tmp, dstPath); cerr != nil { _ = os.Remove(tmp) return "", cerr } _ = os.Remove(tmp) } } if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil { return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath) } return dstPath, nil } // copyFile performs a plain file copy. We don't use os.Rename because // the source is the live database; we want a fresh standalone file. func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } defer in.Close() out, err := os.Create(dst) if err != nil { return fmt.Errorf("create dest: %w", err) } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(dst) return fmt.Errorf("copy: %w", err) } return out.Close() } // copyZipped writes a single-entry deflate zip containing the database. // innerName is the entry name inside the zip (the live DB's base name) so // unzip restores e.g. "opslog.db" wherever the user extracts. func copyZipped(src, dst, innerName string) error { in, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } defer in.Close() out, err := os.Create(dst) if err != nil { return fmt.Errorf("create dest: %w", err) } zw := zip.NewWriter(out) w, err := zw.Create(innerName) if err != nil { zw.Close() out.Close() os.Remove(dst) return fmt.Errorf("zip entry: %w", err) } if _, err := io.Copy(w, in); err != nil { zw.Close() out.Close() os.Remove(dst) return fmt.Errorf("zip write: %w", err) } if err := zw.Close(); err != nil { out.Close() os.Remove(dst) return fmt.Errorf("zip close: %w", err) } return out.Close() } // rotate keeps the most recent `keep` SQLite backups (opslog-*.db / // opslog-*.db.zip) and deletes the rest. func rotate(folder string, keep int) error { return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip") } // rotateMatch keeps the most recent `keep` files in folder whose name has the // given prefix and one of the given suffixes, deleting older ones. Only matching // files are touched — never unrelated user files in the same folder. The suffix // filter keeps the .db family and the .adi family from rotating each other even // though both share the "opslog-" prefix. func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error { entries, err := os.ReadDir(folder) if err != nil { return err } type item struct { path string mod time.Time } matches := make([]item, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasPrefix(name, prefix) { continue } ok := false for _, sfx := range suffixes { if strings.HasSuffix(name, sfx) { ok = true break } } if !ok { continue } info, err := e.Info() if err != nil { continue } matches = append(matches, item{path: filepath.Join(folder, name), mod: info.ModTime()}) } if len(matches) <= keep { return nil } sort.Slice(matches, func(i, j int) bool { return matches[i].mod.After(matches[j].mod) }) var firstErr error for _, m := range matches[keep:] { if err := os.Remove(m.path); err != nil && firstErr == nil { firstErr = err } } return firstErr } // HasBackupToday returns true if a SQLite backup for today's date already // exists in folder. Used by the auto-backup to skip when the user has already // restarted the app once today. func HasBackupToday(folder string) bool { return hasBackupToday(folder, "opslog-", ".db", ".db.zip") } // HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode). func HasADIFBackupToday(folder string) bool { return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip") } func hasBackupToday(folder, prefix string, exts ...string) bool { if folder == "" { return false } stamp := time.Now().Format("2006-01-02") for _, ext := range exts { if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil { return true } } return false }