Files
hetzner-api-dyndns-records-…/internal/store/store.go
T
admin f02ec9977c
Container-Image veröffentlichen / container (push) Has been cancelled
feat: add resilient updates and access controls
2026-08-28 14:02:07 +02:00

1770 lines
68 KiB
Go

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
}
var (
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
)
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) {
dataDir := filepath.Dir(path)
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
if err := os.Chmod(dataDir, 0o700); err != nil {
return nil, fmt.Errorf("secure 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)
}
if err := os.Chmod(path, 0o600); err != nil {
_ = db.Close()
return nil, fmt.Errorf("secure database file: %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 := s.applyMigrationV8(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV9(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV10(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV11(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) applyMigrationV8(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 8`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV8); err != nil {
return fmt.Errorf("apply schema v8: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(8, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV9(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 9`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV9); err != nil {
return fmt.Errorf("apply schema v9: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(9, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 10`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV10); err != nil {
return fmt.Errorf("apply schema v10: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(10, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
return fmt.Errorf("apply schema v11: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, 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));
`
const schemaV8 = `
CREATE TABLE pihole_integrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_ciphertext BLOB NOT NULL,
password_nonce BLOB NOT NULL,
insecure_tls INTEGER NOT NULL DEFAULT 0 CHECK (insecure_tls IN (0,1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_sync_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT ''
);
`
const schemaV9 = `
CREATE TABLE pihole_record_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
integration_id INTEGER NOT NULL REFERENCES pihole_integrations(id) ON DELETE CASCADE,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER NOT NULL DEFAULT 0,
UNIQUE(integration_id, value)
);
CREATE INDEX pihole_record_links_integration_archived_idx ON pihole_record_links(integration_id, archived_at);
`
const schemaV10 = `
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
`
const schemaV11 = `
CREATE TABLE software_update_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
registry_username TEXT NOT NULL DEFAULT '',
token_ciphertext BLOB NOT NULL DEFAULT X'',
token_nonce BLOB NOT NULL DEFAULT X'',
latest_version TEXT NOT NULL DEFAULT '',
release_url TEXT NOT NULL DEFAULT '',
desired_version TEXT NOT NULL DEFAULT '',
last_checked_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
`
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 {
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
SELECT ?, ?, ?, 'admin', 1, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
if err != nil {
return err
}
created, err := result.RowsAffected()
if err != nil {
return err
}
if created != 1 {
return ErrAdminAlreadyConfigured
}
return nil
}
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) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.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
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
return err
}
return tx.Commit()
}
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 {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var currentRole string
var currentActive int
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(&currentRole, &currentActive); err != nil {
return err
}
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
var activeAdmins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
return err
}
if activeAdmins <= 1 {
return ErrLastActiveAdmin
}
}
result, err := tx.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
}
// Administrators receive full access from their role. Removing stale member
// grants prevents hidden permissions from resurfacing after a later demotion.
if role == "admin" {
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
}
if currentRole != role || intBool(currentActive) != active {
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.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
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
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() }()
var role string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
return err
}
if role == "admin" {
return ErrAdminAccessImmutable
}
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 t.active DESC, 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) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
var settings model.SoftwareUpdateSettings
var checkedAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
)
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
return settings, err
}
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
return err
}
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
return err
}
// RecordSoftwareUpdateFailure intentionally retains the last known release so
// a temporary Gitea outage never removes an already visible update reminder.
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
return err
}
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, 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 = COALESCE(NULLIF(?, ''), last_ipv4),
last_ipv6 = COALESCE(NULLIF(?, ''), 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) CreatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) (model.PiHoleIntegration, error) {
timestamp := now()
result, err := s.db.ExecContext(ctx, `INSERT INTO pihole_integrations(name, base_url, password_ciphertext, password_nonce, insecure_tls, created_at, updated_at)
SELECT ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM pihole_integrations)`,
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), timestamp, timestamp)
if err != nil {
return model.PiHoleIntegration{}, err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return model.PiHoleIntegration{}, ErrPiHoleAlreadyConfigured
}
id, err := result.LastInsertId()
if err != nil {
return model.PiHoleIntegration{}, err
}
return s.GetPiHoleIntegration(ctx, id)
}
func (s *Store) GetPiHoleIntegration(ctx context.Context, id int64) (model.PiHoleIntegration, error) {
return scanPiHoleIntegration(s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE id = ?`, id))
}
func (s *Store) GetPiHoleIntegrationWithCredential(ctx context.Context, id int64) (model.PiHoleIntegration, model.PiHoleCredential, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error, password_ciphertext, password_nonce FROM pihole_integrations WHERE id = ?`, id)
var integration model.PiHoleIntegration
var credential model.PiHoleCredential
var insecureTLS int
var createdAt, updatedAt, lastSyncAt int64
err := row.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError, &credential.Ciphertext, &credential.Nonce)
integration.InsecureTLS = intBool(insecureTLS)
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
return integration, credential, err
}
func (s *Store) ListPiHoleIntegrations(ctx context.Context) ([]model.PiHoleIntegration, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations ORDER BY name COLLATE NOCASE, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var integrations []model.PiHoleIntegration
for rows.Next() {
integration, scanErr := scanPiHoleIntegration(rows)
if scanErr != nil {
return nil, scanErr
}
integrations = append(integrations, integration)
}
return integrations, rows.Err()
}
func (s *Store) UpdatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET name = ?, base_url = ?, password_ciphertext = ?, password_nonce = ?, insecure_tls = ?, updated_at = ?, last_error = '' WHERE id = ?`,
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), now(), integration.ID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) MarkPiHoleSynced(ctx context.Context, id int64, lastError string) error {
_, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET last_sync_at = ?, last_error = ? WHERE id = ?`, now(), lastError, id)
return err
}
func (s *Store) DeletePiHoleIntegration(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_integrations WHERE id = ?`, id)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) SyncPiHoleRecordLinks(ctx context.Context, integrationID int64, remoteValues []string) ([]model.PiHoleRecordLink, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ?`, integrationID)
if err != nil {
return nil, err
}
existing := make(map[string]model.PiHoleRecordLink)
for rows.Next() {
link, scanErr := scanPiHoleRecordLink(rows)
if scanErr != nil {
_ = rows.Close()
return nil, scanErr
}
existing[link.Value] = link
}
if err := rows.Close(); err != nil {
return nil, err
}
remote := make(map[string]bool, len(remoteValues))
for _, value := range remoteValues {
value = strings.TrimSpace(value)
if value == "" || remote[value] {
continue
}
remote[value] = true
if _, found := existing[value]; !found {
timestamp := now()
if _, err := tx.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at) VALUES(?, 1, ?, ?, ?)`, integrationID, value, timestamp, timestamp); err != nil {
return nil, err
}
}
}
for value, link := range existing {
active := remote[value]
if link.Active != active {
if _, err := tx.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), now(), link.ID, integrationID); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.ListPiHoleRecordLinks(ctx, integrationID)
}
func (s *Store) ListPiHoleRecordLinks(ctx context.Context, integrationID int64) ([]model.PiHoleRecordLink, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ? ORDER BY value COLLATE NOCASE, id`, integrationID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []model.PiHoleRecordLink
for rows.Next() {
link, scanErr := scanPiHoleRecordLink(rows)
if scanErr != nil {
return nil, scanErr
}
links = append(links, link)
}
return links, rows.Err()
}
func (s *Store) GetPiHoleRecordLink(ctx context.Context, integrationID, linkID int64) (model.PiHoleRecordLink, error) {
return scanPiHoleRecordLink(s.db.QueryRowContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID))
}
func (s *Store) LinkPiHoleRecord(ctx context.Context, integrationID int64, value string, active bool) error {
timestamp := now()
_, err := s.db.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at, archived_at) VALUES(?, ?, ?, ?, ?, 0)
ON CONFLICT(integration_id, value) DO UPDATE SET active = excluded.active, updated_at = excluded.updated_at, archived_at = 0`, integrationID, boolInt(active), value, timestamp, timestamp)
return err
}
func (s *Store) UpdatePiHoleRecordLink(ctx context.Context, integrationID, linkID int64, value string, active bool) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, value = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), value, now(), linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) ArchivePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), now(), linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) RestorePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = 0, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) DeletePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
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 active DESC, 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 {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
return err
}
return tx.Commit()
}
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 active DESC, 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 pihole_integrations 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, `UPDATE pihole_integrations 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) ListPiHoleIntegrationsWithErrors(ctx context.Context) ([]model.PiHoleIntegration, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE last_error != '' ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var integrations []model.PiHoleIntegration
for rows.Next() {
integration, scanErr := scanPiHoleIntegration(rows)
if scanErr != nil {
return nil, scanErr
}
integrations = append(integrations, integration)
}
return integrations, rows.Err()
}
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 scanPiHoleIntegration(scanner interface{ Scan(...any) error }) (model.PiHoleIntegration, error) {
var integration model.PiHoleIntegration
var insecureTLS int
var createdAt, updatedAt, lastSyncAt int64
err := scanner.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError)
integration.InsecureTLS = intBool(insecureTLS)
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
return integration, err
}
func scanPiHoleRecordLink(scanner interface{ Scan(...any) error }) (model.PiHoleRecordLink, error) {
var link model.PiHoleRecordLink
var active int
var createdAt, updatedAt, archivedAt int64
err := scanner.Scan(&link.ID, &link.IntegrationID, &active, &link.Value, &createdAt, &updatedAt, &archivedAt)
link.Active = intBool(active)
link.CreatedAt, link.UpdatedAt, link.ArchivedAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(archivedAt)
return link, 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
}