includes/settings.php

<?php
declare(strict_types=1);

/*
|--------------------------------------------------------------------------
| Brivacia Settings
|--------------------------------------------------------------------------
|
| Defaults to /data/settings.json and /data/brivacia.key.
| settings.json is the source of truth for user configuration.
|
*/

function brivacia_default_settings(): array
{
    return [
        'installed' => false,
        'version' => '1.0.0b1',
        'sites' => [
            'main' => '',
        ],
        'dashboard' => [
            'auto_refresh' => 1,
            'instance_name' => 'Brivacia',
            'light_theme' => false,
            'show_external_icon_in_top_pages' => true,
            'show_page_language_in_top_pages' => false,
        ],
        'admin' => [
            'username' => '',
            'password_hash' => '',
            'ignore_cookie_years' => 5,
            'two_factor' => [
                'enabled' => false,
                'secret' => '',
                'backup_codes' => [],
                'last_counter' => 0,
            ],
        ],
        'privacy' => [
            'country_provider' => 'none',
            'ip_prefix_octets' => 3,
        ],
        'referrers' => [
            'auto_referrers' => true,
            'auto_referrer_icons' => true,
            'max_icon_bytes' => 102400,
            'max_icon_size' => 96,
        ],
        'trends' => [
            'visitors' => true,
            'visits' => true,
            'pageviews' => true,
            'sensitivity' => 'normal',
            'placeholder_style' => 'padawan',
        ],
    ];
}


/*
|--------------------------------------------------------------------------
| Paths
|--------------------------------------------------------------------------
*/

function brivacia_root_path(string $path = ''): string
{
    $root = rtrim(__DIR__ . '/..', '/\\');
    $path = trim($path, '/\\');

    return $path === '' ? $root : $root . '/' . $path;
}

function brivacia_default_data_dir(): string
{
    return brivacia_root_path('data');
}

function brivacia_settings_path(): string
{
    return brivacia_default_data_dir() . '/settings.json';
}

function brivacia_key_path(): string
{
    return brivacia_default_data_dir() . '/brivacia.key';
}


/*
|--------------------------------------------------------------------------
| Loading and merging
|--------------------------------------------------------------------------
*/

function brivacia_read_json_file(string $file): ?array
{
    if (!is_file($file)) {
        return null;
    }

    $json = file_get_contents($file);

    if ($json === false || trim($json) === '') {
        return null;
    }

    $data = json_decode($json, true);

    return is_array($data) ? $data : null;
}

// Recursively merges $override on top of $base, but only for associative
// arrays: lists (numerically indexed arrays) are replaced wholesale rather
// than merged element by element. Used to layer settings.json on top of
// brivacia_default_settings() without losing keys the user hasn't set.
function brivacia_array_merge_recursive_distinct(array $base, array $override): array
{
    foreach ($override as $key => $value) {
        if (
            isset($base[$key]) &&
            is_array($base[$key]) &&
            is_array($value) &&
            array_is_list($base[$key]) === false &&
            array_is_list($value) === false
        ) {
            $base[$key] = brivacia_array_merge_recursive_distinct($base[$key], $value);
            continue;
        }

        $base[$key] = $value;
    }

    return $base;
}

function brivacia_settings(): array
{
    static $settings = null;

    if ($settings !== null) {
        return $settings;
    }

    $loaded = brivacia_read_json_file(brivacia_settings_path()) ?? [];

    $settings = brivacia_array_merge_recursive_distinct(
        brivacia_default_settings(),
        $loaded
    );

    // 'sites' is a user-managed map (site code => domain), not a set of
    // fixed keys to merge defaults into: if the user has saved a 'sites'
    // list, it fully replaces the default rather than merging with it,
    // otherwise a deleted site would keep reappearing from the defaults.
    if (isset($loaded['sites']) && is_array($loaded['sites'])) {
        $settings['sites'] = $loaded['sites'];
    }

    return $settings;
}

// Reads a single setting by dot-notation path, e.g. 'dashboard.auto_refresh'.
function brivacia_setting(string $key, mixed $default = null): mixed
{
    $value = brivacia_settings();

    foreach (explode('.', $key) as $part) {
        if (!is_array($value) || !array_key_exists($part, $value)) {
            return $default;
        }

        $value = $value[$part];
    }

    return $value;
}

/*
|--------------------------------------------------------------------------
| Trend sensitivity presets
|--------------------------------------------------------------------------
|
| The minimum sample size (on the smaller side of the comparison) before a
| trend percentage is shown instead of the "not enough data" placeholder.
| Below this, a single extra visit can still swing the percentage wildly,
| especially on small sites where a rare multi-page visit is normal rather
| than a real traffic spike. Exposed as named presets rather than a raw
| number, since there's no way for someone to know what "8" means without
| already understanding this mechanism.
*/
function brivaciaTrendMinSample(): int
{
    return match (brivacia_setting('trends.sensitivity', 'normal')) {
        'cautious' => 15,
        'normal' => 8,
        default => 3,
    };
}

// Every tool worth its salt needs at least one pointless-but-essential
// feature. This one lets the "not enough data yet" placeholder match
// whatever sense of humor the instance owner (not necessarily the
// visitor) has — or none at all, via the neutral option.
function brivaciaTrendPlaceholderKey(): string
{
    $style = brivacia_setting('trends.placeholder_style', 'padawan');

    if (!in_array($style, ['padawan', 'doh', 'neutral'], true)) {
        $style = 'padawan';
    }

    return 'metric.change.not.enough.data.' . $style;
}


function brivacia_sites(): array
{
    $sites = brivacia_setting('sites', []);

    return is_array($sites) ? $sites : [];
}

// Settings as exposed to the frontend/API: strips anything that must never
// leave the server (secrets, the admin password hash).
function brivacia_public_settings(): array
{
    $settings = brivacia_settings();

    unset(
        $settings['secret'],
        $settings['key'],
        $settings['admin']['password_hash'],
        $settings['admin']['two_factor']['secret'],
        $settings['admin']['two_factor']['backup_codes'],
        $settings['admin']['two_factor']['last_counter'],
        $settings['admin']['two_factor_setup']
    );

    return $settings;
}

// The random key in /data/brivacia.key, used to derive encryption/signing
// material. Generated once on install (see brivacia_save_settings()) and
// never stored in settings.json itself.
function brivacia_secret_key(): string
{
    static $key = null;

    if ($key !== null) {
        return $key;
    }

    $file = brivacia_key_path();

    if (!is_file($file)) {
        return $key = '';
    }

    return $key = trim((string)file_get_contents($file));
}

function brivacia_is_installed(): bool
{
    $settings = brivacia_settings();

    return
        (bool)($settings['installed'] ?? false)
        && trim((string)($settings['admin']['username'] ?? '')) !== ''
        && trim((string)($settings['admin']['password_hash'] ?? '')) !== '';
}

// True when settings.json claims Brivacia is installed but brivacia.key is
// missing/empty — a broken install state (e.g. the key file was deleted or
// excluded from a backup restore) that callers should surface as an error
// rather than silently limping along without a secret key.
function brivacia_installation_key_missing(): bool
{
    return brivacia_is_installed() && brivacia_secret_key() === '';
}


/*
|--------------------------------------------------------------------------
| Writing
|--------------------------------------------------------------------------
*/

// Writes to a temp file first, then renames into place. rename() is atomic
// on the same filesystem, so readers never see a half-written file.
function brivacia_write_file_atomic(string $file, string $content): void
{
    $dir = dirname($file);

    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    $tmp = $file . '.tmp.' . bin2hex(random_bytes(6));

    if (file_put_contents($tmp, $content, LOCK_EX) === false) {
        throw new RuntimeException('Unable to write temporary file: ' . $tmp);
    }

    if (!rename($tmp, $file)) {
        @unlink($tmp);
        throw new RuntimeException('Unable to write file: ' . $file);
    }
}

function brivacia_save_settings(array $settings): void
{
    $settings = brivacia_sanitize_settings($settings, brivacia_settings());
    $dataDir = brivacia_default_data_dir();

    if (!is_dir($dataDir)) {
        mkdir($dataDir, 0755, true);
    }

    $keyFile = brivacia_key_path();

    if (!is_file($keyFile)) {
        brivacia_write_file_atomic($keyFile, bin2hex(random_bytes(32)) . "\n");
    }

    $json = json_encode(
        $settings,
        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
    );

    if ($json === false) {
        throw new RuntimeException('Unable to encode settings JSON.');
    }

    brivacia_write_file_atomic(brivacia_settings_path(), $json . "\n");
}


/*
|--------------------------------------------------------------------------
| Sanitization
|--------------------------------------------------------------------------
|
| Every setting written to disk goes through brivacia_sanitize_settings(),
| which rebuilds a clean settings array field by field rather than trusting
| whatever was submitted — untrusted input never reaches settings.json as-is.
|
*/

function brivacia_sanitize_bool(mixed $value): bool
{
    if (is_bool($value)) {
        return $value;
    }

    if (is_string($value)) {
        return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
    }

    return (bool)$value;
}

function brivacia_sanitize_int(mixed $value, int $min, int $max, int $default): int
{
    if (!is_numeric($value)) {
        return $default;
    }

    return max($min, min($max, (int)$value));
}

// Site codes are used as URL params and array keys, so keep them to a
// narrow, predictable charset. Falls back to 'main' rather than ''.
function brivacia_sanitize_site_code(string $code): string
{
    $code = strtolower(trim($code));
    $code = preg_replace('/[^a-z0-9_-]/', '-', $code) ?? '';
    $code = trim($code, '-_');

    return $code !== '' ? $code : 'main';
}

// Strips scheme and path, keeping only the bare host (e.g.
// "https://example.com/foo" -> "example.com").
function brivacia_sanitize_domain(string $domain): string
{
    $domain = strtolower(trim($domain));
    $domain = preg_replace('#^https?://#', '', $domain) ?? $domain;
    $domain = explode('/', $domain)[0] ?? $domain;
    $domain = preg_replace('/[^a-z0-9.-]/', '', $domain) ?? '';

    return trim($domain, '.');
}

function brivacia_sanitize_sites(mixed $sites): array
{
    $clean = [];

    if (is_array($sites)) {
        foreach ($sites as $code => $domain) {
            // Accept both the plain 'code => domain' map and a list of
            // {code, domain} objects, since the settings form can submit
            // either shape depending on how the site rows were edited.
            if (is_array($domain)) {
                $code = (string)($domain['code'] ?? $code);
                $domain = (string)($domain['domain'] ?? '');
            }

            $code = brivacia_sanitize_site_code((string)$code);
            $domain = brivacia_sanitize_domain((string)$domain);

            if ($domain !== '') {
                $clean[$code] = $domain;
            }
        }
    }

    // Never persist an empty site list: there must always be at least one
    // site to track against, even if unconfigured.
    return $clean !== [] ? $clean : ['main' => ''];
}

// Rejects usernames that are too short/long, use unexpected characters, or
// match an obvious/reserved word (helps avoid accidental "admin"-as-username
// setups on top of the confusable-with-support-account risk).
function brivacia_sanitize_admin_username(string $username): string
{
    $username = trim($username);
    $lower = strtolower($username);

    if (
        mb_strlen($lower, 'UTF-8') < 3 || mb_strlen($lower, 'UTF-8') > 32
    ) {
        return '';
    }

    // Case-insensitive charset/blocklist check, but the returned value
    // below keeps whatever case the user actually typed at creation time
    // — usernames are no longer forced to lowercase in storage.
    if (!preg_match('/^[a-z0-9._-]+$/i', $username)) {
        return '';
    }

    $blocked = [
        'admin',
        'administrateur',
        'administrator',
        'brivacia',
        'demo',
        'guest',
        'login',
        'owner',
        'root',
        'support',
        'system',
        'test',
        'user',
    ];

    return in_array($lower, $blocked, true)
        ? ''
        : $username;
}

// Minimum password policy: 12-128 chars, at least one lowercase, uppercase,
// digit and non-word character.
function brivacia_validate_admin_password(string $password): bool
{
    $length = mb_strlen($password, 'UTF-8');

    if ($length < 12 || $length > 128) {
        return false;
    }

    return
        (bool)preg_match('/[a-z]/', $password)
        && (bool)preg_match('/[A-Z]/', $password)
        && (bool)preg_match('/\d/', $password)
        && (bool)preg_match('/[^\w]/', $password);
}


function brivacia_sanitize_two_factor_settings(mixed $value): array
{
    $value = is_array($value) ? $value : [];

    $secret = strtoupper(trim((string)($value['secret'] ?? '')));
    $secret = preg_replace('/[^A-Z2-7]/', '', $secret) ?? '';

    if ($secret !== '' && !preg_match('/^[A-Z2-7]{16,128}$/', $secret)) {
        $secret = '';
    }

    $backupCodes = [];

    if (isset($value['backup_codes']) && is_array($value['backup_codes'])) {
        foreach ($value['backup_codes'] as $hash) {
            $hash = trim((string)$hash);

            if ($hash !== '' && strlen($hash) <= 255) {
                $backupCodes[] = $hash;
            }
        }
    }

    return [
        'enabled' => $secret !== '' && brivacia_sanitize_bool($value['enabled'] ?? false),
        'secret' => $secret,
        'backup_codes' => array_values($backupCodes),
        'last_counter' => brivacia_sanitize_int($value['last_counter'] ?? 0, 0, PHP_INT_MAX, 0),
    ];
}


function brivacia_sanitize_two_factor_setup_settings(mixed $value): array
{
    $value = is_array($value) ? $value : [];
    $tokenHash = strtolower(trim((string)($value['token_hash'] ?? '')));
    $secret = strtoupper(trim((string)($value['secret'] ?? '')));
    $secret = preg_replace('/[^A-Z2-7]/', '', $secret) ?? '';
    $expiresAt = brivacia_sanitize_int($value['expires_at'] ?? 0, 0, PHP_INT_MAX, 0);

    if (!preg_match('/^[a-f0-9]{64}$/', $tokenHash)) {
        return [];
    }

    if ($secret === '' || !preg_match('/^[A-Z2-7]{16,128}$/', $secret)) {
        return [];
    }

    if ($expiresAt < time()) {
        return [];
    }

    return [
        'token_hash' => $tokenHash,
        'secret' => $secret,
        'expires_at' => $expiresAt,
    ];
}

function brivacia_sanitize_admin_settings(array $merged, array $base): array
{
    // Never take a password_hash from user input: it can only come from
    // the existing $base (unchanged) or from brivacia_install() after
    // hashing a freshly validated password. The check must be "is it
    // genuinely empty" rather than "is it null" — $base['admin']
    // ['password_hash'] defaults to '' (an empty string, not null), so a
    // plain "??" chain never falls through to $merged and would silently
    // discard the real hash on every fresh install.
    $existingHash = trim((string)($base['admin']['password_hash'] ?? ''));
    $passwordHash = $existingHash !== ''
        ? $existingHash
        : trim((string)($merged['admin']['password_hash'] ?? ''));

    $admin = [
        'username' => brivacia_sanitize_admin_username((string)($merged['admin']['username'] ?? '')),
        'password_hash' => $passwordHash,
        'ignore_cookie_years' => brivacia_sanitize_int($merged['admin']['ignore_cookie_years'] ?? 5, 1, 20, 5),
        'two_factor' => brivacia_sanitize_two_factor_settings($merged['admin']['two_factor'] ?? []),
    ];

    $setup = brivacia_sanitize_two_factor_setup_settings($merged['admin']['two_factor_setup'] ?? []);

    if ($setup !== []) {
        $admin['two_factor_setup'] = $setup;
    }

    return $admin;
}

// Rebuilds a fully sanitized settings array from $input, falling back to
// $base (or the hardcoded defaults) for anything missing or invalid. This
// is the single choke point every settings write passes through — no
// caller should write to settings.json directly.
function brivacia_sanitize_settings(array $input, ?array $base = null): array
{
    $base ??= brivacia_default_settings();
    $merged = brivacia_array_merge_recursive_distinct($base, $input);

    $provider = (string)($merged['privacy']['country_provider'] ?? 'none');

    if (!in_array($provider, ['none', 'blurloc', 'cloudflare'], true)) {
        $provider = 'none';
    }

    $trendSensitivity = (string)($merged['trends']['sensitivity'] ?? 'normal');

    if (!in_array($trendSensitivity, ['sensitive', 'normal', 'cautious'], true)) {
        $trendSensitivity = 'normal';
    }

    $trendPlaceholderStyle = (string)($merged['trends']['placeholder_style'] ?? 'padawan');

    if (!in_array($trendPlaceholderStyle, ['padawan', 'doh', 'neutral'], true)) {
        $trendPlaceholderStyle = 'padawan';
    }

    return [
        'installed' => brivacia_sanitize_bool($merged['installed'] ?? false),
        'version' => preg_match('/^\d+\.\d+\.\d+(?:[a-z]\d+)?$/i', (string)($merged['version'] ?? '1.0.0'))
            ? (string)$merged['version']
            : '1.0.0',
        'sites' => brivacia_sanitize_sites($input['sites'] ?? $merged['sites'] ?? []),
        'dashboard' => [
            'auto_refresh' => brivacia_sanitize_int($merged['dashboard']['auto_refresh'] ?? 1, 0, 1440, 1),
            'instance_name' => trim((string)($merged['dashboard']['instance_name'] ?? 'Brivacia')) ?: 'Brivacia',
            'light_theme' => brivacia_sanitize_bool($merged['dashboard']['light_theme'] ?? false),
            'show_external_icon_in_top_pages' => brivacia_sanitize_bool($merged['dashboard']['show_external_icon_in_top_pages'] ?? true),
            'show_page_language_in_top_pages' => brivacia_sanitize_bool($merged['dashboard']['show_page_language_in_top_pages'] ?? false),
        ],
        'admin' => brivacia_sanitize_admin_settings($merged, $base),
        'privacy' => [
            'country_provider' => $provider,
            'ip_prefix_octets' => brivacia_sanitize_int($merged['privacy']['ip_prefix_octets'] ?? 3, 2, 3, 3),
        ],
        'referrers' => [
            'auto_referrers' => brivacia_sanitize_bool($merged['referrers']['auto_referrers'] ?? true),
            'auto_referrer_icons' => brivacia_sanitize_bool($merged['referrers']['auto_referrer_icons'] ?? true),
            'max_icon_bytes' => brivacia_sanitize_int($merged['referrers']['max_icon_bytes'] ?? 102400, 1024, 1048576, 102400),
            'max_icon_size' => brivacia_sanitize_int($merged['referrers']['max_icon_size'] ?? 96, 16, 512, 96),
        ],
        'trends' => [
            'visitors' => brivacia_sanitize_bool($merged['trends']['visitors'] ?? true),
            'visits' => brivacia_sanitize_bool($merged['trends']['visits'] ?? true),
            'pageviews' => brivacia_sanitize_bool($merged['trends']['pageviews'] ?? true),
            'sensitivity' => $trendSensitivity,
            'placeholder_style' => $trendPlaceholderStyle,
        ],
    ];
}


/*
|--------------------------------------------------------------------------
| Installation
|--------------------------------------------------------------------------
*/

// First-run setup: validates the chosen admin credentials, hashes the
// password, and persists the initial settings with installed = true.
function brivacia_install(array $input): array
{
    $username = (string)($input['admin']['username'] ?? '');
    $password = (string)($input['admin']['password'] ?? '');
    $passwordConfirm = (string)($input['admin']['password_confirm'] ?? '');

    if (brivacia_sanitize_admin_username($username) === '') {
        throw new RuntimeException('auth.errors.invalid.username');
    }

    if ($password !== $passwordConfirm) {
        throw new RuntimeException('auth.errors.passwords.do.not.match');
    }

    if (!brivacia_validate_admin_password($password)) {
        throw new RuntimeException('auth.errors.password.too.weak');
    }

    $input['admin']['password_hash'] = password_hash(
        $password,
        PASSWORD_DEFAULT
    );

    // Never persist the raw/confirm passwords, only the hash computed above.
    unset(
        $input['admin']['password'],
        $input['admin']['password_confirm']
    );

    $settings = brivacia_sanitize_settings(
        $input,
        brivacia_default_settings()
    );

    $settings['installed'] = true;

    brivacia_save_settings($settings);

    return $settings;
}

Contribute