1844 lines
71 KiB
Go
1844 lines
71 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/buildinfo"
|
|
"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
|
|
}
|
|
var appliedMigrations int
|
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&appliedMigrations); err != nil {
|
|
return err
|
|
}
|
|
if appliedMigrations != buildinfo.CurrentSchemaVersion {
|
|
return fmt.Errorf("database migration chain is incomplete: have %d of %d", appliedMigrations, buildinfo.CurrentSchemaVersion)
|
|
}
|
|
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(¤tRole, ¤tActive); 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) ClearDesiredSoftwareVersion(ctx context.Context, expected string) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = '', updated_at = ? WHERE id = 1 AND desired_version = ?`, now(), expected)
|
|
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
|
|
}
|
|
|
|
// CompleteUpdateRun commits history, dashboard state and audit together.
|
|
func (s *Store) CompleteUpdateRun(ctx context.Context, id int64, status, ipv4, ipv6, summary string) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = ?, ipv4 = ?, ipv6 = ?, summary = ? WHERE id = ?`, now(), status, ipv4, ipv6, summary, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.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); err != nil {
|
|
return err
|
|
}
|
|
if status != "ok" {
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), "update."+status, summary); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RecoverInterruptedRuns is called once at process startup, before workers start.
|
|
func (s *Store) RecoverInterruptedRuns(ctx context.Context) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
result, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = 'error', summary = 'Durch Neustart unterbrochen; erneute Prüfung folgt automatisch' WHERE status = 'running'`, now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_run_status = 'error' WHERE id = 1`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
|
return s.recentRuns(ctx, limit, false)
|
|
}
|
|
|
|
// RecentSignificantRuns keeps changes and failures visible even after many
|
|
// successful checks without changes. The existing history stores counts only
|
|
// in its summary, so exclude the scheduler's two known successful no-op results.
|
|
// Unknown summaries and all failures are retained rather than silently hidden.
|
|
func (s *Store) RecentSignificantRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
|
return s.recentRuns(ctx, limit, true)
|
|
}
|
|
|
|
func (s *Store) recentRuns(ctx context.Context, limit int, significantOnly bool) ([]model.UpdateRun, error) {
|
|
query := `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs`
|
|
if significantOnly {
|
|
query += ` WHERE status IN ('error', 'partial') OR (status = 'ok' AND summary NOT IN ('0 Ziel(e) aktualisiert, 0 Fehler', 'Keine aktiven DynDNS-Ziele'))`
|
|
}
|
|
query += ` ORDER BY id DESC LIMIT ?`
|
|
rows, err := s.db.QueryContext(ctx, query, 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
|
|
}
|