Backups are .tar.gz archives of a consistent database snapshot (VACUUM INTO) plus the images folder. They run on a cron schedule, daily at 3:00 by default, and the newest N scheduled ones are kept (7 by default). Manual backups are never pruned. Settings → Backups offers schedule presets or a custom cron expression, retention, "Back up now", and download/delete for each backup. A small built-in parser handles standard 5-field cron syntax with names, ranges, steps and @daily-style shorthands. Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a separate canon-backups volume at /backups, overridable with CANON_BACKUP_PATH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
4.3 KiB
Go
137 lines
4.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// Setting keys.
|
|
const (
|
|
SettingSiteTitle = "site_title"
|
|
SettingOwnerName = "owner_name"
|
|
SettingTagline = "tagline"
|
|
SettingAvatar = "avatar" // cached image file name
|
|
SettingTheme = "theme"
|
|
SettingCustomCSS = "custom_css"
|
|
SettingPreviewCount = "preview_count" // items shown per list on the home page
|
|
SettingShowNotes = "show_notes"
|
|
SettingShowPreviews = "show_previews"
|
|
SettingMusicProvider = "music_provider"
|
|
SettingTVProvider = "tv_provider"
|
|
SettingBackupCron = "backup_cron" // cron expression, or "off"
|
|
SettingBackupKeep = "backup_keep" // how many scheduled backups to keep
|
|
|
|
SettingTMDBKey = "tmdb_api_key"
|
|
SettingTVDBKey = "tvdb_api_key"
|
|
SettingTVDBPin = "tvdb_pin"
|
|
SettingIGDBClientID = "igdb_client_id"
|
|
SettingIGDBClientSecret = "igdb_client_secret"
|
|
)
|
|
|
|
func (s *Store) Settings(ctx context.Context) (map[string]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]string{}
|
|
for rows.Next() {
|
|
var k, v string
|
|
if err := rows.Scan(&k, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
out[k] = v
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SetSettings(ctx context.Context, kv map[string]string) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for k, v := range kv {
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ---- users & sessions ----
|
|
|
|
type User struct {
|
|
ID int64
|
|
Username string
|
|
PasswordHash string
|
|
}
|
|
|
|
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func (s *Store) CreateUser(ctx context.Context, username, hash string) error {
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, hash)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
|
|
var u User
|
|
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return u, ErrNotFound
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
func (s *Store) UserByID(ctx context.Context, id int64) (User, error) {
|
|
var u User
|
|
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE id = ?`, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return u, ErrNotFound
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
func (s *Store) UpdateUser(ctx context.Context, id int64, username, hash string) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ? WHERE id = ?`, username, hash, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expires time.Time) error {
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)`, tokenHash, userID, expires.Unix())
|
|
return err
|
|
}
|
|
|
|
// SessionUser returns the user for a live session.
|
|
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (User, error) {
|
|
var u User
|
|
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.password_hash FROM sessions s JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = ? AND s.expires_at > unixepoch()`, tokenHash).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return u, ErrNotFound
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
|
return err
|
|
}
|
|
|
|
// DeleteUserSessions logs a user out everywhere except the given session.
|
|
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keepTokenHash string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, keepTokenHash)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) PruneSessions(ctx context.Context) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= unixepoch()`)
|
|
return err
|
|
}
|