package server import ( "database/sql" "errors" "fmt" "time" _ "modernc.org/sqlite" ) const schema = ` CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, domain TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE, config_yaml TEXT NOT NULL DEFAULT '', keep_daily INTEGER NOT NULL DEFAULT 7, keep_weekly INTEGER NOT NULL DEFAULT 4, keep_monthly INTEGER NOT NULL DEFAULT 6, heavy_keep INTEGER NOT NULL DEFAULT 1, heavy_every_days INTEGER NOT NULL DEFAULT 7, stale_hours INTEGER NOT NULL DEFAULT 26, created_at INTEGER NOT NULL, last_backup_at INTEGER NOT NULL DEFAULT 0, last_heavy_at INTEGER NOT NULL DEFAULT 0, last_seen_at INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', last_error_at INTEGER NOT NULL DEFAULT 0, stale_notified INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS backups ( id INTEGER PRIMARY KEY, client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, status TEXT NOT NULL, dir TEXT NOT NULL DEFAULT '', manifest TEXT NOT NULL, total_size INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS backups_client ON backups(client_id, created_at); CREATE TABLE IF NOT EXISTS parts ( backup_id INTEGER NOT NULL REFERENCES backups(id) ON DELETE CASCADE, name TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, sha256 TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, PRIMARY KEY (backup_id, name) ); CREATE TABLE IF NOT EXISTS restore_tokens ( token_hash TEXT PRIMARY KEY, client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, csrf TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); ` const ( StatusUploading = "uploading" StatusComplete = "complete" StatusFailed = "failed" PartUploaded = "uploaded" PartComplete = "complete" PartDeleted = "deleted" // heavy-часть удалена ротацией ) var ErrNotFound = errors.New("не найдено") type Store struct { db *sql.DB } func OpenStore(path string) (*Store, error) { db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") if err != nil { return nil, err } // одно соединение: исключает SQLITE_BUSY, нагрузка у сервиса мизерная db.SetMaxOpenConns(1) if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("миграция: %w", err) } return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } type Client struct { ID int64 Name string Domain string TokenHash string ConfigYAML string KeepDaily int KeepWeekly int KeepMonthly int HeavyKeep int HeavyEveryDays int StaleHours int CreatedAt int64 LastBackupAt int64 LastHeavyAt int64 LastSeenAt int64 LastError string LastErrorAt int64 StaleNotified bool Disabled bool } const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly, heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at, last_error, last_error_at, stale_notified, disabled` func scanClient(row interface{ Scan(...any) error }) (*Client, error) { var c Client err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly, &c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt, &c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return &c, err } func (s *Store) CreateClient(name, domain, tokenHash string) (int64, error) { res, err := s.db.Exec(`INSERT INTO clients (name, domain, token_hash, created_at) VALUES (?, ?, ?, ?)`, name, domain, tokenHash, time.Now().Unix()) if err != nil { return 0, err } return res.LastInsertId() } func (s *Store) GetClient(id int64) (*Client, error) { return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE id = ?`, id)) } func (s *Store) ClientByToken(hash string) (*Client, error) { return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE token_hash = ?`, hash)) } func (s *Store) ListClients() ([]*Client, error) { rows, err := s.db.Query(`SELECT ` + clientCols + ` FROM clients ORDER BY domain`) if err != nil { return nil, err } defer rows.Close() var out []*Client for rows.Next() { c, err := scanClient(rows) if err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } func (s *Store) UpdateClientSettings(c *Client) error { _, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?, keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ? WHERE id = ?`, c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays, c.StaleHours, c.Disabled, c.ID) return err } func (s *Store) SetClientToken(id int64, hash string) error { _, err := s.db.Exec(`UPDATE clients SET token_hash = ? WHERE id = ?`, hash, id) return err } func (s *Store) DeleteClient(id int64) error { _, err := s.db.Exec(`DELETE FROM clients WHERE id = ?`, id) return err } func (s *Store) TouchClient(id int64) error { _, err := s.db.Exec(`UPDATE clients SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), id) return err } func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error { q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0` if heavy { q += `, last_heavy_at = ?1` } _, err := s.db.Exec(q+` WHERE id = ?2`, at, id) return err } func (s *Store) MarkClientError(id int64, msg string) error { _, err := s.db.Exec(`UPDATE clients SET last_error = ?, last_error_at = ? WHERE id = ?`, msg, time.Now().Unix(), id) return err } func (s *Store) SetStaleNotified(id int64) error { _, err := s.db.Exec(`UPDATE clients SET stale_notified = 1 WHERE id = ?`, id) return err } type Backup struct { ID int64 ClientID int64 CreatedAt int64 Status string Dir string Manifest string TotalSize int64 Error string Parts []Part } type Part struct { BackupID int64 Name string Size int64 SHA256 string Status string } func (s *Store) CreateBackup(clientID int64, createdAt int64, manifest string) (int64, error) { res, err := s.db.Exec(`INSERT INTO backups (client_id, created_at, status, manifest) VALUES (?, ?, ?, ?)`, clientID, createdAt, StatusUploading, manifest) if err != nil { return 0, err } return res.LastInsertId() } func (s *Store) SetBackupDir(id int64, dir string) error { _, err := s.db.Exec(`UPDATE backups SET dir = ? WHERE id = ?`, dir, id) return err } func (s *Store) SetBackupStatus(id int64, status, errMsg string, totalSize int64) error { _, err := s.db.Exec(`UPDATE backups SET status = ?, error = ?, total_size = ? WHERE id = ?`, status, errMsg, totalSize, id) return err } func (s *Store) SetBackupManifest(id int64, manifest string) error { _, err := s.db.Exec(`UPDATE backups SET manifest = ? WHERE id = ?`, manifest, id) return err } func (s *Store) DeleteBackup(id int64) error { _, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id) return err } func (s *Store) GetBackup(id int64) (*Backup, error) { var b Backup err := s.db.QueryRow(`SELECT id, client_id, created_at, status, dir, manifest, total_size, error FROM backups WHERE id = ?`, id). Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.Manifest, &b.TotalSize, &b.Error) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } b.Parts, err = s.parts(id) return &b, err } func (s *Store) parts(backupID int64) ([]Part, error) { rows, err := s.db.Query(`SELECT backup_id, name, size, sha256, status FROM parts WHERE backup_id = ? ORDER BY name = 'main' DESC, name`, backupID) if err != nil { return nil, err } defer rows.Close() var out []Part for rows.Next() { var p Part if err := rows.Scan(&p.BackupID, &p.Name, &p.Size, &p.SHA256, &p.Status); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // ListBackups возвращает бэкапы клиента от новых к старым (без манифеста), с частями. func (s *Store) ListBackups(clientID int64, status string) ([]*Backup, error) { q := `SELECT id, client_id, created_at, status, dir, total_size, error FROM backups WHERE client_id = ?` args := []any{clientID} if status != "" { q += ` AND status = ?` args = append(args, status) } rows, err := s.db.Query(q+` ORDER BY created_at DESC, id DESC`, args...) if err != nil { return nil, err } var out []*Backup for rows.Next() { var b Backup if err := rows.Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.TotalSize, &b.Error); err != nil { rows.Close() return nil, err } out = append(out, &b) } rows.Close() if err := rows.Err(); err != nil { return nil, err } for _, b := range out { if b.Parts, err = s.parts(b.ID); err != nil { return nil, err } } return out, nil } // LastSizes возвращает размер последнего успешного бэкапа по каждому клиенту одним запросом. func (s *Store) LastSizes() (map[int64]int64, error) { rows, err := s.db.Query(`SELECT client_id, total_size FROM backups b WHERE status = ?1 AND id = ( SELECT id FROM backups WHERE client_id = b.client_id AND status = ?1 ORDER BY created_at DESC, id DESC LIMIT 1)`, StatusComplete) if err != nil { return nil, err } defer rows.Close() out := map[int64]int64{} for rows.Next() { var id, size int64 if err := rows.Scan(&id, &size); err != nil { return nil, err } out[id] = size } return out, rows.Err() } // LatestComplete возвращает последний успешный бэкап клиента. func (s *Store) LatestComplete(clientID int64) (*Backup, error) { var id int64 err := s.db.QueryRow(`SELECT id FROM backups WHERE client_id = ? AND status = ? ORDER BY created_at DESC, id DESC LIMIT 1`, clientID, StatusComplete).Scan(&id) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return s.GetBackup(id) } // UploadingBefore возвращает незавершённые загрузки старше ts (по всем клиентам или одному). func (s *Store) UploadingBefore(ts int64, clientID int64) ([]*Backup, error) { q := `SELECT id, client_id, dir FROM backups WHERE status = ? AND created_at < ?` args := []any{StatusUploading, ts} if clientID != 0 { q += ` AND client_id = ?` args = append(args, clientID) } rows, err := s.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var out []*Backup for rows.Next() { var b Backup if err := rows.Scan(&b.ID, &b.ClientID, &b.Dir); err != nil { return nil, err } out = append(out, &b) } return out, rows.Err() } func (s *Store) UpsertPart(p Part) error { _, err := s.db.Exec(`INSERT INTO parts (backup_id, name, size, sha256, status) VALUES (?, ?, ?, ?, ?) ON CONFLICT (backup_id, name) DO UPDATE SET size = excluded.size, sha256 = excluded.sha256, status = excluded.status`, p.BackupID, p.Name, p.Size, p.SHA256, p.Status) return err } func (s *Store) SetPartStatus(backupID int64, name, status string) error { _, err := s.db.Exec(`UPDATE parts SET status = ? WHERE backup_id = ? AND name = ?`, status, backupID, name) return err } func (s *Store) RecalcTotal(backupID int64) error { _, err := s.db.Exec(`UPDATE backups SET total_size = (SELECT COALESCE(SUM(size), 0) FROM parts WHERE backup_id = ?1 AND status = ?2) WHERE id = ?1`, backupID, PartComplete) return err } func (s *Store) CreateRestoreToken(hash string, clientID int64, ttl time.Duration) error { now := time.Now() _, err := s.db.Exec(`INSERT INTO restore_tokens (token_hash, client_id, created_at, expires_at) VALUES (?, ?, ?, ?)`, hash, clientID, now.Unix(), now.Add(ttl).Unix()) return err } func (s *Store) RestoreTokenClient(hash string) (int64, error) { var id int64 err := s.db.QueryRow(`SELECT client_id FROM restore_tokens WHERE token_hash = ? AND expires_at > ?`, hash, time.Now().Unix()).Scan(&id) if errors.Is(err, sql.ErrNoRows) { return 0, ErrNotFound } return id, err } func (s *Store) CreateSession(token, csrf string, ttl time.Duration) error { _, err := s.db.Exec(`INSERT INTO sessions (token, csrf, expires_at) VALUES (?, ?, ?)`, token, csrf, time.Now().Add(ttl).Unix()) return err } func (s *Store) SessionCSRF(token string) (string, error) { var csrf string err := s.db.QueryRow(`SELECT csrf FROM sessions WHERE token = ? AND expires_at > ?`, token, time.Now().Unix()).Scan(&csrf) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } return csrf, err } func (s *Store) DeleteSession(token string) error { _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) return err } func (s *Store) DeleteAllSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`) return err } func (s *Store) Cleanup() error { now := time.Now().Unix() if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now); err != nil { return err } _, err := s.db.Exec(`DELETE FROM restore_tokens WHERE expires_at <= ?`, now) return err } func (s *Store) Setting(key string) string { var v string _ = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v) return v } func (s *Store) SetSetting(key, value string) error { _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value) return err }