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;
}