package store import ( "context" "database/sql" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" _ "github.com/mattn/go-sqlite3" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" ) type Store struct { db *sql.DB } type ManagedTarget struct { Target model.Target Zone model.Zone Credential model.Credential } type AccessSelection struct { ZoneIDs []int64 TargetIDs []int64 ManageZoneIDs []int64 ManageTargetIDs []int64 CreateTargetZoneIDs []int64 } func Open(ctx context.Context, path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return nil, fmt.Errorf("create data directory: %w", err) } db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on") if err != nil { return nil, fmt.Errorf("open database: %w", err) } db.SetMaxOpenConns(1) if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, fmt.Errorf("ping database: %w", err) } store := &Store{db: db} if err := store.migrate(ctx); err != nil { _ = db.Close() return nil, err } return store, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil { return err } var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 1`).Scan(&count); err != nil { return err } if count == 0 { if _, err := tx.ExecContext(ctx, schemaV1); err != nil { return fmt.Errorf("apply schema v1: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)`, now()); err != nil { return err } } if err := s.applyMigrationV2(ctx, tx); err != nil { return err } if err := s.applyMigrationV3(ctx, tx); err != nil { return err } if err := s.applyMigrationV4(ctx, tx); err != nil { return err } if err := s.applyMigrationV5(ctx, tx); err != nil { return err } if err := s.applyMigrationV6(ctx, tx); err != nil { return err } if err := s.applyMigrationV7(ctx, tx); err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`, 300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil { return err } return tx.Commit() } func (s *Store) applyMigrationV4(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 4`).Scan(&count); err != nil { return err } if count != 0 { return nil } if _, err := tx.ExecContext(ctx, schemaV4); err != nil { return fmt.Errorf("apply schema v4: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(4, ?)`, now()); err != nil { return err } return nil } func (s *Store) applyMigrationV5(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 5`).Scan(&count); err != nil { return err } if count != 0 { return nil } if _, err := tx.ExecContext(ctx, schemaV5); err != nil { return fmt.Errorf("apply schema v5: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(5, ?)`, now()); err != nil { return err } return nil } func (s *Store) applyMigrationV6(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 6`).Scan(&count); err != nil { return err } if count != 0 { return nil } if _, err := tx.ExecContext(ctx, schemaV6); err != nil { return fmt.Errorf("apply schema v6: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(6, ?)`, now()); err != nil { return err } return nil } func (s *Store) applyMigrationV7(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 7`).Scan(&count); err != nil { return err } if count != 0 { return nil } if _, err := tx.ExecContext(ctx, schemaV7); err != nil { return fmt.Errorf("apply schema v7: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(7, ?)`, now()); err != nil { return err } return nil } func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil { return err } if count != 0 { return nil } if _, err := tx.ExecContext(ctx, schemaV3); err != nil { return fmt.Errorf("apply schema v3: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(3, ?)`, now()); err != nil { return err } return nil } func (s *Store) applyMigrationV2(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 2`).Scan(&count); err != nil { return err } if count != 0 { return nil } if _, err := tx.ExecContext(ctx, schemaV2); err != nil { return fmt.Errorf("apply schema v2: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO users(id, username, display_name, password_hash, role, active, created_at, updated_at) SELECT 1, 'admin', 'Administrator', password_hash, 'admin', 1, created_at, updated_at FROM admins WHERE id = 1`); err != nil { return fmt.Errorf("migrate legacy admin: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)`, now()); err != nil { return err } return nil } const schemaV1 = ` CREATE TABLE admins ( id INTEGER PRIMARY KEY CHECK (id = 1), password_hash TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, ciphertext BLOB NOT NULL, nonce BLOB NOT NULL, fingerprint TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL ); CREATE TABLE zones ( id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE RESTRICT, provider_id TEXT NOT NULL, name TEXT NOT NULL COLLATE NOCASE UNIQUE, created_at INTEGER NOT NULL, last_sync_at INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '' ); CREATE TABLE targets ( id INTEGER PRIMARY KEY AUTOINCREMENT, zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, record_name TEXT NOT NULL, fqdn TEXT NOT NULL, update_ipv4 INTEGER NOT NULL DEFAULT 1 CHECK (update_ipv4 IN (0,1)), update_ipv6 INTEGER NOT NULL DEFAULT 0 CHECK (update_ipv6 IN (0,1)), active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0,1)), description TEXT NOT NULL DEFAULT '', ttl INTEGER NOT NULL DEFAULT 60, last_updated_at INTEGER NOT NULL DEFAULT 0, last_status TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, UNIQUE(zone_id, record_name) ); CREATE TABLE settings ( id INTEGER PRIMARY KEY CHECK (id = 1), update_interval_seconds INTEGER NOT NULL, ipv4_endpoints TEXT NOT NULL, ipv6_endpoints TEXT NOT NULL, last_ipv4 TEXT NOT NULL DEFAULT '', last_ipv6 TEXT NOT NULL DEFAULT '', last_run_at INTEGER NOT NULL DEFAULT 0, last_run_status TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL ); CREATE TABLE sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT NOT NULL UNIQUE, csrf_token TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, reauth_until INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX sessions_expires_at_idx ON sessions(expires_at); CREATE TABLE update_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at INTEGER NOT NULL, finished_at INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, ipv4 TEXT NOT NULL DEFAULT '', ipv6 TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '' ); CREATE INDEX update_runs_started_at_idx ON update_runs(started_at DESC); CREATE TABLE audit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at INTEGER NOT NULL, kind TEXT NOT NULL, message TEXT NOT NULL ); CREATE INDEX audit_events_created_at_idx ON audit_events(created_at DESC); ` const schemaV2 = ` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL COLLATE NOCASE UNIQUE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'member')), active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE access_grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, target_id INTEGER REFERENCES targets(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, UNIQUE(user_id, zone_id, target_id) ); CREATE INDEX access_grants_user_zone_idx ON access_grants(user_id, zone_id); ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1; CREATE INDEX sessions_user_id_idx ON sessions(user_id); ` const schemaV3 = ` ALTER TABLE targets ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0; CREATE INDEX targets_zone_archived_idx ON targets(zone_id, archived_at); ` const schemaV4 = ` ALTER TABLE settings ADD COLUMN updater_enabled INTEGER NOT NULL DEFAULT 1 CHECK (updater_enabled IN (0,1)); ALTER TABLE settings ADD COLUMN ipv6_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ipv6_enabled IN (0,1)); ALTER TABLE access_grants ADD COLUMN can_manage_targets INTEGER NOT NULL DEFAULT 1 CHECK (can_manage_targets IN (0,1)); ALTER TABLE access_grants ADD COLUMN can_create_targets INTEGER NOT NULL DEFAULT 0 CHECK (can_create_targets IN (0,1)); ` const schemaV5 = ` CREATE TABLE cname_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, record_name TEXT NOT NULL, fqdn TEXT NOT NULL, destination TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', ttl INTEGER NOT NULL DEFAULT 60, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(zone_id, record_name) ); CREATE INDEX cname_records_zone_idx ON cname_records(zone_id, record_name); ` const schemaV6 = ` ALTER TABLE targets ADD COLUMN static_ipv4 TEXT NOT NULL DEFAULT ''; ALTER TABLE targets ADD COLUMN static_ipv6 TEXT NOT NULL DEFAULT ''; ` const schemaV7 = ` ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)); ` func (s *Store) HasAdmin(ctx context.Context) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count) return count > 0, err } func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error { _, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now()) return err } func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) { var hash string err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id = ? AND active = 1`, userID).Scan(&hash) return hash, err } func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash string) error { result, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID) if err != nil { return err } if affected, _ := result.RowsAffected(); affected != 1 { return sql.ErrNoRows } return nil } func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) { return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id)) } func (s *Store) GetUserByUsername(ctx context.Context, username string) (model.User, string, error) { var user model.User var passwordHash string var active int var createdAt, updatedAt, lastLoginAt int64 err := s.db.QueryRowContext(ctx, `SELECT id, username, display_name, password_hash, role, active, created_at, updated_at, last_login_at FROM users WHERE username = ?`, username).Scan( &user.ID, &user.Username, &user.DisplayName, &passwordHash, &user.Role, &active, &createdAt, &updatedAt, &lastLoginAt, ) if err != nil { return user, "", err } user.Active = intBool(active) user.CreatedAt, user.UpdatedAt, user.LastLoginAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastLoginAt) return user, passwordHash, nil } func (s *Store) ListUsers(ctx context.Context) ([]model.User, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, username COLLATE NOCASE`) if err != nil { return nil, err } defer rows.Close() var users []model.User for rows.Next() { user, err := scanUser(rows) if err != nil { return nil, err } users = append(users, user) } return users, rows.Err() } func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (model.User, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?)`, username, displayName, passwordHash, role, now(), now()) if err != nil { return model.User{}, err } id, err := result.LastInsertId() if err != nil { return model.User{}, err } return s.GetUser(ctx, id) } func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error { result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error { return s.UpdatePassword(ctx, userID, passwordHash) } func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error { _, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, now(), userID) return err } func (s *Store) CountActiveAdmins(ctx context.Context) (int, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count) return count, err } func (s *Store) ListAccessGrants(ctx context.Context, userID int64) ([]model.AccessGrant, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, user_id, zone_id, target_id, can_manage_targets, can_create_targets FROM access_grants WHERE user_id = ? ORDER BY zone_id, target_id`, userID) if err != nil { return nil, err } defer rows.Close() var grants []model.AccessGrant for rows.Next() { var grant model.AccessGrant var targetID sql.NullInt64 var canManage, canCreate int if err := rows.Scan(&grant.ID, &grant.UserID, &grant.ZoneID, &targetID, &canManage, &canCreate); err != nil { return nil, err } grant.CanManageTargets, grant.CanCreateTargets = intBool(canManage), intBool(canCreate) if targetID.Valid { value := targetID.Int64 grant.TargetID = &value } grants = append(grants, grant) } return grants, rows.Err() } // ReplaceAccessGrants atomically applies domain-wide and individual target grants. func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection AccessSelection) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil { return err } managedZones := idSet(selection.ManageZoneIDs) createZones := idSet(selection.CreateTargetZoneIDs) for _, zoneID := range uniqueIDs(selection.ZoneIDs) { canManage := managedZones[zoneID] canCreate := canManage && createZones[zoneID] if _, err := tx.ExecContext(ctx, `INSERT INTO access_grants(user_id, zone_id, target_id, can_manage_targets, can_create_targets, created_at) VALUES(?, ?, NULL, ?, ?, ?)`, userID, zoneID, boolInt(canManage), boolInt(canCreate), now()); err != nil { return err } } managedTargets := idSet(selection.ManageTargetIDs) for _, targetID := range uniqueIDs(selection.TargetIDs) { var zoneID int64 if err := tx.QueryRowContext(ctx, `SELECT zone_id FROM targets WHERE id = ?`, targetID).Scan(&zoneID); err != nil { return fmt.Errorf("invalid target access grant: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO access_grants(user_id, zone_id, target_id, can_manage_targets, can_create_targets, created_at) VALUES(?, ?, ?, ?, 0, ?)`, userID, zoneID, targetID, boolInt(managedTargets[targetID]), now()); err != nil { return err } } return tx.Commit() } func (s *Store) CanAccessZone(ctx context.Context, userID, zoneID int64) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ?`, userID, zoneID).Scan(&count) return count > 0, err } func (s *Store) CanAccessTarget(ctx context.Context, userID, zoneID, targetID int64) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ? AND (target_id IS NULL OR target_id = ?)`, userID, zoneID, targetID).Scan(&count) return count > 0, err } func (s *Store) CanManageTarget(ctx context.Context, userID, zoneID, targetID int64) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ? AND (target_id IS NULL OR target_id = ?) AND can_manage_targets = 1`, userID, zoneID, targetID).Scan(&count) return count > 0, err } func (s *Store) CanCreateTarget(ctx context.Context, userID, zoneID int64) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ? AND target_id IS NULL AND can_manage_targets = 1 AND can_create_targets = 1`, userID, zoneID).Scan(&count) return count > 0, err } func (s *Store) ListAccessibleZones(ctx context.Context, userID int64) ([]model.Zone, error) { rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT z.id, z.credential_id, z.provider_id, z.name, z.created_at, z.last_sync_at, z.last_error FROM zones z JOIN access_grants a ON a.zone_id = z.id WHERE a.user_id = ? ORDER BY z.name COLLATE NOCASE`, userID) if err != nil { return nil, err } defer rows.Close() var zones []model.Zone for rows.Next() { zone, err := scanZone(rows) if err != nil { return nil, err } zones = append(zones, zone) } return zones, rows.Err() } func (s *Store) ListAccessibleTargets(ctx context.Context, userID, zoneID int64) ([]model.Target, error) { rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS ( SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id) ) ORDER BY CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID) if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func (s *Store) CountActiveAccessibleTargets(ctx context.Context, userID int64) (int, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM targets t WHERE t.active = 1 AND t.archived_at = 0 AND EXISTS ( SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id) )`, userID).Scan(&count) return count, err } func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) { var settings model.Settings var ipv4JSON, ipv6JSON string var updaterEnabled, ipv6Enabled int var lastRunAt, updatedAt int64 err := s.db.QueryRowContext(ctx, `SELECT update_interval_seconds, updater_enabled, ipv6_enabled, ipv4_endpoints, ipv6_endpoints, last_ipv4, last_ipv6, last_run_at, last_run_status, updated_at FROM settings WHERE id = 1`).Scan( &settings.UpdateIntervalSeconds, &updaterEnabled, &ipv6Enabled, &ipv4JSON, &ipv6JSON, &settings.LastIPv4, &settings.LastIPv6, &lastRunAt, &settings.LastRunStatus, &updatedAt, ) if err != nil { return settings, err } if err := json.Unmarshal([]byte(ipv4JSON), &settings.IPv4Endpoints); err != nil { return settings, fmt.Errorf("decode IPv4 endpoints: %w", err) } if err := json.Unmarshal([]byte(ipv6JSON), &settings.IPv6Endpoints); err != nil { return settings, fmt.Errorf("decode IPv6 endpoints: %w", err) } settings.LastRunAt = fromUnix(lastRunAt) settings.UpdatedAt = fromUnix(updatedAt) settings.UpdaterEnabled, settings.IPv6Enabled = intBool(updaterEnabled), intBool(ipv6Enabled) return settings, nil } func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled, ipv6Enabled bool, ipv4, ipv6 []string) error { _, err := s.db.ExecContext(ctx, `UPDATE settings SET update_interval_seconds = ?, updater_enabled = ?, ipv6_enabled = ?, ipv4_endpoints = ?, ipv6_endpoints = ?, updated_at = ? WHERE id = 1`, interval, boolInt(updaterEnabled), boolInt(ipv6Enabled), mustJSON(ipv4), mustJSON(ipv6), now()) return err } func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error { _, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status) return err } func (s *Store) FindCredentialByFingerprint(ctx context.Context, fingerprint string) (*model.Credential, error) { credential, err := scanCredential(s.db.QueryRowContext(ctx, `SELECT id, label, ciphertext, nonce, fingerprint, created_at FROM credentials WHERE fingerprint = ?`, fingerprint)) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return credential, err } func (s *Store) CreateCredential(ctx context.Context, label string, ciphertext, nonce []byte, fingerprint string) (model.Credential, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO credentials(label, ciphertext, nonce, fingerprint, created_at) VALUES(?, ?, ?, ?, ?)`, label, ciphertext, nonce, fingerprint, now()) if err != nil { return model.Credential{}, err } id, err := result.LastInsertId() if err != nil { return model.Credential{}, err } return s.GetCredential(ctx, id) } func (s *Store) GetCredential(ctx context.Context, id int64) (model.Credential, error) { credential, err := scanCredential(s.db.QueryRowContext(ctx, `SELECT id, label, ciphertext, nonce, fingerprint, created_at FROM credentials WHERE id = ?`, id)) if err != nil { return model.Credential{}, err } return *credential, nil } func (s *Store) CreateZone(ctx context.Context, credentialID int64, providerID, name string) (model.Zone, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO zones(credential_id, provider_id, name, created_at) VALUES(?, ?, ?, ?)`, credentialID, providerID, strings.ToLower(name), now()) if err != nil { return model.Zone{}, err } id, err := result.LastInsertId() if err != nil { return model.Zone{}, err } return s.GetZone(ctx, id) } func (s *Store) GetZone(ctx context.Context, id int64) (model.Zone, error) { return scanZone(s.db.QueryRowContext(ctx, `SELECT id, credential_id, provider_id, name, created_at, last_sync_at, last_error FROM zones WHERE id = ?`, id)) } func (s *Store) GetZoneWithCredential(ctx context.Context, id int64) (model.Zone, model.Credential, error) { row := s.db.QueryRowContext(ctx, `SELECT z.id, z.credential_id, z.provider_id, z.name, z.created_at, z.last_sync_at, z.last_error, c.id, c.label, c.ciphertext, c.nonce, c.fingerprint, c.created_at FROM zones z JOIN credentials c ON c.id = z.credential_id WHERE z.id = ?`, id) var zone model.Zone var credential model.Credential var zoneCreatedAt, zoneLastSyncAt, credentialCreatedAt int64 err := row.Scan(&zone.ID, &zone.CredentialID, &zone.ProviderID, &zone.Name, &zoneCreatedAt, &zoneLastSyncAt, &zone.LastError, &credential.ID, &credential.Label, &credential.Ciphertext, &credential.Nonce, &credential.Fingerprint, &credentialCreatedAt) if err != nil { return zone, credential, err } zone.CreatedAt, zone.LastSyncAt, credential.CreatedAt = fromUnix(zoneCreatedAt), fromUnix(zoneLastSyncAt), fromUnix(credentialCreatedAt) return zone, credential, nil } func (s *Store) ListZones(ctx context.Context) ([]model.Zone, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, credential_id, provider_id, name, created_at, last_sync_at, last_error FROM zones ORDER BY name COLLATE NOCASE`) if err != nil { return nil, err } defer rows.Close() var zones []model.Zone for rows.Next() { zone, scanErr := scanZone(rows) if scanErr != nil { return nil, scanErr } zones = append(zones, zone) } return zones, rows.Err() } func (s *Store) DeleteZone(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM zones WHERE id = ?`, id) return err } func (s *Store) MarkZoneSynced(ctx context.Context, id int64, lastError string) error { _, err := s.db.ExecContext(ctx, `UPDATE zones SET last_sync_at = ?, last_error = ? WHERE id = ?`, now(), lastError, id) return err } func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID) if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func (s *Store) ListArchivedTargets(ctx context.Context, zoneID int64) ([]model.Target, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at > 0 ORDER BY archived_at DESC, record_name`, zoneID) if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func (s *Store) GetTarget(ctx context.Context, id int64) (model.Target, error) { return scanTarget(s.db.QueryRowContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE id = ?`, id)) } func (s *Store) CreateTarget(ctx context.Context, target model.Target) (model.Target, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO targets(zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, target.ZoneID, target.RecordName, target.FQDN, boolInt(target.UpdateIPv4), boolInt(target.UpdateIPv6), boolInt(target.Active), target.StaticIPv4, target.StaticIPv6, target.Description, target.TTL, now()) if err != nil { return model.Target{}, err } id, err := result.LastInsertId() if err != nil { return model.Target{}, err } return s.GetTarget(ctx, id) } // UpsertImportedTarget adds a safe, inactive target only when the host was not already known. func (s *Store) UpsertImportedTarget(ctx context.Context, target model.Target) (bool, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO targets(zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, description, ttl, created_at) VALUES(?, ?, ?, ?, ?, 0, ?, ?, ?) ON CONFLICT(zone_id, record_name) DO NOTHING`, target.ZoneID, target.RecordName, target.FQDN, boolInt(target.UpdateIPv4), boolInt(target.UpdateIPv6), target.Description, target.TTL, now()) if err != nil { return false, err } count, err := result.RowsAffected() return count == 1, err } // UpdateImportedTargetTTL refreshes the provider-side TTL without changing a // target's local activation, address-family selection, or static addresses. func (s *Store) UpdateImportedTargetTTL(ctx context.Context, zoneID int64, recordName string, ttl int) error { _, err := s.db.ExecContext(ctx, `UPDATE targets SET ttl = ? WHERE zone_id = ? AND record_name = ?`, ttl, zoneID, recordName) return err } func (s *Store) UpdateZoneTargetTTLs(ctx context.Context, zoneID int64, ttl int) error { _, err := s.db.ExecContext(ctx, `UPDATE targets SET ttl = ? WHERE zone_id = ? AND archived_at = 0`, ttl, zoneID) return err } func (s *Store) UpdateTarget(ctx context.Context, target model.Target) error { result, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv4 = ?, update_ipv6 = ?, active = ?, static_ipv4 = ?, static_ipv6 = ?, description = ?, ttl = ? WHERE id = ? AND archived_at = 0`, boolInt(target.UpdateIPv4), boolInt(target.UpdateIPv6), boolInt(target.Active), target.StaticIPv4, target.StaticIPv6, target.Description, target.TTL, target.ID) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } func (s *Store) UpdateTargetResult(ctx context.Context, id int64, status, lastError string, updated bool) error { updatedAt := int64(0) if updated { updatedAt = now() } _, err := s.db.ExecContext(ctx, `UPDATE targets SET last_status = ?, last_error = ?, last_updated_at = CASE WHEN ? > 0 THEN ? ELSE last_updated_at END WHERE id = ? AND archived_at = 0`, status, lastError, updatedAt, updatedAt, id) return err } func (s *Store) DeleteTarget(ctx context.Context, id int64) error { result, err := s.db.ExecContext(ctx, `DELETE FROM targets WHERE id = ?`, id) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE update_ipv6 = 1 AND archived_at = 0 ORDER BY zone_id, id`) if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func (s *Store) DisableIPv6ForTargets(ctx context.Context) error { _, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`) return err } func (s *Store) ArchiveTarget(ctx context.Context, id int64) error { result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = ?, last_status = 'Getrennt', last_error = '' WHERE id = ? AND archived_at = 0`, now(), id) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } func (s *Store) RestoreTarget(ctx context.Context, id int64) error { result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = 0, last_status = 'Wieder verknüpft', last_error = '' WHERE id = ? AND archived_at > 0`, id) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID) if err != nil { return nil, err } defer rows.Close() return scanCNAMERecords(rows) } func (s *Store) GetCNAMERecord(ctx context.Context, id int64) (model.CNAMERecord, error) { return scanCNAMERecord(s.db.QueryRowContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE id = ?`, id)) } func (s *Store) CreateCNAMERecord(ctx context.Context, record model.CNAMERecord) (model.CNAMERecord, error) { timestamp := now() result, err := s.db.ExecContext(ctx, `INSERT INTO cname_records(zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, record.ZoneID, boolInt(record.Active), record.RecordName, record.FQDN, record.Destination, record.Description, record.TTL, timestamp, timestamp) if err != nil { return model.CNAMERecord{}, err } id, err := result.LastInsertId() if err != nil { return model.CNAMERecord{}, err } return s.GetCNAMERecord(ctx, id) } func (s *Store) UpdateCNAMERecord(ctx context.Context, record model.CNAMERecord) error { result, err := s.db.ExecContext(ctx, `UPDATE cname_records SET active = ?, destination = ?, description = ?, ttl = ?, updated_at = ? WHERE id = ?`, boolInt(record.Active), record.Destination, record.Description, record.TTL, now(), record.ID) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } func (s *Store) UpdateZoneCNAMERecordTTLs(ctx context.Context, zoneID int64, ttl int) error { _, err := s.db.ExecContext(ctx, `UPDATE cname_records SET ttl = ?, updated_at = ? WHERE zone_id = ?`, ttl, now(), zoneID) return err } func (s *Store) DeleteCNAMERecord(ctx context.Context, id int64) error { result, err := s.db.ExecContext(ctx, `DELETE FROM cname_records WHERE id = ?`, id) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } return nil } // ReplaceCNAMERecords mirrors the provider's active CNAME RRSets while keeping // locally disabled records in the UI. Missing provider records become inactive // instead of disappearing; only the explicit remove action deletes them. func (s *Store) ReplaceCNAMERecords(ctx context.Context, zoneID int64, records []model.CNAMERecord) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() timestamp := now() if _, err := tx.ExecContext(ctx, `UPDATE cname_records SET active = 0, updated_at = ? WHERE zone_id = ?`, timestamp, zoneID); err != nil { return err } for _, record := range records { if _, err := tx.ExecContext(ctx, `INSERT INTO cname_records(zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at) VALUES(?, 1, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(zone_id, record_name) DO UPDATE SET active = 1, fqdn = excluded.fqdn, destination = excluded.destination, description = excluded.description, ttl = excluded.ttl, updated_at = excluded.updated_at`, zoneID, record.RecordName, record.FQDN, record.Destination, record.Description, record.TTL, timestamp, timestamp); err != nil { return err } } return tx.Commit() } func (s *Store) ListManagedTargets(ctx context.Context) ([]ManagedTarget, error) { rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, z.id, z.credential_id, z.provider_id, z.name, z.created_at, z.last_sync_at, z.last_error, c.id, c.label, c.ciphertext, c.nonce, c.fingerprint, c.created_at FROM targets t JOIN zones z ON z.id = t.zone_id JOIN credentials c ON c.id = z.credential_id WHERE t.active = 1 AND t.archived_at = 0 ORDER BY z.id, t.id`) if err != nil { return nil, err } defer rows.Close() var result []ManagedTarget for rows.Next() { var item ManagedTarget var targetLastUpdated, targetCreated, zoneCreated, zoneSynced, credentialCreated int64 var updateIPv4, updateIPv6, active int err := rows.Scan( &item.Target.ID, &item.Target.ZoneID, &item.Target.RecordName, &item.Target.FQDN, &updateIPv4, &updateIPv6, &active, &item.Target.StaticIPv4, &item.Target.StaticIPv6, &item.Target.Description, &item.Target.TTL, &targetLastUpdated, &item.Target.LastStatus, &item.Target.LastError, &targetCreated, &item.Zone.ID, &item.Zone.CredentialID, &item.Zone.ProviderID, &item.Zone.Name, &zoneCreated, &zoneSynced, &item.Zone.LastError, &item.Credential.ID, &item.Credential.Label, &item.Credential.Ciphertext, &item.Credential.Nonce, &item.Credential.Fingerprint, &credentialCreated, ) if err != nil { return nil, err } item.Target.UpdateIPv4, item.Target.UpdateIPv6, item.Target.Active = intBool(updateIPv4), intBool(updateIPv6), intBool(active) item.Target.LastUpdatedAt, item.Target.CreatedAt = fromUnix(targetLastUpdated), fromUnix(targetCreated) item.Zone.CreatedAt, item.Zone.LastSyncAt = fromUnix(zoneCreated), fromUnix(zoneSynced) item.Credential.CreatedAt = fromUnix(credentialCreated) result = append(result, item) } return result, rows.Err() } func (s *Store) CountActiveTargets(ctx context.Context) (int, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM targets WHERE active = 1 AND archived_at = 0`).Scan(&count) return count, err } func (s *Store) CreateUpdateRun(ctx context.Context) (int64, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO update_runs(started_at, status) VALUES(?, 'running')`, now()) if err != nil { return 0, err } return result.LastInsertId() } func (s *Store) FinishUpdateRun(ctx context.Context, id int64, status, ipv4, ipv6, summary string) error { _, err := s.db.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = ?, ipv4 = ?, ipv6 = ?, summary = ? WHERE id = ?`, now(), status, ipv4, ipv6, summary, id) return err } func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var runs []model.UpdateRun for rows.Next() { var run model.UpdateRun var started, finished int64 if err := rows.Scan(&run.ID, &started, &finished, &run.Status, &run.IPv4, &run.IPv6, &run.Summary); err != nil { return nil, err } run.StartedAt, run.FinishedAt = fromUnix(started), fromUnix(finished) runs = append(runs, run) } return runs, rows.Err() } func (s *Store) FailedRuns(ctx context.Context) ([]model.UpdateRun, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs WHERE status IN ('error', 'partial') ORDER BY id DESC`) if err != nil { return nil, err } defer rows.Close() var runs []model.UpdateRun for rows.Next() { var run model.UpdateRun var started, finished int64 if err := rows.Scan(&run.ID, &started, &finished, &run.Status, &run.IPv4, &run.IPv6, &run.Summary); err != nil { return nil, err } run.StartedAt, run.FinishedAt = fromUnix(started), fromUnix(finished) runs = append(runs, run) } return runs, rows.Err() } func (s *Store) ListTargetsWithErrors(ctx context.Context) ([]model.Target, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE last_error != '' ORDER BY id DESC`) if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func (s *Store) ListZonesWithErrors(ctx context.Context) ([]model.Zone, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, credential_id, provider_id, name, created_at, last_sync_at, last_error FROM zones WHERE last_error != '' ORDER BY name COLLATE NOCASE`) if err != nil { return nil, err } defer rows.Close() var zones []model.Zone for rows.Next() { zone, err := scanZone(rows) if err != nil { return nil, err } zones = append(zones, zone) } return zones, rows.Err() } func (s *Store) CountErrors(ctx context.Context) (int, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM targets WHERE last_error != '') + (SELECT COUNT(*) FROM zones WHERE last_error != '') + (SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count) return count, err } func (s *Store) ClearErrors(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `UPDATE targets SET last_error = '', last_status = CASE WHEN last_status = 'Fehler' THEN 'Zurückgesetzt' ELSE last_status END WHERE last_error != ''`); err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM audit_events WHERE kind IN ('update.error', 'update.partial')`); err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_run_status = '' WHERE last_run_status IN ('error', 'partial')`); err != nil { return err } return tx.Commit() } func (s *Store) AddAudit(ctx context.Context, kind, message string) error { _, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message) return err } func (s *Store) RecentAudit(ctx context.Context, limit int) ([]model.AuditEvent, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, created_at, kind, message FROM audit_events ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var events []model.AuditEvent for rows.Next() { var event model.AuditEvent var createdAt int64 if err := rows.Scan(&event.ID, &createdAt, &event.Kind, &event.Message); err != nil { return nil, err } event.CreatedAt = fromUnix(createdAt) events = append(events, event) } return events, rows.Err() } func (s *Store) CreateSession(ctx context.Context, session model.Session) (model.Session, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO sessions(user_id, token_hash, csrf_token, created_at, expires_at, reauth_until) VALUES(?, ?, ?, ?, ?, ?)`, session.UserID, session.TokenHash, session.CSRFToken, now(), unix(session.ExpiresAt), unix(session.ReauthUntil)) if err != nil { return session, err } id, err := result.LastInsertId() if err != nil { return session, err } session.ID = id session.CreatedAt = time.Now().UTC() return session, nil } func (s *Store) GetSession(ctx context.Context, tokenHash string) (*model.Session, error) { var session model.Session var createdAt, expiresAt, reauthUntil int64 err := s.db.QueryRowContext(ctx, `SELECT s.id, s.user_id, s.token_hash, s.csrf_token, s.created_at, s.expires_at, s.reauth_until, u.username, u.display_name, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`, tokenHash, now()).Scan( &session.ID, &session.UserID, &session.TokenHash, &session.CSRFToken, &createdAt, &expiresAt, &reauthUntil, &session.Username, &session.DisplayName, &session.Role, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } session.CreatedAt, session.ExpiresAt, session.ReauthUntil = fromUnix(createdAt), fromUnix(expiresAt), fromUnix(reauthUntil) return &session, nil } func (s *Store) SetSessionReauth(ctx context.Context, id int64, until time.Time) error { _, err := s.db.ExecContext(ctx, `UPDATE sessions SET reauth_until = ? WHERE id = ?`, unix(until), id) return 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 } func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepID int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepID) return err } func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) return err } func (s *Store) Prune(ctx context.Context) error { cutoff := time.Now().UTC().Add(-90 * 24 * time.Hour).Unix() if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, now()); err != nil { return err } if _, err := s.db.ExecContext(ctx, `DELETE FROM update_runs WHERE started_at < ?`, cutoff); err != nil { return err } _, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE created_at < ?`, cutoff) return err } func scanCredential(scanner interface{ Scan(...any) error }) (*model.Credential, error) { var credential model.Credential var createdAt int64 if err := scanner.Scan(&credential.ID, &credential.Label, &credential.Ciphertext, &credential.Nonce, &credential.Fingerprint, &createdAt); err != nil { return nil, err } credential.CreatedAt = fromUnix(createdAt) return &credential, nil } func scanUser(scanner interface{ Scan(...any) error }) (model.User, error) { var user model.User var active int var createdAt, updatedAt, lastLoginAt int64 err := scanner.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &active, &createdAt, &updatedAt, &lastLoginAt) user.Active = intBool(active) user.CreatedAt, user.UpdatedAt, user.LastLoginAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastLoginAt) return user, err } func scanZone(scanner interface{ Scan(...any) error }) (model.Zone, error) { var zone model.Zone var createdAt, lastSyncAt int64 err := scanner.Scan(&zone.ID, &zone.CredentialID, &zone.ProviderID, &zone.Name, &createdAt, &lastSyncAt, &zone.LastError) zone.CreatedAt, zone.LastSyncAt = fromUnix(createdAt), fromUnix(lastSyncAt) return zone, err } func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) { var target model.Target var updateIPv4, updateIPv6, active int var lastUpdatedAt, createdAt, archivedAt int64 err := scanner.Scan(&target.ID, &target.ZoneID, &target.RecordName, &target.FQDN, &updateIPv4, &updateIPv6, &active, &target.StaticIPv4, &target.StaticIPv6, &target.Description, &target.TTL, &lastUpdatedAt, &target.LastStatus, &target.LastError, &createdAt, &archivedAt) target.UpdateIPv4, target.UpdateIPv6, target.Active = intBool(updateIPv4), intBool(updateIPv6), intBool(active) target.LastUpdatedAt, target.CreatedAt, target.ArchivedAt = fromUnix(lastUpdatedAt), fromUnix(createdAt), fromUnix(archivedAt) return target, err } func scanTargets(rows *sql.Rows) ([]model.Target, error) { var targets []model.Target for rows.Next() { target, err := scanTarget(rows) if err != nil { return nil, err } targets = append(targets, target) } return targets, rows.Err() } func scanCNAMERecord(scanner interface{ Scan(...any) error }) (model.CNAMERecord, error) { var record model.CNAMERecord var active int var createdAt, updatedAt int64 err := scanner.Scan(&record.ID, &record.ZoneID, &active, &record.RecordName, &record.FQDN, &record.Destination, &record.Description, &record.TTL, &createdAt, &updatedAt) record.Active = intBool(active) record.CreatedAt, record.UpdatedAt = fromUnix(createdAt), fromUnix(updatedAt) return record, err } func scanCNAMERecords(rows *sql.Rows) ([]model.CNAMERecord, error) { var records []model.CNAMERecord for rows.Next() { record, err := scanCNAMERecord(rows) if err != nil { return nil, err } records = append(records, record) } return records, rows.Err() } func now() int64 { return time.Now().UTC().Unix() } func unix(t time.Time) int64 { if t.IsZero() { return 0 } return t.UTC().Unix() } func fromUnix(value int64) time.Time { if value <= 0 { return time.Time{} } return time.Unix(value, 0).UTC() } func boolInt(value bool) int { if value { return 1 } return 0 } func intBool(value int) bool { return value != 0 } func mustJSON(value []string) string { encoded, _ := json.Marshal(value) return string(encoded) } func uniqueIDs(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) result := make([]int64, 0, len(values)) for _, value := range values { if value > 0 { if _, ok := seen[value]; !ok { seen[value] = struct{}{} result = append(result, value) } } } return result } func idSet(values []int64) map[int64]bool { result := make(map[int64]bool, len(values)) for _, value := range values { if value > 0 { result[value] = true } } return result }