includes/core.php
<?php
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| Installation check
|--------------------------------------------------------------------------
*/
require_once __DIR__ . '/settings.php';
if (!defined('BRIVACIA_BLOCKED')) {
define('BRIVACIA_BLOCKED', '__blocked__');
}
if (!defined('BRIVACIA_UNKNOWN')) {
define('BRIVACIA_UNKNOWN', '__unknown__');
}
require_once __DIR__ . '/rules.php';
function brivaciaNeedsInstall(): bool {
return !brivacia_is_installed();
}
/*
|--------------------------------------------------------------------------
| HTTP security headers
|--------------------------------------------------------------------------
|
| Sent on every page that isn't meant to be embedded from another site.
| $allowFraming is set to true only for api/privacy.php's iframe widget,
| which is deliberately embedded on the *tracked* website itself.
|
*/
/*
|--------------------------------------------------------------------------
| CSP nonce
|--------------------------------------------------------------------------
|
| One random value per request, used both in the Content-Security-Policy
| header (script-src) and as the nonce="" attribute on index.php's few
| legitimate inline <script> blocks. static $nonce makes sure the header
| and the HTML always agree, however many times this is called.
|
*/
function brivaciaCspNonce(): string
{
static $nonce = null;
if ($nonce === null) {
$nonce = base64_encode(random_bytes(16));
}
return $nonce;
}
function brivaciaSecurityHeaders(bool $allowFraming = false): void
{
if (headers_sent()) {
return;
}
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()');
if (!$allowFraming) {
header('X-Frame-Options: SAMEORIGIN');
// Every inline <script> that stays in index.php carries a matching
// nonce="" attribute (see brivaciaCspNonce()), so script-src doesn't
// need 'unsafe-inline'. The onchange="" attributes that used to
// require it have been replaced by a data-auto-submit hook wired
// up from one of those nonce'd scripts instead.
header(
"Content-Security-Policy: default-src 'self'; " .
"script-src 'self' 'nonce-" . brivaciaCspNonce() . "'; " .
"style-src 'self'; " .
"img-src 'self' data:; " .
"font-src 'self'; " .
"connect-src 'self'; " .
"object-src 'none'; " .
"base-uri 'self'; " .
"form-action 'self'; " .
"frame-ancestors 'self'"
);
}
}
/*
|--------------------------------------------------------------------------
| Storage
|--------------------------------------------------------------------------
*/
function ensureDir(string $dir): string {
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $dir;
}
function protectDir(string $dir): void {
$file = rtrim($dir, '/') . '/.htaccess';
if (is_file($file)) return;
@file_put_contents($file, "Require all denied\nDeny from all\n", LOCK_EX);
}
function storagePath(string $folder): string {
return __DIR__ . '/../' . $folder;
}
function storageDir(string $dir): string {
$dir = ensureDir($dir);
protectDir($dir);
return $dir;
}
function archiveDir(): string { return storageDir(storagePath('archives')); }
function backupDir(): string { return storageDir(storagePath('backup')); }
function corruptDir(): string { return storageDir(storagePath('corrupt')); }
function dataDir(): string { return storageDir(storagePath('data')); }
function liveDbFile(): string { return dataDir() . '/brivacia.sqlite'; }
function logDir(): string {
$dir = storageDir(storagePath('logs'));
foreach (['archive','backup','import','pages','pixel','providers','referrers', 'update'] as $subdir) {
storageDir($dir . '/' . $subdir);
}
return $dir;
}
function updateDir(): string { return storageDir(storagePath('update')); }
function initStorageDirs(): void {
archiveDir(); backupDir(); corruptDir(); dataDir(); logDir(); updateDir();
$blocklist = dataDir() . '/referrers_blocklist.json';
if (!is_file($blocklist)) {
file_put_contents($blocklist, json_encode(['hosts' => [], 'contains' => []], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
}
protectDir(__DIR__);
}
if (!brivaciaNeedsInstall()) {
initStorageDirs();
}
/*
|--------------------------------------------------------------------------
| Logging
|--------------------------------------------------------------------------
*/
function trimLogFile(string $file, int $days = 30): void {
if (!is_file($file)) return;
// ... (garde ta fonction existante)
$cutoff = time() - ($days * 86400);
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$keep = [];
foreach ($lines as $line) {
$timestamp = strtotime(substr($line, 0, 25));
if ($timestamp !== false && $timestamp >= $cutoff) $keep[] = $line;
}
file_put_contents($file, implode(PHP_EOL, $keep) . (count($keep) ? PHP_EOL : ''), LOCK_EX);
}
function brivaciaSanitizeLogMessage(string $message): string {
$message = str_replace(["\r", "\n"], ' ', $message);
$message = preg_replace('~\s+~u', ' ', $message) ?? $message;
// Never keep raw IP addresses in logs.
$message = preg_replace(
'~(?<![A-Za-z0-9])(?:\d{1,3}\.){3}\d{1,3}(?![A-Za-z0-9])~',
'[redacted-ipv4]',
$message
) ?? $message;
// Best-effort IPv6 redaction for diagnostics logs.
$message = preg_replace(
'~(?<![A-Za-z0-9])(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{0,4}(?![A-Za-z0-9])~',
'[redacted-ipv6]',
$message
) ?? $message;
return trim(mb_substr($message, 0, 2000, 'UTF-8'));
}
function brivaciaLog(string $file, string $message): void {
$path = logDir() . '/' . $file;
$dir = dirname($path);
if (!is_dir($dir)) mkdir($dir, 0755, true);
trimLogFile($path);
file_put_contents($path, date('c') . ' ' . brivaciaSanitizeLogMessage($message) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
require_once __DIR__ . '/backup.php';
/*
|--------------------------------------------------------------------------
| Database
|--------------------------------------------------------------------------
|
| Opens the live SQLite database, restores it from backup if needed, and
| ensures the schema exists.
|
*/
function brivaciaDb(): PDO {
restoreBrivaciaDbIfBroken();
$db = new PDO('sqlite:' . liveDbFile());
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA journal_mode = WAL');
$db->exec('PRAGMA busy_timeout = 3000');
initBrivaciaDb($db);
return $db;
}
function initBrivaciaDb(PDO $db): void {
$db->exec("
CREATE TABLE IF NOT EXISTS hits_daily (
site TEXT NOT NULL DEFAULT '',
day TEXT NOT NULL,
unique_visitors INTEGER NOT NULL DEFAULT 0,
visits INTEGER NOT NULL DEFAULT 0,
pageviews INTEGER NOT NULL DEFAULT 0,
bots INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site, day)
);
CREATE TABLE IF NOT EXISTS hits_hourly (
site TEXT NOT NULL DEFAULT '',
day TEXT NOT NULL,
hour INTEGER NOT NULL,
unique_visitors INTEGER NOT NULL DEFAULT 0,
visits INTEGER NOT NULL DEFAULT 0,
pageviews INTEGER NOT NULL DEFAULT 0,
bots INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site, day, hour)
);
CREATE TABLE IF NOT EXISTS pages_daily (
site TEXT NOT NULL,
day TEXT NOT NULL,
page_key TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, site, page_key)
);
CREATE TABLE IF NOT EXISTS countries_daily (
site TEXT NOT NULL DEFAULT '',
day TEXT NOT NULL,
country TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site, day, country)
);
CREATE TABLE IF NOT EXISTS referrers_daily (
site TEXT NOT NULL DEFAULT '',
day TEXT NOT NULL,
referrer TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site, day, referrer)
);
CREATE TABLE IF NOT EXISTS seen_daily (
site TEXT NOT NULL DEFAULT '',
day TEXT NOT NULL,
visitor_hash TEXT NOT NULL,
PRIMARY KEY (site, day, visitor_hash)
);
CREATE TABLE IF NOT EXISTS visitor_sessions (
site TEXT NOT NULL DEFAULT '',
day TEXT NOT NULL,
visitor_hash TEXT NOT NULL,
last_seen INTEGER NOT NULL,
returned_today INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site, day, visitor_hash)
);
CREATE TABLE IF NOT EXISTS login_attempts (
ip_prefix TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
last_failed_at INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (ip_prefix)
);
CREATE TABLE IF NOT EXISTS pixel_rate_limit (
ip_prefix TEXT NOT NULL,
window_start INTEGER NOT NULL DEFAULT 0,
hits INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (ip_prefix)
);
");
ensureColumn($db, 'hits_daily', 'site', "TEXT NOT NULL DEFAULT ''");
ensureColumn($db, 'countries_daily', 'site', "TEXT NOT NULL DEFAULT ''");
ensureColumn($db, 'pages_daily', 'url', "TEXT NOT NULL DEFAULT ''");
ensureColumn($db, 'pages_daily', 'page_last_try', "TEXT DEFAULT NULL");
ensureColumn($db, 'pages_daily', 'page_failures', "INTEGER NOT NULL DEFAULT 0");
ensureColumn($db, 'pages_daily', 'page_resolved', "INTEGER NOT NULL DEFAULT 0");
ensureColumn($db, 'pages_daily', 'page_lang_last_try', "TEXT DEFAULT NULL");
ensureColumn($db, 'pages_daily', 'page_lang_failures', "INTEGER NOT NULL DEFAULT 0");
ensureColumn($db, 'referrers_daily', 'site', "TEXT NOT NULL DEFAULT ''");
ensureColumn($db, 'seen_daily', 'site', "TEXT NOT NULL DEFAULT ''");
ensureColumn($db, 'visitor_sessions', 'site', "TEXT NOT NULL DEFAULT ''");
ensureColumn($db, 'visitor_sessions', 'returned_today', "INTEGER NOT NULL DEFAULT 0");
ensureColumn($db, 'visitor_sessions', 'referrer', "TEXT NOT NULL DEFAULT ''");
$db->exec("
CREATE INDEX IF NOT EXISTS idx_hits_daily_day
ON hits_daily(day);
CREATE INDEX IF NOT EXISTS idx_countries_daily_day_country
ON countries_daily(day, country);
CREATE INDEX IF NOT EXISTS idx_referrers_daily_day_referrer
ON referrers_daily(day, referrer);
CREATE INDEX IF NOT EXISTS idx_pages_daily_day_site_page
ON pages_daily(day, site, page_key);
CREATE INDEX IF NOT EXISTS idx_pages_daily_site_day
ON pages_daily(site, day);
CREATE INDEX IF NOT EXISTS idx_pages_daily_resolve
ON pages_daily(page_resolved, day DESC, views DESC);
");
}
function ensureColumn(PDO $db, string $table, string $column, string $definition): void {
$cols = fetchAll($db, 'PRAGMA table_info(' . $table . ')');
foreach ($cols as $col) {
if (($col['name'] ?? '') === $column) {
return;
}
}
$db->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $definition);
}
/*
|--------------------------------------------------------------------------
| Yearly archive check
|--------------------------------------------------------------------------
|
| Closed years only need to be archived once per calendar year.
| Store the last processed year to avoid scanning archives on every request.
|
*/
function brivaciaShouldCheckYearArchive(PDO $db): bool {
$cutoff = date('Y') . '-01-01';
$stmt = $db->prepare('
SELECT EXISTS(
SELECT 1
FROM hits_daily
WHERE day < ?
AND site != ""
LIMIT 1
)
');
$stmt->execute([$cutoff]);
return (int)$stmt->fetchColumn() === 1;
}
/*
|--------------------------------------------------------------------------
| Network helpers
|--------------------------------------------------------------------------
|
| Shared helpers for outbound HTTP requests.
| Prevents SSRF by refusing private, loopback and reserved addresses.
|
*/
function brivaciaHttpContext(int $timeout = 1) {
return stream_context_create([
'http' => [
'timeout' => $timeout,
'user_agent' => 'Brivacia privacy-focused analytics favicon fetcher',
// Without this, PHP's http:// wrapper returns false on any
// non-2xx response (e.g. a 403 from a bot-protection layer)
// instead of the response body — even when that body still
// has a usable <title>. We want the body regardless of the
// status code; callers decide what to do with it.
'ignore_errors' => true,
],
]);
}
function brivaciaIsPublicHost(string $host): bool {
$host = strtolower(trim($host));
if ($host === '') {
return false;
}
$records = dns_get_record($host, DNS_A + DNS_AAAA);
if (!is_array($records) || $records === []) {
return false;
}
foreach ($records as $record) {
$ip = (string)($record['ip'] ?? $record['ipv6'] ?? '');
if ($ip === '') {
continue;
}
if (!filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
return false;
}
}
return true;
}
/*
|--------------------------------------------------------------------------
| Pixel response and request helpers
|--------------------------------------------------------------------------
*/
function sendPixel(): never {
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X8xQAAAAASUVORK5CYII=');
exit;
}
/*
| Sends the pixel bytes to the browser right now, WITHOUT ending the
| script. Any slow work that happens after this call (country lookups,
| page-label/language auto-detection over HTTP, DB writes, ...) no
| longer delays the response the visitor's browser is waiting on.
|
| On PHP-FPM, fastcgi_finish_request() actually closes the connection
| to the browser while PHP keeps running server-side. When that's not
| available (e.g. plain mod_php), we fall back to a best-effort flush:
| the browser still gets the bytes as soon as possible, even if the
| connection itself stays open a bit longer.
*/
function sendPixelNow(): void {
// Whichever path below is used, the visitor's browser may move on
// (close the tab, navigate away) the instant it has its pixel bytes.
// Without this, a plain mod_php setup would abort the script right
// there, before the "slow" work after this function even runs.
ignore_user_abort(true);
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X8xQAAAAASUVORK5CYII=');
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Content-Length: ' . strlen($png));
echo $png;
if (function_exists('fastcgi_finish_request')) {
// PHP-FPM (LiteSpeed, nginx, most modern hosts): this is the clean
// way to close the connection to the browser while PHP keeps
// running server-side for the rest of this file.
fastcgi_finish_request();
return;
}
// Plain mod_php fallback (older/shared Apache hosts without FPM):
// there's no equivalent of fastcgi_finish_request(), but flushing
// every buffering layer still pushes the bytes out to the browser
// as soon as possible. Since Content-Length matches exactly what we
// sent, the browser considers the request done right away and won't
// wait around for the connection to close — even though, on this
// fallback path, the PHP process itself keeps occupying a worker
// slot on the server until the script finishes.
while (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
function param(string $key, string $default = ''): string {
return trim((string)($_GET[$key] ?? $default));
}
/*
|--------------------------------------------------------------------------
| Visitor identity and classification
|--------------------------------------------------------------------------
*/
function brivaciaClientIp(): string {
return (string)($_SERVER['REMOTE_ADDR'] ?? '');
}
function brivaciaNormalizeUserAgent(string $ua): string {
$ua = strtolower(trim($ua));
if ($ua === '') {
return 'other|other';
}
if (detectVisitorKind($ua) === 'bot') {
return 'bot|other';
}
// Order matters: Edge and Opera also expose Chrome/Chromium tokens.
$browser = match (true) {
preg_match('~\b(?:edg|edge)/\d+~', $ua) === 1 => 'edge',
preg_match('~\b(?:opr|opera)/\d+~', $ua) === 1 => 'opera',
preg_match('~\b(?:firefox|fxios)/\d+~', $ua) === 1 => 'firefox',
preg_match('~\b(?:chrome|crios|chromium)/\d+~', $ua) === 1 => 'chromium',
preg_match('~\bversion/\d+.*\bsafari/\d+~', $ua) === 1 => 'safari',
default => 'other',
};
// Keep only the platform family, never the exact OS version, architecture or device model.
$platform = match (true) {
str_contains($ua, 'android') => 'android',
str_contains($ua, 'iphone') || str_contains($ua, 'ipad') || str_contains($ua, 'ipod') => 'ios',
str_contains($ua, 'windows') => 'windows',
str_contains($ua, 'mac os') || str_contains($ua, 'macintosh') => 'macos',
str_contains($ua, 'linux') || str_contains($ua, 'x11') || str_contains($ua, 'cros') => 'linux',
default => 'other',
};
return $browser . '|' . $platform;
}
function brivaciaSafeLogPath(string $value): string {
$value = trim($value);
if ($value === '') {
return '[empty]';
}
if (preg_match('~^https?://~i', $value)) {
$path = parse_url($value, PHP_URL_PATH);
return is_string($path) && $path !== '' ? '[path]' : '[empty]';
}
if (str_starts_with($value, '/')) {
return '[path]';
}
return '[value]';
}
function brivaciaSafeLogRef(string $ref): string {
$host = normalizeHost((string)(parse_url($ref, PHP_URL_HOST) ?? ''));
if ($host !== '') {
return $host;
}
$ref = trim($ref);
if ($ref === '') {
return '[empty]';
}
return '[ref]';
}
function brivaciaSafeLogUrl(string $url): string {
$url = trim($url);
if ($url === '') {
return '[empty]';
}
$host = parse_url($url, PHP_URL_HOST);
$path = parse_url($url, PHP_URL_PATH);
if (is_string($host) && $host !== '') {
return strtolower($host) . (is_string($path) && $path !== '' ? $path : '/');
}
if (str_starts_with($url, '/')) {
return (string)(parse_url($url, PHP_URL_PATH) ?: '/');
}
return mb_substr($url, 0, 120, 'UTF-8');
}
function detectVisitorKind(string $ua): string {
static $botPatterns = [
// IA
'anthropic',
'chatgpt',
'claudebot',
'cohere-ai',
'gptbot',
'google-extended',
'omgili',
'omgilibot',
'perplexity',
'perplexitybot',
'qwen',
'youbot',
'you.com',
// Google
'googlebot',
'googleother',
'google-inspectiontool',
'storebot-google',
// Bing / Microsoft
'bingbot',
'bingpreview',
'adidxbot',
// Apple
'applebot',
'applebot-extended',
// Meta
'facebookexternalhit',
'meta-externalagent',
'meta-externalfetcher',
// ByteDance
'bytespider',
// SEO
'ahrefs',
'ahrefsbot',
'semrush',
'semrushbot',
'mj12bot',
'dotbot',
'seokicks',
'petalbot',
// Archive
'archive.org_bot',
'ia_archiver',
// Divers
'slurp',
'duckassistbot',
'imagesiftbot',
'amazonbot',
'coccocbot',
'dataprovider',
'uptimerobot',
'pingdom',
'whatsapp',
];
$ua = strtolower($ua);
if (preg_match('~(?:bot|crawl|crawler|spider|scraper|fetcher|scanner)\b~i', $ua)) {
return 'bot';
}
foreach ($botPatterns as $pattern) {
if (str_contains($ua, $pattern)) {
return 'bot';
}
}
return 'human';
}
function brivaciaIpPrefix(string $ip): string {
$ip = trim($ip);
$precision = max(
2,
min(3, (int) brivacia_setting('privacy.ip_prefix_octets', 3))
);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return implode('.', array_slice($parts, 0, $precision));
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return '';
}
$hextets = $precision === 2 ? 3 : 4;
$hex = bin2hex(substr($packed, 0, $hextets * 2));
return implode(':', str_split($hex, 4));
}
return '';
}
/*
|--------------------------------------------------------------------------
| Pixel flood protection
|--------------------------------------------------------------------------
|
| A crude flood/anti-abuse guard for the public tracking endpoint, reusing
| the same truncated IP prefix as everywhere else (never the full IP).
| Counts hits in fixed 10-second windows per prefix; once a window goes
| over the threshold, further hits in that same window are dropped from
| storage (the pixel itself is still returned, so nothing looks broken to
| a script hammering the endpoint — it just stops being recorded).
| A real visitor loading pages normally never gets close to this.
*/
function brivaciaPixelRateLimitKey(string $ip): string {
$prefix = brivaciaIpPrefix($ip);
return $prefix !== '' ? $prefix : 'unknown';
}
function brivaciaPixelRateLimited(PDO $db, string $key, int $windowSeconds = 10): bool {
$maxPerWindow = max(10, (int) brivacia_setting('privacy.pixel_rate_limit', 60));
$windowStart = intdiv(time(), $windowSeconds) * $windowSeconds;
$stmt = $db->prepare('SELECT window_start, hits FROM pixel_rate_limit WHERE ip_prefix = ?');
$stmt->execute([$key]);
$row = $stmt->fetch() ?: ['window_start' => 0, 'hits' => 0];
$hits = ((int)$row['window_start'] === $windowStart) ? (int)$row['hits'] + 1 : 1;
$db->prepare('
INSERT INTO pixel_rate_limit (ip_prefix, window_start, hits)
VALUES (?, ?, ?)
ON CONFLICT(ip_prefix) DO UPDATE SET
window_start = excluded.window_start,
hits = excluded.hits
')->execute([$key, $windowStart, $hits]);
return $hits > $maxPerWindow;
}
function visitorHash(string $day, string $ua, string $ip): string {
return hash(
'sha256',
$day . '|' .
brivaciaIpPrefix($ip) . '|' .
brivaciaNormalizeUserAgent($ua) . '|' .
brivacia_secret_key()
);
}
/*
|--------------------------------------------------------------------------
| Counters
|--------------------------------------------------------------------------
*/
function ensureDay(PDO $db, string $day, string $site = ''): void {
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$db->prepare('INSERT OR IGNORE INTO hits_daily(site, day) VALUES(?, ?)')
->execute([$site, $day]);
}
/*
|--------------------------------------------------------------------------
| Hourly counters
|--------------------------------------------------------------------------
|
| Same idea as hits_daily/inc(), just with an extra hour dimension (0-23).
| This exists so the dashboard can compare "today so far" with the same
| number of hours yesterday, instead of a partial day vs a full day.
|
| Only ~9 days are kept (maybePurgeHourlyHits) since this is only useful
| for short, recent comparisons, not long-term history.
|
*/
function ensureHour(PDO $db, string $day, int $hour, string $site = ''): void {
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$db->prepare('INSERT OR IGNORE INTO hits_hourly(site, day, hour) VALUES(?, ?, ?)')
->execute([$site, $day, $hour]);
}
function incHourly(PDO $db, string $col, string $day, int $hour, string $site = ''): void {
$allowed = ['unique_visitors', 'visits', 'pageviews', 'bots'];
if (!in_array($col, $allowed, true)) {
return;
}
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$db->prepare("UPDATE hits_hourly SET $col = $col + 1 WHERE site = ? AND day = ? AND hour = ?")
->execute([$site, $day, $hour]);
}
function maybePurgeHourlyHits(PDO $db, string $day, int $hour): void {
// Only worth checking once per day, right when a new day starts, so
// this doesn't re-run (and rescan the table) on every single hit.
if ($hour !== 0) {
return;
}
$cutoff = date('Y-m-d', strtotime($day . ' -8 days'));
$db->prepare('DELETE FROM hits_hourly WHERE day < ?')->execute([$cutoff]);
}
function markSeen(PDO $db, string $day, string $hash, string $site = ''): bool {
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$stmt = $db->prepare('
INSERT OR IGNORE INTO seen_daily(site, day, visitor_hash)
VALUES(?, ?, ?)
');
$stmt->execute([$site, $day, $hash]);
return $stmt->rowCount() > 0;
}
function markSession(PDO $db, string $day, string $hash, string $site = '', int $timeout = 1800): bool {
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$now = time();
$stmt = $db->prepare('
SELECT last_seen
FROM visitor_sessions
WHERE site = ? AND day = ? AND visitor_hash = ?
LIMIT 1
');
$stmt->execute([$site, $day, $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
$db->prepare('
INSERT INTO visitor_sessions(site, day, visitor_hash, last_seen)
VALUES(?, ?, ?, ?)
')->execute([$site, $day, $hash, $now]);
return true;
}
$isNewSession = (int)($row['last_seen'] ?? 0) <= $now - $timeout;
if ($isNewSession) {
$db->prepare('
UPDATE visitor_sessions
SET last_seen = ?, returned_today = 1
WHERE site = ? AND day = ? AND visitor_hash = ?
')->execute([$now, $site, $day, $hash]);
} else {
$db->prepare('
UPDATE visitor_sessions
SET last_seen = ?
WHERE site = ? AND day = ? AND visitor_hash = ?
')->execute([$now, $site, $day, $hash]);
}
return $isNewSession;
}
/*
|--------------------------------------------------------------------------
| Referrer upgrade within an existing session
|--------------------------------------------------------------------------
|
| A session/visit is only counted once per 30-minute window (see
| markSession()), and its referrer is only recorded on that first hit. If
| that first hit had no usable referrer (direct visit, or the browser
| withheld it) but a later hit in the SAME session carries a real one —
| e.g. the visitor opened the site directly, then a bit later clicked
| through from an external link to another page while still within the
| session window — this corrects the attribution: the visit moves from
| "Unknown" to the real source, without touching visit/pageview counts.
|
| Deliberately one-directional: once a session has a real, identified
| referrer, a *different* later referrer in the same session never
| overwrites it (e.g. internal navigation, or clicking a second external
| link) — the first real source found is kept as the visit's origin.
*/
function maybeUpgradeSessionReferrer(PDO $db, string $day, string $hash, string $site, string $referrer): void {
if ($referrer === '' || $referrer === BRIVACIA_UNKNOWN || $referrer === BRIVACIA_BLOCKED) {
return;
}
$stmt = $db->prepare('
SELECT referrer FROM visitor_sessions
WHERE site = ? AND day = ? AND visitor_hash = ?
LIMIT 1
');
$stmt->execute([$site, $day, $hash]);
$stored = $stmt->fetchColumn();
if ($stored === false) {
return;
}
$stored = (string)$stored;
if ($stored !== '' && $stored !== BRIVACIA_UNKNOWN) {
return;
}
$oldReferrer = $stored === '' ? BRIVACIA_UNKNOWN : $stored;
$db->prepare('
UPDATE referrers_daily SET views = MAX(0, views - 1)
WHERE site = ? AND day = ? AND referrer = ?
')->execute([$site, $day, canonicalReferrerSource($oldReferrer)]);
incReferrer($db, $day, $referrer, $site);
$db->prepare('
UPDATE visitor_sessions SET referrer = ?
WHERE site = ? AND day = ? AND visitor_hash = ?
')->execute([$referrer, $site, $day, $hash]);
}
function inc(PDO $db, string $col, string $day, string $site = ''): void {
$allowed = ['unique_visitors','visits','pageviews','bots'];
if (!in_array($col, $allowed, true)) {
return;
}
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$db->prepare("UPDATE hits_daily SET $col = $col + 1 WHERE site = ? AND day = ?")
->execute([$site, $day]);
}
function incPage(PDO $db, string $day, string $site, string $key, string $title, string $url = ''): void {
$url = brivaciaCleanStoredPageUrl($url);
$isFallback = brivaciaShouldLogPageFallback($key, $title, $url);
if ($isFallback) {
brivaciaLog(
'pixel/page-fallback.log',
'site=' . $site .
' key=' . $key .
' title=' . ($title !== '' ? $title : '[empty]') .
' url=' . brivaciaSafeLogUrl($url)
);
}
$db->prepare(
'INSERT OR IGNORE INTO pages_daily(day, site, page_key, title, url, views, page_resolved) VALUES(?, ?, ?, ?, ?, 0, ?)'
)->execute([$day, $site, $key, $title, $url, $isFallback ? 0 : 1]);
// Do not let a bad hit overwrite a previously resolved title/URL.
$db->prepare(
'UPDATE pages_daily
SET views = views + 1,
title = CASE WHEN ? = 0 AND ? != "" THEN ? ELSE title END,
url = CASE WHEN ? = 0 AND ? != "" THEN ? ELSE url END,
page_resolved = CASE WHEN ? = 0 THEN 1 ELSE page_resolved END
WHERE day = ? AND site = ? AND page_key = ?'
)->execute([
$isFallback ? 1 : 0, $title, $title,
$isFallback ? 1 : 0, $url, $url,
$isFallback ? 1 : 0,
$day, $site, $key
]);
}
/*
|--------------------------------------------------------------------------
| Sites and page helpers
|--------------------------------------------------------------------------
*/
function cleanPageTitle(string $title, string $site, string $pageKey = '', string $url = ''): string {
$default = brivaciaCleanPageTitle($title, $site);
$result = brivaciaRunCustomRules($site, [
'title' => $default,
'pageKey' => $pageKey,
'pageId' => brivaciaRawPageKey($pageKey),
'url' => $url,
'pageUrl' => $url,
]);
return (string)($result['title'] ?? $default);
}
function siteLabel(string $site): string {
$site = trim(strtolower($site));
if (isset(brivacia_sites()[$site])) {
return brivacia_sites()[$site];
}
if (str_contains($site, '.')) {
return $site;
}
return $site;
}
function mainSiteHost(): string {
$sites = array_values(brivacia_sites());
return strtolower(
trim($sites[0] ?? '')
);
}
/*
|--------------------------------------------------------------------------
| Custom rules runtime
|--------------------------------------------------------------------------
|
| Runs rules_custom.php with simple variables.
|
| This is only used to make page titles nicer and page URLS with the real
| one in the dashboard.
|
| Available in rules_custom.php:
| - $isSite1, $isSite2, ...
| - $title
| - $pageKey
| - $pageId
| - $trackedWebsiteLang
| - $url
| - $pageUrl
|
*/
function brivaciaRunCustomRules(string $site, array $data): array {
$file = __DIR__ . '/rules_custom.php';
if (!is_file($file)) {
return $data;
}
foreach (array_keys(brivacia_sites()) as $index => $siteId) {
$number = $index + 1;
${'site' . $number} = (string)$siteId;
${'isSite' . $number} = ($site === (string)$siteId);
}
$title = (string)($data['title'] ?? '');
$pageKey = (string)($data['pageKey'] ?? '');
$pageId = (string)($data['pageId'] ?? brivaciaRawPageKey($pageKey));
$trackedWebsiteLang = (string)($data['trackedWebsiteLang'] ?? '');
$url = (string)($data['url'] ?? '');
$pageUrl = (string)($data['pageUrl'] ?? '');
$resolved = (array)($data['resolved'] ?? []);
include $file;
$data['title'] = $title;
$data['pageKey'] = $pageKey;
$data['pageId'] = $pageId;
$data['trackedWebsiteLang'] = $trackedWebsiteLang;
$data['url'] = $url;
$data['pageUrl'] = $pageUrl;
$data['resolved'] = $resolved;
return $data;
}
/*
|--------------------------------------------------------------------------
| Cookies
|--------------------------------------------------------------------------
|
| Returns the cookie domain used by ignore.php.
|
*/
function ignoreCookieDomain(): string {
return '.' . mainSiteHost();
}
/*
|--------------------------------------------------------------------------
| Page metadata
|--------------------------------------------------------------------------
|
| Bad shared hosting can make the tracked site return only a page id.
| Brivacia keeps the hit immediately, then retries later to resolve title/URL.
|
*/
function brivaciaResolveMetadata(string $site, string $pageKey, string $url = ''): array {
$resolved = brivaciaResolvePageMetadata($site, $pageKey, $url);
$resolvedUrl = (string)($resolved['url'] ?? $url);
$result = brivaciaRunCustomRules($site, [
'pageKey' => $pageKey,
'url' => $resolvedUrl,
'title' => (string)($resolved['title'] ?? ''),
'pageUrl' => $resolvedUrl,
'resolved' => $resolved,
]);
$resolved['title'] = (string)($result['title'] ?? $resolved['title'] ?? '');
$resolved['url'] = (string)($result['pageUrl'] ?? $resolvedUrl);
return $resolved;
}
function refreshPageLabels(PDO $db, int $limit = 3): void {
if ($limit <= 0) {
return;
}
$lockFile = dataDir() . '/pages-refresh.lock';
$lockHandle = fopen($lockFile, 'c');
if (!$lockHandle) return;
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
fclose($lockHandle);
return;
}
$currentYearStart = date('Y') . '-01-01';
$scanLimit = max(1, min(20, $limit * 3));
$rows = fetchAll($db, '
SELECT
site,
page_key,
MAX(url) AS url,
MAX(page_failures) AS failures,
MAX(page_last_try) AS last_try
FROM pages_daily
WHERE page_resolved = 0
AND day >= ?
GROUP BY site, page_key
ORDER BY MAX(day) DESC, SUM(views) DESC
LIMIT ?
', [$currentYearStart, $scanLimit]);
$done = 0;
foreach ($rows as $row) {
if ($done >= $limit) break;
$failures = (int)($row['failures'] ?? 0);
$cooldown = match (true) {
$failures >= 5 => 86400,
$failures >= 3 => 3600,
$failures >= 1 => 600,
default => 0,
};
$lastTry = $row['last_try'] ?? null;
if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
continue;
}
$site = (string)$row['site'];
$pageKey = (string)$row['page_key'];
$url = (string)($row['url'] ?? '');
brivaciaLog(
'pages/recheck.log',
'TRY site=' . $site .
' key=' . $pageKey .
' url=' . brivaciaSafeLogUrl($url) .
' failures=' . $failures
);
$resolved = brivaciaResolveMetadata($site, $pageKey, $url);
brivaciaLog(
'pages/recheck.log',
'RESULT site=' . $site .
' key=' . $pageKey .
' title=' . (($resolved['title'] ?? '') !== '' ? $resolved['title'] : '[empty]') .
' url=' . brivaciaSafeLogUrl((string)($resolved['url'] ?? ''))
);
$now = date('c');
if (($resolved['title'] ?? '') === '' || ($resolved['url'] ?? '') === '') {
$db->prepare('
UPDATE pages_daily
SET page_last_try = ?, page_failures = page_failures + 1
WHERE site = ? AND page_key = ?
')->execute([$now, $site, $pageKey]);
$done++;
continue;
}
$db->prepare('
UPDATE pages_daily
SET title = ?, url = ?, page_resolved = 1, page_failures = 0, page_last_try = ?
WHERE site = ? AND page_key = ?
')->execute([(string)$resolved['title'], (string)$resolved['url'], $now, $site, $pageKey]);
brivaciaLog('pixel/page-resolved.log', 'site=' . $site . ' key=' . $pageKey . ' url=' . brivaciaSafeLogUrl((string)$resolved['url']));
$done++;
}
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
function refreshPageLabelsForRange(PDO $db, string $rangeSql, array $rangeParams, int $limit = 5): void {
if ($limit <= 0) {
return;
}
$lockFile = dataDir() . '/pages-refresh.lock';
$lockHandle = fopen($lockFile, 'c');
if (!$lockHandle) return;
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
fclose($lockHandle);
return;
}
$scanLimit = max(1, min(30, $limit * 3));
$rows = fetchAll($db, '
SELECT
site,
page_key,
MAX(url) AS url,
MAX(page_failures) AS failures,
MAX(page_last_try) AS last_try
FROM pages_daily
WHERE page_resolved = 0
AND ' . $rangeSql . '
GROUP BY site, page_key
ORDER BY SUM(views) DESC, MAX(day) DESC
LIMIT ?
', array_merge($rangeParams, [$scanLimit]));
$done = 0;
foreach ($rows as $row) {
if ($done >= $limit) break;
$failures = (int)($row['failures'] ?? 0);
$cooldown = match (true) {
$failures >= 5 => 86400,
$failures >= 3 => 3600,
$failures >= 1 => 600,
default => 0,
};
$lastTry = $row['last_try'] ?? null;
if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
continue;
}
$site = (string)$row['site'];
$pageKey = (string)$row['page_key'];
$url = (string)($row['url'] ?? '');
brivaciaLog(
'pages/recheck.log',
'RANGE TRY site=' . $site .
' key=' . $pageKey .
' url=' . brivaciaSafeLogUrl($url) .
' failures=' . $failures
);
$resolved = brivaciaResolveMetadata($site, $pageKey, $url);
$now = date('c');
if (($resolved['title'] ?? '') === '' || ($resolved['url'] ?? '') === '') {
$db->prepare('
UPDATE pages_daily
SET page_last_try = ?, page_failures = page_failures + 1
WHERE site = ? AND page_key = ?
')->execute([$now, $site, $pageKey]);
$done++;
continue;
}
$db->prepare('
UPDATE pages_daily
SET title = ?, url = ?, page_resolved = 1, page_failures = 0, page_last_try = ?
WHERE site = ? AND page_key = ?
')->execute([
(string)$resolved['title'],
(string)$resolved['url'],
$now,
$site,
$pageKey
]);
brivaciaLog(
'pixel/page-resolved.log',
'range site=' . $site . ' key=' . $pageKey . ' url=' . brivaciaSafeLogUrl((string)$resolved['url'])
);
$done++;
}
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
/*
|--------------------------------------------------------------------------
| Language refresh
|--------------------------------------------------------------------------
|
| Mirrors refreshPageLabels(): periodically retries pages that couldn't get
| a real language at pixel time (stuck on the 'xx:' alert marker, or on a
| bare pre-fix legacy key with no language segment at all). Never touches a
| page_key that already carries a real language prefix.
|
| Re-fetches the page's HTML (already needed for title/URL resolution) and
| reads <html lang="...">. If found, the page_key is renamed from
| 'xx:/path' (or '/path') to 'lang:/path'. If a row for that day already
| exists under the resolved key (e.g. both '/path' and 'en:/path' got hits
| on the same day), the views are merged into the existing row and the
| unresolved one is dropped — never two rows for the same real page.
|
*/
function brivaciaPageKeyNeedsLangRefresh(string $pageKey): bool {
if (str_starts_with($pageKey, BRIVACIA_UNKNOWN_LANG . ':')) {
return true;
}
// Anything that isn't already "xx:..." and doesn't start with a real
// 2-letter language prefix either (a pre-fix legacy key, e.g. "/about").
return !preg_match('/^[a-z]{2}:/i', $pageKey);
}
function unresolvedLanguagesCount(PDO $db): int {
$row = fetchAll($db, "
SELECT COUNT(*) AS total
FROM (
SELECT site, page_key
FROM pages_daily
WHERE (page_key GLOB 'xx:*' OR page_key NOT GLOB '[a-z][a-z]:*')
AND day >= ?
GROUP BY site, page_key
)
", [date('Y') . '-01-01'])[0] ?? [];
return (int)($row['total'] ?? 0);
}
function brivaciaMaintenanceLangLimit(PDO $db): int {
$remaining = unresolvedLanguagesCount($db);
return match (true) {
$remaining > 10000 => 25,
$remaining > 5000 => 15,
$remaining > 1000 => 8,
$remaining > 100 => 5,
$remaining > 0 => 2,
default => 0,
};
}
// Renames site+oldKey to site+newKey across every day it appears. When a
// day already has a row under newKey (both keys got hits that day), the
// views are merged into that existing row and the oldKey row is dropped,
// so the same real page never ends up counted under two different keys.
function brivaciaMergePageKey(PDO $db, string $site, string $oldKey, string $newKey, string $now): void {
if ($oldKey === $newKey) {
return;
}
$rows = fetchAll($db, '
SELECT day, views
FROM pages_daily
WHERE site = ? AND page_key = ?
', [$site, $oldKey]);
foreach ($rows as $row) {
$day = (string)$row['day'];
$views = (int)($row['views'] ?? 0);
$exists = fetchAll($db, '
SELECT 1 FROM pages_daily WHERE day = ? AND site = ? AND page_key = ?
', [$day, $site, $newKey]);
if ($exists) {
$db->prepare('
UPDATE pages_daily SET views = views + ?
WHERE day = ? AND site = ? AND page_key = ?
')->execute([$views, $day, $site, $newKey]);
$db->prepare('
DELETE FROM pages_daily WHERE day = ? AND site = ? AND page_key = ?
')->execute([$day, $site, $oldKey]);
} else {
$db->prepare('
UPDATE pages_daily
SET page_key = ?, page_lang_last_try = ?, page_lang_failures = 0
WHERE day = ? AND site = ? AND page_key = ?
')->execute([$newKey, $now, $day, $site, $oldKey]);
}
}
}
function refreshPageLanguages(PDO $db, int $limit = 3): void {
if ($limit <= 0) {
return;
}
$lockFile = dataDir() . '/pages-lang-refresh.lock';
$lockHandle = fopen($lockFile, 'c');
if (!$lockHandle) return;
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
fclose($lockHandle);
return;
}
$currentYearStart = date('Y') . '-01-01';
$scanLimit = max(1, min(20, $limit * 3));
$rows = fetchAll($db, "
SELECT
site,
page_key,
MAX(url) AS url,
MAX(page_lang_failures) AS failures,
MAX(page_lang_last_try) AS last_try
FROM pages_daily
WHERE (page_key GLOB 'xx:*' OR page_key NOT GLOB '[a-z][a-z]:*')
AND day >= ?
GROUP BY site, page_key
ORDER BY MAX(day) DESC, SUM(views) DESC
LIMIT ?
", [$currentYearStart, $scanLimit]);
$done = 0;
foreach ($rows as $row) {
if ($done >= $limit) break;
$site = (string)$row['site'];
$oldKey = (string)$row['page_key'];
if (!brivaciaPageKeyNeedsLangRefresh($oldKey)) {
continue;
}
$failures = (int)($row['failures'] ?? 0);
$cooldown = match (true) {
$failures >= 5 => 86400,
$failures >= 3 => 3600,
$failures >= 1 => 600,
default => 0,
};
$lastTry = $row['last_try'] ?? null;
if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
continue;
}
$url = (string)($row['url'] ?? '');
$now = date('c');
brivaciaLog(
'pages/lang-recheck.log',
'TRY site=' . $site .
' key=' . brivaciaSafeLogPath($oldKey) .
' url=' . brivaciaSafeLogUrl($url) .
' failures=' . $failures
);
$resolved = brivaciaResolvePageMetadata($site, $oldKey, $url);
$lang = brivaciaNormalizeLangCode((string)($resolved['lang'] ?? ''));
if ($lang === '') {
$db->prepare('
UPDATE pages_daily
SET page_lang_last_try = ?, page_lang_failures = page_lang_failures + 1
WHERE site = ? AND page_key = ?
')->execute([$now, $site, $oldKey]);
$done++;
continue;
}
$newKey = $lang . ':' . brivaciaRawPageKey($oldKey);
brivaciaMergePageKey($db, $site, $oldKey, $newKey, $now);
brivaciaLog(
'pages/lang-resolved.log',
'site=' . $site . ' from=' . brivaciaSafeLogPath($oldKey) . ' to_lang=' . $lang
);
$done++;
}
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
// Same idea as refreshPageLanguages(), but scoped to an arbitrary SQL
// range instead of "the current year, a few per pixel hit" — meant to be
// called synchronously right before a year is archived+purged (see
// createYearArchive() in archive.php), so a page stuck on 'xx:' or a bare
// key doesn't get frozen into the static archive JSON forever just
// because the regular per-hit trickle hadn't caught up to it yet.
function refreshPageLanguagesForRange(PDO $db, string $rangeSql, array $rangeParams, int $limit = 100): void {
if ($limit <= 0) {
return;
}
$lockFile = dataDir() . '/pages-lang-refresh.lock';
$lockHandle = fopen($lockFile, 'c');
if (!$lockHandle) return;
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
fclose($lockHandle);
return;
}
$scanLimit = max(1, min(500, $limit * 3));
$rows = fetchAll($db, "
SELECT
site,
page_key,
MAX(url) AS url,
MAX(page_lang_failures) AS failures,
MAX(page_lang_last_try) AS last_try
FROM pages_daily
WHERE (page_key GLOB 'xx:*' OR page_key NOT GLOB '[a-z][a-z]:*')
AND " . $rangeSql . "
GROUP BY site, page_key
ORDER BY SUM(views) DESC, MAX(day) DESC
LIMIT ?
", array_merge($rangeParams, [$scanLimit]));
$done = 0;
foreach ($rows as $row) {
if ($done >= $limit) break;
$site = (string)$row['site'];
$oldKey = (string)$row['page_key'];
if (!brivaciaPageKeyNeedsLangRefresh($oldKey)) {
continue;
}
// Unlike the regular trickle job, this is a one-shot pre-archive
// pass — it still records failures for logging, but doesn't wait
// out the usual backoff cooldown, since there won't be another
// chance to retry after this year is purged.
$failures = (int)($row['failures'] ?? 0);
$url = (string)($row['url'] ?? '');
$now = date('c');
brivaciaLog(
'pages/lang-recheck.log',
'RANGE TRY site=' . $site .
' key=' . brivaciaSafeLogPath($oldKey) .
' url=' . brivaciaSafeLogUrl($url) .
' failures=' . $failures
);
$resolved = brivaciaResolvePageMetadata($site, $oldKey, $url);
$lang = brivaciaNormalizeLangCode((string)($resolved['lang'] ?? ''));
if ($lang === '') {
$db->prepare('
UPDATE pages_daily
SET page_lang_last_try = ?, page_lang_failures = page_lang_failures + 1
WHERE site = ? AND page_key = ?
')->execute([$now, $site, $oldKey]);
$done++;
continue;
}
$newKey = $lang . ':' . brivaciaRawPageKey($oldKey);
brivaciaMergePageKey($db, $site, $oldKey, $newKey, $now);
brivaciaLog(
'pages/lang-resolved.log',
'range site=' . $site . ' from=' . brivaciaSafeLogPath($oldKey) . ' to_lang=' . $lang
);
$done++;
}
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
function pageRulesSignature(): string {
$files = [
__DIR__ . '/rules.php',
__DIR__ . '/rules_custom.php',
];
$hash = '';
foreach ($files as $file) {
$hash .= is_file($file)
? md5_file($file)
: 'missing';
}
return md5($hash);
}
function normalizeStoredPages(PDO $db): void {
$db->exec("
UPDATE pages_daily
SET
page_resolved = 0,
page_failures = 0,
page_last_try = NULL
");
}
function maybeNormalizeStoredPages(PDO $db): void {
$stamp = dataDir() . '/pages-rules.stamp';
$current = pageRulesSignature();
$last = is_file($stamp)
? trim((string)file_get_contents($stamp))
: '';
if ($current === $last) {
return;
}
normalizeStoredPages($db);
file_put_contents($stamp, $current, LOCK_EX);
}
function unresolvedPagesCount(PDO $db): int {
$row = fetchAll($db, '
SELECT COUNT(*) AS total
FROM (
SELECT site, page_key
FROM pages_daily
WHERE page_resolved = 0
AND day >= ?
GROUP BY site, page_key
)
', [date('Y') . '-01-01'])[0] ?? [];
return (int)($row['total'] ?? 0);
}
function brivaciaMaintenancePageLimit(PDO $db): int {
$remaining = unresolvedPagesCount($db);
return match (true) {
$remaining > 10000 => 25,
$remaining > 5000 => 15,
$remaining > 1000 => 8,
$remaining > 100 => 5,
$remaining > 0 => 2,
default => 0,
};
}
/*
|--------------------------------------------------------------------------
| Countries
|--------------------------------------------------------------------------
*/
function normalizeCountryCode(string $country): string {
$country = trim($country);
if ($country === '' || $country === BRIVACIA_UNKNOWN) {
return 'XX';
}
$country = strtoupper(substr($country, 0, 2));
return preg_match('/^[A-Z]{2}$/', $country)
? $country
: 'XX';
}
function incCountry(PDO $db, string $day, string $country, string $site = ''): void {
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$country = normalizeCountryCode($country);
$db->prepare(
'INSERT OR IGNORE INTO countries_daily(site, day, country, views) VALUES(?, ?, ?, 0)'
)->execute([$site, $day, $country]);
$db->prepare(
'UPDATE countries_daily SET views = views + 1 WHERE site = ? AND day = ? AND country = ?'
)->execute([$site, $day, $country]);
}
function countryName(string $code): string {
$code = normalizeCountryCode($code);
if ($code === 'XX') {
return t('ui.unknown');
}
$lang = currentLang();
// Cached per-request: ResourceBundle::create() reads from disk, so
// this only pays that cost once no matter how many countries are
// rendered on a given page (bar/pie/map graphs, the countries card,
// the countries modal all call countryName() repeatedly).
static $shortTables = [];
if (!array_key_exists($lang, $shortTables)) {
$shortTables[$lang] = class_exists('ResourceBundle')
? \ResourceBundle::create($lang, 'ICUDATA-region')?->get('Countries%short')
: null;
}
$short = $shortTables[$lang]?->get($code);
// Generic sanity check, not a country list: ICU4C's "Countries%short"
// table is supposed to be for genuine disambiguation (Hong Kong vs.
// "Hong Kong SAR China", RD Congo vs. Congo), but in practice it's
// inconsistent across languages -- e.g. the English table contains
// plain abbreviations like "US" and "UK", not just disambiguation
// names. A real place name has a mix of upper/lowercase letters
// ("Hong Kong", "Macao"); a pure abbreviation, once punctuation and
// spaces are stripped, is entirely uppercase ("US", "UK", "É.-U." ->
// "ÉU"). Rejecting anything that looks like the latter and falling
// through to the full name is safer than trusting the table blindly.
if (is_string($short) && $short !== '') {
$lettersOnly = preg_replace('/[^\p{L}]/u', '', $short) ?? '';
$looksLikeAbbreviation = $lettersOnly !== '' && $lettersOnly === mb_strtoupper($lettersOnly, 'UTF-8');
if (!$looksLikeAbbreviation) {
return $short;
}
}
if (class_exists('Locale')) {
$long = \Locale::getDisplayRegion('-' . $code, $lang) ?: $code;
// One-off exception, not the start of a list: the Vatican is the
// only case in this app's two languages where the ICU long name
// has a chunky, droppable prefix/suffix around the actual name
// ("État de la Cité du Vatican" / "Vatican City") rather than
// being a genuinely different (and unshortenable) official name.
if ($code === 'VA') {
$long = str_replace(['État de la Cité du ', ' City'], '', $long);
}
return $long;
}
return $code;
}
function countryFlagUrl(string $country): string
{
$country = strtolower(normalizeCountryCode($country));
$base = dirname(__DIR__);
foreach (['svg', 'png', 'webp', 'jpg', 'jpeg', 'ico'] as $ext) {
// /static/images/... survives updates (see update_blocked_dirs());
// /assets/images/... is Brivacia's own built-in asset and gets
// overwritten on update, so a custom flag placed there would be
// silently lost on the next update.
$customFile = '/static/images/flags/' . $country . '.' . $ext;
if (is_file($base . $customFile)) {
return $customFile;
}
$builtInFile = '/assets/images/flags/' . $country . '.' . $ext;
if (is_file($base . $builtInFile)) {
return $builtInFile;
}
}
return '/assets/images/flags/xx.png';
}
function countryFlag(string $country): string
{
return '<img class="flag" src="' . h(countryFlagUrl($country)) . '" alt="">';
}
/*
|--------------------------------------------------------------------------
| Language to country mapping
|--------------------------------------------------------------------------
|
| Some ISO 639 language codes differ from ISO 3166 country codes.
| Only the exceptions are listed here.
|
*/
function pageLanguageCode(string $pageKey): string
{
if (!preg_match('/^([a-z]{2})(?:-[a-z]{2})?:/i', trim($pageKey), $m)) {
return '';
}
return strtolower($m[1]);
}
function pageLanguageCountry(string $pageKey): string
{
$lang = pageLanguageCode($pageKey);
if ($lang === '') {
return '';
}
return match ($lang) {
'cs' => 'cz', // Čeština
'da' => 'dk', // Dansk
'el' => 'gr', // Ελληνικά
'en' => 'us', // English
'ja' => 'jp', // 日本語
'ko' => 'kr', // 한국어
'uk' => 'ua', // Українська
'zh' => 'cn', // 中文
default => normalizeCountryCode($lang),
};
}
/*
|--------------------------------------------------------------------------
| Geo provider
|--------------------------------------------------------------------------
|
| Resolves visitor geo data using the configured provider.
|
| BlurLoc receives only a truncated IPv4 prefix, never the full IP.
| Cloudflare is supported for convenience, but is not recommended for
| privacy-first deployments.
|
*/
function geoLookup(string $ip): array {
return match (brivacia_setting('privacy.country_provider', 'none')) {
'blurloc' => geoLookupBlurLoc($ip),
'cloudflare' => geoLookupCloudflare(),
default => geoDisabled(),
};
}
function geoDisabled(): array {
return [
'provider' => 'none',
'country' => BRIVACIA_UNKNOWN,
'vpn' => null,
'prefix' => '',
];
}
function countryFromIp(string $ip): string {
return (string)geoLookup($ip)['country'];
}
/*
| Cloudflare uses the CF-IPCountry header when it is available.
| This only works when the request reaches Brivacia through Cloudflare
| or through a proxy/CDN that provides a compatible header.
| It does not send the visitor IP to Cloudflare from Brivacia.
| However, if Cloudflare is used in front of the site, Cloudflare already
| receives the full visitor IP before Brivacia runs.
*/
function geoLookupCloudflare(): array {
$country = normalizeCountryCode(
$_SERVER['HTTP_CF_IPCOUNTRY'] ?? BRIVACIA_UNKNOWN
);
return [
'provider' => 'cloudflare',
'country' => $country,
'vpn' => null,
'prefix' => '',
];
}
/*
|--------------------------------------------------------------------------
| BlurLoc
|--------------------------------------------------------------------------
|
| Privacy-first geolocation provider.
|
| Only a truncated IPv4 prefix is sent to BlurLoc, never the full
| address. Depending on the configured prefix length, country accuracy
| may be reduced compared to traditional IP geolocation services.
|
| This trade-off is intentional: protecting visitor privacy is preferred
| over obtaining perfectly accurate geolocation data.
|
| The provider can also return VPN detection information when available.
|
*/
function geoLookupBlurLoc(string $ip): array {
$prefix = blurLocIpPrefix($ip);
if ($prefix === '') {
return geoDisabled();
}
$url = 'https://blurloc.com/lookup/' . rawurlencode($prefix);
$json = @file_get_contents($url, false, brivaciaHttpContext(2));
if (!$json) {
brivaciaLog('providers/blurloc.log', 'error=request_failed prefix=' . $prefix);
return geoDisabled();
}
$data = json_decode($json, true);
if (!is_array($data)) {
brivaciaLog('providers/blurloc.log', 'error=invalid_json prefix=' . $prefix);
return geoDisabled();
}
return [
'provider' => 'blurloc',
'country' => normalizeCountryCode($data['country_code'] ?? BRIVACIA_UNKNOWN),
'vpn' => array_key_exists('is_vpn', $data) ? (bool)$data['is_vpn'] : null,
'prefix' => $prefix,
];
}
function blurLocIpPrefix(string $ip): string {
$prefix = brivaciaIpPrefix($ip);
if ($prefix === '') {
brivaciaLog(
'providers/blurloc.log',
'error=invalid_ip family=' .
(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6' : 'invalid')
);
return '';
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
? $prefix . '.'
: $prefix;
}
function geoVpnLabel(?bool $vpn): string {
if ($vpn === null) {
return t('ui.unknown');
}
return $vpn ? t('ui.yes') : t('ui.no');
}
/*
|--------------------------------------------------------------------------
| Referrer icons
|--------------------------------------------------------------------------
|
| Favicon files are cached locally under /static/images/referrers.
|
*/
function safeIconName(string $source): string {
$source = strtolower(trim($source));
$source = preg_replace('/[^a-z0-9.-]+/', '-', $source) ?? '';
return trim($source, '-') ?: 'unknown';
}
function referrerIconUrl(string $source): string {
if ($source === BRIVACIA_BLOCKED) {
return '/assets/images/icons/blocked.svg';
}
$source = referrerCanonical($source);
$icon = referrerIcon($source);
if ($icon === '') {
return '/assets/images/flags/xx.png';
}
return '/static/images/referrers/' . $icon;
}
function referrerIconHtml(string $source): string {
if ($source === BRIVACIA_BLOCKED) {
return icon('blocked');
}
return '<img alt="" class="referrer" src="' . h(referrerIconUrl($source)) . '">';
}
function referrerIcon(string $source): string {
$source = referrerCanonical(trim($source));
if ($source === BRIVACIA_UNKNOWN) {
return '';
}
if ($source === mainSiteHost()) {
return safeIconName(mainSiteHost()) . '.png';
}
$dir = __DIR__ . '/../static/images/referrers';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$name = safeIconName($source);
foreach (['svg', 'png', 'webp', 'jpg', 'ico'] as $ext) {
if (is_file("$dir/$name.$ext")) {
return "$name.$ext";
}
}
// No cached icon: never fetch it live from here. This function runs
// during page rendering (dashboard cards, the "since the start" modal,
// etc.), so it must never block a response on outbound network I/O —
// that's exactly what made the dashboard slow before. Fetching missing
// icons is refreshReferrerIcons()'s job, called from the same
// background-ish maintenance step as label resolution (see
// maybeNormalizeStoredReferrers() in pixel.php).
return '';
}
function refreshReferrerIcons(int $limit = 5): void {
if (!brivacia_setting('referrers.auto_referrer_icons', true)) {
return;
}
$dir = __DIR__ . '/../static/images/referrers';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$done = 0;
foreach (referrerLabels() as $canonical => $rule) {
if ($done >= $limit) break;
$canonical = rootHost((string)$canonical);
if ($canonical === '' || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $canonical)) continue;
$name = safeIconName($canonical);
$hasIcon = false;
foreach (['svg', 'png', 'webp', 'jpg', 'ico'] as $ext) {
if (is_file("$dir/$name.$ext")) {
$hasIcon = true;
break;
}
}
if ($hasIcon) continue;
$failFile = "$dir/$name.fail";
if (is_file($failFile) && filemtime($failFile) > time() - 86400) continue;
$fetchStart = microtime(true);
if (fetchFavicon($canonical, "$dir/$name")) {
@unlink($failFile);
brivaciaLog(
'referrers/favicon.log',
'OK host=' . $canonical .
' fetch=' . round((microtime(true) - $fetchStart) * 1000, 1) . 'ms'
);
} else {
@touch($failFile);
brivaciaLog(
'referrers/favicon.log',
'FAIL host=' . $canonical .
' fetch=' . round((microtime(true) - $fetchStart) * 1000, 1) . 'ms'
);
}
$done++;
}
}
function fetchFavicon(string $host, string $targetBase): bool {
$host = strtolower(trim($host));
if ($host === '' || $host === BRIVACIA_UNKNOWN || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $host)) {
return false;
}
if (!brivaciaIsPublicHost($host)) {
brivaciaLog('referrers/favicon.log', 'BLOCK private_or_reserved host=' . $host);
return false;
}
$home = "https://$host/";
$html = @file_get_contents($home, false, brivaciaHttpContext());
$candidates = [];
if ($html) {
if (preg_match_all('~<link[^>]+>~i', $html, $links)) {
foreach ($links[0] as $tag) {
if (!preg_match('~rel=["\']([^"\']+)["\']~i', $tag, $rel)) continue;
if (!str_contains(strtolower($rel[1]), 'icon')) continue;
if (!preg_match('~href=["\']([^"\']+)["\']~i', $tag, $href)) continue;
$hrefValue = trim(html_entity_decode($href[1], ENT_QUOTES, 'UTF-8'));
if ($hrefValue === '') continue;
$candidates[] = [iconScore($tag), resolveIconUrl($home, $hrefValue)];
}
}
}
$candidates[] = [75, "https://$host/apple-touch-icon.png"];
$candidates[] = [70, "https://$host/favicon.svg"];
$candidates[] = [65, "https://$host/favicon.png"];
$candidates[] = [60, "https://$host/apple-touch-icon-precomposed.png"];
$candidates[] = [0, "https://$host/favicon.ico"];
rsort($candidates);
$fallback = null;
foreach ($candidates as [, $url]) {
if ($url === '' || !preg_match('~^https?://~i', $url)) continue;
$prepared = brivaciaPrepareIconData($url);
if ($prepared === null) continue;
// SVGs are vector and essentially always meant to be transparent
// wherever they don't draw anything, so they're accepted as-is
// without a pixel-level check. Raster formats are checked for a
// real alpha channel — apple-touch-icon in particular is very
// often a fully opaque PNG/JPG with a plain background, since
// Apple applies its own rounded mask at display time rather than
// expecting the icon itself to carry transparency.
$isTransparent = $prepared['ext'] === 'svg'
|| brivaciaIconHasTransparency($prepared['data']);
if ($isTransparent) {
file_put_contents($targetBase . '.' . $prepared['ext'], $prepared['data'], LOCK_EX);
return true;
}
// Keep the first opaque result as a last resort — still better
// than falling back to the generic unknown placeholder — but
// keep looking in case a later, genuinely transparent candidate
// turns up.
$fallback ??= $prepared;
}
if ($fallback !== null) {
file_put_contents($targetBase . '.' . $fallback['ext'], $fallback['data'], LOCK_EX);
return true;
}
return false;
}
// Fetches and prepares one icon candidate's bytes (download, extract the
// best frame if it's an .ico, resize if oversized) without writing
// anything to disk yet — lets fetchFavicon() evaluate several candidates
// (e.g. for transparency) before committing to one.
function brivaciaPrepareIconData(string $url): ?array {
$host = strtolower(trim((string)parse_url($url, PHP_URL_HOST)));
if (!is_string($host) || $host === '' || !brivaciaIsPublicHost($host)) {
brivaciaLog('referrers/favicon.log', 'BLOCK private_or_reserved_url');
return null;
}
$data = @file_get_contents($url, false, brivaciaHttpContext());
if (!$data || strlen($data) < 50 || strlen($data) > brivacia_setting('referrers.max_icon_bytes', 102400)) {
return null;
}
$ext = iconExtFromUrlOrData($url, $data);
// GD cannot decode .ico files at all (imagecreatefromstring() always
// fails on them), so without this step the resize below silently
// no-ops and whatever the server returned — often just a 16x16 or
// 32x32 legacy frame — would be kept completely unscaled. Modern
// .ico files embed their larger frames as plain PNG data inside the
// ICO container, so the largest frame can usually be pulled out and
// treated as a normal PNG from here on.
if ($ext === 'ico') {
$frame = brivaciaExtractBestIcoFrame($data);
if ($frame !== null) {
$data = $frame;
$ext = 'png';
}
}
if ($ext !== 'svg') {
$resized = resizeIcon($data);
if ($resized !== $data) {
$data = $resized;
$ext = 'png';
}
}
return ['data' => $data, 'ext' => $ext];
}
function downloadIcon(string $url, string $targetBase): bool {
$prepared = brivaciaPrepareIconData($url);
if ($prepared === null) {
return false;
}
file_put_contents($targetBase . '.' . $prepared['ext'], $prepared['data'], LOCK_EX);
return true;
}
// Samples a grid of pixels looking for any real alpha transparency.
// Returns false (treated as "not confirmed transparent", so callers fall
// back rather than trust it blindly) when GD can't decode the image at all.
function brivaciaIconHasTransparency(string $data): bool {
$img = @imagecreatefromstring($data);
if (!$img) {
return false;
}
$width = imagesx($img);
$height = imagesy($img);
$grid = 12;
for ($i = 0; $i < $grid; $i++) {
for ($j = 0; $j < $grid; $j++) {
$x = (int)(($i + 0.5) / $grid * $width);
$y = (int)(($j + 0.5) / $grid * $height);
$rgba = imagecolorat($img, $x, $y);
$alpha = ($rgba >> 24) & 0x7F; // GD: 0 = opaque, 127 = fully transparent
if ($alpha > 4) {
imagedestroy($img);
return true;
}
}
}
imagedestroy($img);
return false;
}
function iconScore(string $tag): int {
$tagLower = strtolower($tag);
$score = 0;
// <link rel="mask-icon"> is explicitly a single-color icon by spec —
// browsers recolor it themselves (Safari pinned tabs, etc.), it was
// never meant to represent the brand visually on its own. It must
// never be able to outscore a real apple-touch-icon or rel="icon",
// so this is checked first and returns early rather than stacking
// with the generic "svg" bonus below.
if (str_contains($tagLower, 'mask-icon')) {
return 5;
}
if (str_contains($tagLower, 'apple-touch-icon')) $score += 150;
if (str_contains($tagLower, 'svg')) $score += 80;
if (str_contains($tagLower, 'png')) $score += 60;
if (str_contains($tagLower, 'shortcut icon')) $score += 10;
if (preg_match('~sizes=["\'](\d+)x(\d+)["\']~i', $tag, $m)) {
$size = (int)$m[1];
if ($size === brivacia_setting('referrers.max_icon_size', 96)) {
$score += 10000; // best possible match
} elseif ($size > brivacia_setting('referrers.max_icon_size', 96)) {
$score += 5000 - min($size - brivacia_setting('referrers.max_icon_size', 96), 4000);
} else {
$score += 1000 + $size;
}
}
return $score;
}
function resolveIconUrl(string $base, string $href): string {
if (preg_match('~^https?://~i', $href)) return $href;
$u = parse_url($base);
$origin = $u['scheme'] . '://' . $u['host'];
if (str_starts_with($href, '//')) return $u['scheme'] . ':' . $href;
if (str_starts_with($href, '/')) return $origin . $href;
return rtrim($origin, '/') . '/' . ltrim($href, '/');
}
// Parses an ICO file's directory to find its largest embedded frame and
// return it, but only if that frame is itself a PNG byte stream (the
// modern convention for anything above ~32x32) — GD can decode that part
// fine even though it can't parse the ICO container itself. Older-style
// frames stored as a raw BMP DIB are left alone (returns null) rather
// than risk producing corrupt output.
function brivaciaExtractBestIcoFrame(string $ico): ?string {
if (strlen($ico) < 6) {
return null;
}
$header = unpack('vreserved/vtype/vcount', substr($ico, 0, 6));
if (!$header || $header['type'] !== 1 || $header['count'] < 1) {
return null;
}
$best = null;
$bestArea = -1;
for ($i = 0; $i < $header['count']; $i++) {
$offset = 6 + ($i * 16);
if (strlen($ico) < $offset + 16) {
break;
}
$entry = unpack(
'Cwidth/Cheight/CcolorCount/Creserved/vplanes/vbpp/Vsize/Voffset',
substr($ico, $offset, 16)
);
if (!$entry) {
continue;
}
$width = $entry['width'] === 0 ? 256 : $entry['width'];
$height = $entry['height'] === 0 ? 256 : $entry['height'];
$area = $width * $height;
if ($area > $bestArea) {
$bestArea = $area;
$best = $entry;
}
}
if ($best === null || strlen($ico) < $best['offset'] + $best['size']) {
return null;
}
$frame = substr($ico, $best['offset'], $best['size']);
return str_starts_with($frame, "\x89PNG\r\n\x1a\n") ? $frame : null;
}
function resizeIcon(string $data): string {
$img = @imagecreatefromstring($data);
if (!$img) {
return $data;
}
$width = imagesx($img);
$height = imagesy($img);
if (
$width <= brivacia_setting('referrers.max_icon_size', 96) &&
$height <= brivacia_setting('referrers.max_icon_size', 96)
) {
imagedestroy($img);
return $data;
}
$ratio = min(
brivacia_setting('referrers.max_icon_size', 96) / $width,
brivacia_setting('referrers.max_icon_size', 96) / $height
);
$newWidth = max(1, (int)round($width * $ratio));
$newHeight = max(1, (int)round($height * $ratio));
$dst = imagecreatetruecolor($newWidth, $newHeight);
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled(
$dst,
$img,
0, 0, 0, 0,
$newWidth,
$newHeight,
$width,
$height
);
ob_start();
imagepng($dst);
$result = ob_get_clean();
imagedestroy($img);
imagedestroy($dst);
return $result ?: $data;
}
function iconExtFromUrlOrData(string $url, string $data): string {
$path = strtolower(parse_url($url, PHP_URL_PATH) ?? '');
if (str_ends_with($path, '.png')) return 'png';
if (str_ends_with($path, '.svg')) return 'svg';
if (str_ends_with($path, '.jpg') || str_ends_with($path, '.jpeg')) return 'jpg';
if (str_ends_with($path, '.webp')) return 'webp';
if (str_ends_with($path, '.ico')) return 'ico';
if (str_starts_with($data, '<svg')) return 'svg';
if (str_starts_with($data, "\x89PNG")) return 'png';
if (str_starts_with($data, "\xFF\xD8")) return 'jpg';
if (str_starts_with($data, 'RIFF')) return 'webp';
return 'ico';
}
/*
|--------------------------------------------------------------------------
| Referrer categories
|--------------------------------------------------------------------------
*/
if (!defined('BRIVACIA_CATEGORY_BLOCKED')) {
define('BRIVACIA_CATEGORY_BLOCKED', 'blocked');
}
if (!defined('BRIVACIA_CATEGORY_REFERRER')) {
define('BRIVACIA_CATEGORY_REFERRER', 'referrer');
}
if (!defined('BRIVACIA_CATEGORY_SEARCH')) {
define('BRIVACIA_CATEGORY_SEARCH', 'search');
}
/*
|--------------------------------------------------------------------------
| Referrer labels management
|--------------------------------------------------------------------------
|
| referrers.json stores metadata for external referrers.
| New referrers are discovered immediately from the pixel.
|
*/
function referrersFile(): string {
return dataDir() . '/referrers.json';
}
function referrerLabels(bool $fresh = false): array {
static $cache = null;
if ($fresh) {$cache = null;}
if ($cache !== null) return $cache;
$file = referrersFile();
if (!is_file($file)) {
file_put_contents($file, "{}", LOCK_EX);
return $cache = [];
}
$json = json_decode((string)file_get_contents($file), true);
return $cache = is_array($json) ? normalizeReferrerLabels($json) : [];
}
function saveReferrerLabels(array $labels): void {
$labels = normalizeReferrerLabels($labels);
file_put_contents(
referrersFile(),
json_encode($labels, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
}
/* Helpers */
function normalizeHost(string $host): string {
$host = strtolower(trim($host));
if (str_starts_with($host, 'www.')) $host = substr($host, 4);
return $host;
}
function rootHost(string $host): string {
$parts = explode('.', normalizeHost($host));
return count($parts) <= 2 ? normalizeHost($host) : implode('.', array_slice($parts, -2));
}
function fallbackReferrerLabel(string $host): string {
return rootHost($host);
}
function normalizeReferrerLabels(array $labels): array {
$normalized = [];
foreach ($labels as $key => $rule) {
if (!is_array($rule)) continue;
$host = normalizeHost((string)$key);
if ($host === '' || $host === BRIVACIA_UNKNOWN) continue;
$canonical = rootHost((string)($rule['canonical'] ?? $host));
if (!isset($normalized[$canonical])) {
$normalized[$canonical] = [
'auto' => (bool)($rule['auto'] ?? brivacia_setting('referrers.auto_referrers', true)),
'category' => (string)($rule['category'] ?? BRIVACIA_CATEGORY_REFERRER),
'failures' => (int)($rule['failures'] ?? 0),
'label' => (string)($rule['label'] ?? fallbackReferrerLabel($canonical)),
'last_try' => $rule['last_try'] ?? null,
'resolved' => (bool)($rule['resolved'] ?? false),
'updated' => (string)($rule['updated'] ?? date('Y-m-d')),
'urls' => [],
];
}
$urls = array_map('normalizeHost', (array)($rule['urls'] ?? []));
$urls[] = $host;
$urls[] = $canonical;
foreach ($urls as $u) {
if ($u !== '' && $u !== BRIVACIA_UNKNOWN) $normalized[$canonical]['urls'][] = $u;
}
}
foreach ($normalized as $canonical => &$rule) {
$rule['urls'] = array_values(array_unique(array_filter($rule['urls'])));
sort($rule['urls'], SORT_NATURAL | SORT_FLAG_CASE);
$rule = [
'auto' => (bool)($rule['auto'] ?? false),
'category' => in_array($rule['category'] ?? BRIVACIA_CATEGORY_REFERRER, [BRIVACIA_CATEGORY_REFERRER, BRIVACIA_CATEGORY_SEARCH, BRIVACIA_CATEGORY_BLOCKED], true)
? $rule['category'] : BRIVACIA_CATEGORY_REFERRER,
'failures' => (int)($rule['failures'] ?? 0),
'label' => (string)($rule['label'] ?? $canonical),
'last_try' => $rule['last_try'] ?? null,
'resolved' => (bool)($rule['resolved'] ?? false),
'updated' => (string)($rule['updated'] ?? date('Y-m-d')),
'urls' => $rule['urls'],
];
}
unset($rule);
ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
return $normalized;
}
// Finds the canonical entry a host already belongs to — as its own
// top-level key, listed inside another entry's `urls` (manually grouped
// domains that don't share a root, e.g. regional TLDs or a rebrand onto a
// different domain), or via the root-domain fallback. Returns null if the
// host isn't known under any existing entry yet.
function findKnownReferrerCanonical(string $host, array $labels): ?string {
if (isset($labels[$host])) {
return $host;
}
foreach ($labels as $canonical => $rule) {
foreach (array_map('normalizeHost', (array)($rule['urls'] ?? [])) as $url) {
if ($host === $url || str_ends_with($host, '.' . $url)) {
return $canonical;
}
}
}
$root = rootHost($host);
return isset($labels[$root]) ? $root : null;
}
function referrerRuleForHost(string $host): array {
$host = normalizeHost($host);
if ($host === '' || $host === BRIVACIA_UNKNOWN) return [BRIVACIA_UNKNOWN, []];
$labels = referrerLabels();
$canonical = findKnownReferrerCanonical($host, $labels);
return $canonical !== null
? [$canonical, $labels[$canonical]]
: [rootHost($host), []];
}
function referrerCanonical(string $host): string {
[$c] = referrerRuleForHost($host);
return $c;
}
function referrerLabel(string $host): string {
[$c, $r] = referrerRuleForHost($host);
if ($c === BRIVACIA_UNKNOWN) return t('ui.unknown');
$label = (string)($r['label'] ?? $c);
return $label === BRIVACIA_UNKNOWN ? t('ui.unknown') : $label;
}
function referrerCategory(string $host): string {
[$c, $r] = referrerRuleForHost($host);
$cat = (string)($r['category'] ?? BRIVACIA_CATEGORY_REFERRER);
return in_array($cat, [BRIVACIA_CATEGORY_REFERRER, BRIVACIA_CATEGORY_SEARCH, BRIVACIA_CATEGORY_BLOCKED], true) ? $cat : BRIVACIA_CATEGORY_REFERRER;
}
function isSearchReferrer(string $host): bool {
return referrerCategory($host) === BRIVACIA_CATEGORY_SEARCH;
}
function isOwnHost(string $host): bool {
$main = mainSiteHost();
return $host === $main || str_ends_with($host, '.' . $main);
}
/* Discovery */
function discoverReferrer(string $host): void {
$host = normalizeHost($host);
if ($host === '' || $host === BRIVACIA_UNKNOWN || isOwnHost($host)) return;
$labels = referrerLabels();
// Check whether this host is already known — as its own entry, or
// grouped inside another entry's `urls` (e.g. a regional TLD or a
// rebrand domain manually merged into a canonical entry that doesn't
// share its root). Without this, a host that was already grouped
// elsewhere would get re-created here as its own separate entry the
// moment it shows up in traffic again, silently splitting it back out
// of the grouping — findKnownReferrerCanonical() is the same lookup
// referrerRuleForHost() uses for that reason.
$canonical = findKnownReferrerCanonical($host, $labels);
$changed = false;
if ($canonical === null) {
$canonical = rootHost($host);
if ($canonical === '') return;
$labels[$canonical] = [
'auto' => brivacia_setting('referrers.auto_referrers', true),
// A host that's already on the blocklist at discovery time is
// created as 'blocked' straight away instead of 'referrer' —
// saves a manual edit, and stops it from being picked up by
// the automatic label-fetch pass (refreshReferrerLabels())
// for a domain that's never going to be shown anyway.
'category' => referrerBlocked($host) ? BRIVACIA_CATEGORY_BLOCKED : BRIVACIA_CATEGORY_REFERRER,
'urls' => [$canonical, $host],
'failures' => 0,
'label' => fallbackReferrerLabel($canonical),
'last_try' => null,
'resolved' => false,
'updated' => date('Y-m-d'),
];
$changed = true;
} else if (!in_array($host, array_map('normalizeHost', (array)($labels[$canonical]['urls'] ?? [])), true)) {
$labels[$canonical]['urls'][] = $host;
$changed = true;
}
if ($changed) saveReferrerLabels($labels);
}
function refreshReferrerLabels(int $limit = 5): void {
$lockFile = dataDir() . '/referrers-refresh.lock';
$lockHandle = fopen($lockFile, 'c');
if (!$lockHandle || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
fclose($lockHandle ?? null);
return;
}
$labels = referrerLabels(true);
$done = 0;
foreach ($labels as $canonical => $rule) {
if ($done >= $limit) break;
if (!($rule['auto'] ?? false) || ($rule['resolved'] ?? false)) continue;
$canonical = rootHost((string)$canonical);
if ($canonical === '' || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $canonical)) continue;
$failures = (int)($rule['failures'] ?? 0);
$cooldown = match(true) {
$failures >= 5 => 7*86400,
$failures >= 3 => 86400,
$failures >= 1 => 3600,
default => 0
};
$lastTry = $rule['last_try'] ?? null;
if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
brivaciaLog(
'referrers/label.log',
'SKIP cooldown host=' . $canonical .
' failures=' . $failures .
' last_try=' . $lastTry .
' cooldown_until=' . date('c', strtotime((string)$lastTry) + $cooldown)
);
continue;
}
// fetchSiteLabel() does real network I/O (up to a few seconds per
// host, with a timeout). Save right after this single attempt
// instead of waiting until the whole batch of $limit referrers is
// done: if the request gets killed by a PHP/webserver execution
// time limit partway through, whatever was already resolved here
// — and the last_try/failures bookkeeping that drives the retry
// cooldown — is not lost. Without this, a batch that never
// finishes in time never saves anything at all, so the same
// unresolved referrers get retried (and time out) forever.
$fetchStart = microtime(true);
$label = fetchSiteLabel($canonical);
$fetchMs = round((microtime(true) - $fetchStart) * 1000, 1);
$labels[$canonical]['last_try'] = date('c');
if ($label === '') {
$labels[$canonical]['failures'] = $failures + 1;
brivaciaLog(
'referrers/label.log',
'FAIL host=' . $canonical .
' fetch=' . $fetchMs . 'ms' .
' failures=' . ($failures + 1)
);
} else {
$labels[$canonical]['label'] = $label;
$labels[$canonical]['resolved'] = true;
$labels[$canonical]['failures'] = 0;
$labels[$canonical]['updated'] = date('Y-m-d');
brivaciaLog(
'referrers/label.log',
'OK host=' . $canonical .
' label=' . $label .
' fetch=' . $fetchMs . 'ms'
);
}
saveReferrerLabels($labels);
$done++;
}
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
/* Label discovery */
function looksLikeBotChallengePage(string $html): bool {
if (!preg_match('~<title[^>]*>(.*?)</title>~is', $html, $m)) {
return false;
}
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES, 'UTF-8'));
// Real site titles don't start with these — they're specific enough
// to Cloudflare/generic bot-challenge interstitials that checking
// just the title (instead of scanning the whole body for script
// markers like cdn-cgi/challenge-platform, which plenty of normal,
// unblocked pages also load) avoids false-positiving on real sites.
return (bool)preg_match(
'~^(just a moment|attention required|checking your browser|please wait|access denied|one moment|verifying you are human)~i',
$title
);
}
function fetchSiteLabel(string $host): string {
$hostsToTry = [$host];
if (!str_starts_with($host, 'www.')) $hostsToTry[] = 'www.' . $host;
foreach ($hostsToTry as $tryHost) {
$url = 'https://' . $tryHost . '/';
$html = @file_get_contents($url, false, brivaciaHttpContext(3));
$status = $http_response_header[0] ?? 'no_status_line';
if (!$html) {
brivaciaLog(
'referrers/label.log',
'DETAIL host=' . $host . ' try=' . $tryHost . ' result=no_response status="' . $status . '"'
);
continue;
}
brivaciaLog(
'referrers/label.log',
'DETAIL host=' . $host . ' try=' . $tryHost . ' result=response status="' . $status . '" bytes=' . strlen($html)
);
// Sites behind bot-protection (Cloudflare and similar) serve an
// interstitial "checking your browser" page instead of the real
// homepage to a plain fetch like this one. That page still has a
// <title> (often literally "Just a moment..."), which would
// otherwise get saved as if it were the site's actual name. Treat
// it as a failed attempt instead of a resolved label.
if (looksLikeBotChallengePage($html)) {
brivaciaLog(
'referrers/label.log',
'DETAIL host=' . $host . ' try=' . $tryHost . ' result=bot_challenge_page'
);
continue;
}
if (preg_match('~<link[^>]+rel=["\']manifest["\'][^>]*href=["\']([^"\']+)["\']~i', $html, $m)) {
$manifestUrl = resolveIconUrl($url, $m[1]);
$label = fetchManifestLabelFromUrl($host, $manifestUrl);
if ($label !== '') return $label;
}
if (preg_match('~<meta[^>]+(?:property|name)=["\'](?:og:site_name|application-name)["\'][^>]*content=["\']([^"\']+)["\']~i', $html, $m)) {
$label = cleanFetchedSiteLabel($m[1], $host);
if ($label !== '') return $label;
}
if (preg_match('~<title[^>]*>(.*?)</title>~is', $html, $m)) {
$label = cleanFetchedSiteLabel($m[1], $host);
if ($label !== '') return $label;
}
brivaciaLog(
'referrers/label.log',
'DETAIL host=' . $host . ' try=' . $tryHost . ' result=no_usable_manifest_meta_or_title'
);
}
return '';
}
function fetchManifestLabelFromUrl(string $host, string $manifestUrl): string {
$json = @file_get_contents($manifestUrl, false, brivaciaHttpContext(3));
if (!$json) return '';
$data = json_decode($json, true);
if (!is_array($data)) return '';
$name = trim((string)($data['short_name'] ?? $data['name'] ?? ''));
return $name !== '' ? cleanFetchedSiteLabel($name, $host) : '';
}
function cleanFetchedSiteLabel(string $label, string $host): string {
$label = html_entity_decode(strip_tags(trim($label)), ENT_QUOTES, 'UTF-8');
$label = preg_replace('~\s+~u', ' ', $label) ?? $label;
$label = trim($label);
// Titles are frequently "Brand | Tagline", "Brand - Tagline", or
// "Brand.com. Tagline. More tagline." — keep only the brand part
// instead of storing the whole marketing tagline as the referrer's
// display name.
$parts = preg_split('~\s+(?:\||—|–|::|»|:)\s+|\s+-\s+~u', $label);
if ($parts !== false && count($parts) > 1 && trim($parts[0]) !== '') {
$label = trim($parts[0]);
} else {
// No pipe/dash-style separator found — try a sentence-style split
// (e.g. "Amazon.com. Spend less. Smile more.") and keep the first
// clause if that leaves something non-empty.
$sentenceParts = preg_split('~\.\s+~u', $label);
if ($sentenceParts !== false && count($sentenceParts) > 1 && trim($sentenceParts[0]) !== '') {
$label = trim($sentenceParts[0]);
}
}
// "Amazon.com", "Booking.com"-style titles just repeat the domain with
// its TLD; keep only the brand part before it.
if (preg_match('~^([\p{L}\p{N} ]+)\.(?:com|net|org|io|co|app|dev|shop|store)$~ui', $label, $tldMatch)) {
$label = trim($tldMatch[1]);
}
return mb_substr(trim($label), 0, 80, 'UTF-8');
}
/*
|--------------------------------------------------------------------------
| Pixel helpers
|--------------------------------------------------------------------------
*/
function refSourceFast(string $ref): string {
$host = normalizeHost(parse_url($ref, PHP_URL_HOST) ?? '');
if ($host === '') return BRIVACIA_UNKNOWN;
if (isOwnHost($host)) return mainSiteHost();
return substr($host, 0, 120);
}
function canonicalReferrerSource(string $source): string {
$source = trim($source);
$lower = mb_strtolower($source, 'UTF-8');
if ($source === '' || $lower === BRIVACIA_UNKNOWN || $lower === 'inconnu') return BRIVACIA_UNKNOWN;
if (in_array($lower, ['newtab', 'new tab', 'about:newtab']) || str_contains($lower, 'android-app://com.android.chrome')) return mainSiteHost();
if (str_contains($lower, 'googlequicksearchbox')) return 'google.com';
$host = normalizeHost((string)(parse_url($source, PHP_URL_HOST) ?? ''));
if ($host !== '') return isOwnHost($host) ? mainSiteHost() : referrerCanonical($host);
$host = normalizeHost($source);
return isOwnHost($host) ? mainSiteHost() : referrerCanonical($host);
}
function maybeNormalizeStoredReferrers(PDO $db): void {
// The database is intentionally unused.
unset($db);
cleanupBlockedReferrerIcons();
refreshReferrerIcons(6);
refreshReferrerLabels(6);
}
/*
|--------------------------------------------------------------------------
| Referrer blocklist
|--------------------------------------------------------------------------
|
| User-editable file:
| /data/referrers_blocklist.json
|
| Blocked referrers are NOT deleted and are NOT ignored at write time.
| They stay in the database so historical totals remain correct.
| Dashboard code groups them under the virtual BRIVACIA_BLOCKED row.
|
*/
function referrersBlocklistFile(): string {
return dataDir() . '/referrers_blocklist.json';
}
function referrersBlocklist(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$file = referrersBlocklistFile();
$json = json_decode((string)file_get_contents($file), true);
if (!is_array($json)) {
$json = [];
}
return $cache = [
'hosts' => array_values(array_filter(array_map('normalizeHost', (array)($json['hosts'] ?? [])))),
'contains' => array_values(array_filter(array_map('strval', (array)($json['contains'] ?? [])))),
];
}
function referrerBlocked(string $host): bool {
$host = normalizeHost($host);
if ($host === '' || $host === BRIVACIA_UNKNOWN) {
return false;
}
$canonical = rootHost($host);
$blocklist = referrersBlocklist();
foreach ($blocklist['hosts'] as $blocked) {
$blocked = rootHost($blocked);
if ($host === $blocked || $canonical === $blocked || str_ends_with($host, '.' . $blocked)) {
return true;
}
}
foreach ($blocklist['contains'] as $needle) {
$needle = strtolower(trim($needle));
if ($needle !== '' && str_contains($host, $needle)) {
return true;
}
}
return false;
}
/*
|--------------------------------------------------------------------------
| Cleanup blocked referrer icons
|--------------------------------------------------------------------------
|
| Blocked referrers should never keep downloaded favicons or failed lookup
| markers. Remove them so the cache always matches the current blocklist.
|
*/
function cleanupBlockedReferrerIcons(): void {
$dir = __DIR__ . '/../static/images/referrers';
if (!is_dir($dir)) {
brivaciaLog('referrers/cleanup.log', 'SKIP missing_dir=' . $dir);
return;
}
$files = glob($dir . '/*') ?: [];
$deleted = 0;
foreach ($files as $file) {
if (!is_file($file)) {
continue;
}
$base = basename($file);
if (!preg_match('~^(.+)\.(svg|png|webp|jpg|jpeg|ico|fail)$~i', $base, $m)) {
continue;
}
$host = (string)$m[1];
if (!referrerBlocked($host)) {
continue;
}
if (@unlink($file)) {
$deleted++;
brivaciaLog('referrers/cleanup.log', 'DELETE file=' . $base . ' host=' . $host);
} else {
brivaciaLog('referrers/cleanup.log', 'FAIL_DELETE file=' . $base . ' host=' . $host);
}
}
brivaciaLog('referrers/cleanup.log', 'DONE files=' . count($files) . ' deleted=' . $deleted);
}
/*
|--------------------------------------------------------------------------
| Referrer statistics and page URLs
|--------------------------------------------------------------------------
|
| Handles referrer counters and page URL reconstruction.
|
| Page URLs can be generated from stored URLs, page identifiers or
| optional page mapping files.
|
*/
function incReferrer(PDO $db, string $day, string $source, string $site = ''): void {
$site = $site !== '' ? $site : array_key_first(brivacia_sites());
$source = canonicalReferrerSource($source);
$source = substr($source, 0, 120);
$db->prepare(
'INSERT OR IGNORE INTO referrers_daily(site, day, referrer, views) VALUES(?, ?, ?, 0)'
)->execute([$site, $day, $source]);
$db->prepare(
'UPDATE referrers_daily SET views = views + 1 WHERE site = ? AND day = ? AND referrer = ?'
)->execute([$site, $day, $source]);
}
function pageUrl(string $site, string $pageKey, string $url = ''): string {
$default = brivaciaPageUrl($site, $pageKey, $url);
$result = brivaciaRunCustomRules($site, [
'pageKey' => $pageKey,
'url' => $url,
'pageUrl' => $default,
]);
return (string)($result['pageUrl'] ?? $default);
}
/*
|--------------------------------------------------------------------------
| Generic SQL helper
|--------------------------------------------------------------------------
*/
function fetchAll(PDO $db, string $sql, array $params = []): array {
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/*
|--------------------------------------------------------------------------
| Output helpers
|--------------------------------------------------------------------------
|
| Small helpers used by templates.
|
*/
function h(string|int|float|bool|null $value): string {
return htmlspecialchars(
(string)$value,
ENT_QUOTES,
'UTF-8'
);
}
/*
|--------------------------------------------------------------------------
| Internationalization
|--------------------------------------------------------------------------
|
| English is always loaded as the base language.
|
| When a matching translation exists in /assets/i18n, it is merged over
| English using the visitor's Accept-Language header.
|
| Missing translations automatically fall back to English.
|
| Translations support optional placeholders:
|
| t('privacy.title', [
| 'instance' => brivacia_setting('dashboard.instance_name', 'Brivacia'),
| ]);
|
| Translation:
|
| "What does {site} know about me?"
|
*/
$GLOBALS['brivacia_lang'] = [];
function currentLang(): string {
$lang = strtolower(substr(
$_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en',
0,
2
));
return $lang !== '' ? $lang : 'en';
}
function currentDir(): string {
return in_array(currentLang(), ['ar', 'fa', 'he', 'ur'], true)
? 'rtl'
: 'ltr';
}
function loadTranslations(): void {
$lang = currentLang();
$enFile = __DIR__ . '/../assets/i18n/en.json';
$langFile = __DIR__ . '/../assets/i18n/' . $lang . '.json';
$translations = [];
if (is_file($enFile)) {
$translations = json_decode(
(string) file_get_contents($enFile),
true
) ?: [];
}
if (
$lang !== 'en' &&
is_file($langFile)
) {
$translations = array_replace(
$translations,
json_decode(
(string) file_get_contents($langFile),
true
) ?: []
);
}
$GLOBALS['brivacia_lang'] = $translations;
}
function t(string $key, array $replace = []): string {
$text = (string)(
$GLOBALS['brivacia_lang'][$key]
?? '[[' . $key . ']]'
);
foreach ($replace as $name => $value) {
$text = str_replace(
'{' . $name . '}',
(string)$value,
$text
);
}
return $text;
}
/*
|--------------------------------------------------------------------------
| Icons
|--------------------------------------------------------------------------
*/
function icon(string $name): string {
static $icons = [];
if (!isset($icons[$name])) {
$icons[$name] = file_get_contents(
__DIR__ . '/../assets/images/icons/' . $name . '.svg'
);
}
return $icons[$name];
}
/*
|--------------------------------------------------------------------------
| External links
|--------------------------------------------------------------------------
|
| Generates a link that opens in a new tab.
|
| The external icon is optional and can be disabled per call.
|
*/
function externalLink( string $url, string $label, bool $showIcon = true, ?string $tooltip = null, string $tooltipPlacement = 'bottom'): string {
return '<a aria-label="' . h($label . ' - ' . t('ui.external')) . '"'
. ($tooltip !== null ? ' data-tooltip="' . h($tooltip) . '"' : '')
. ($tooltip !== null ? ' data-tooltip-placement="' . h($tooltipPlacement) . '"' : '')
. ' href="' . h($url) . '"'
. ' rel="noreferrer noopener"'
. ' target="_blank"'
. '>'
. h($label)
. ($showIcon ? icon('external') : '')
. '</a>';
}
/*
|--------------------------------------------------------------------------
| Dashboard Branding
|--------------------------------------------------------------------------
|
| Users may override the default dashboard branding by placing assets in
| /static/images. Files are selected automatically according to the
| current site code.
|
| Examples:
| - /static/images/favicon-sitecode.EXT
| - /static/images/logo-sitecode.EXT
|
| If no matching asset is found, the built-in Brivacia branding is used.
|
*/
function brivaciaFaviconPlaceholder(): string
{
return !empty(brivacia_setting('dashboard.light_theme'))
? '/assets/images/logo-light.png'
: '/assets/images/logo-dark.png';
}
function brivaciaLogoPlaceholder(): string
{
return !empty(brivacia_setting('dashboard.light_theme'))
? '/assets/images/logo-light.png'
: '/assets/images/logo-dark.png';
}
$theme = !empty(brivacia_setting('dashboard.light_theme')) ? 'light' : 'dark';
$currentSite = trim((string)($_GET['site'] ?? ''));
if ($currentSite !== '' && !isset(brivacia_sites()[$currentSite])) {
$currentSite = '';
}
$siteTitle = $currentSite !== ''
? $currentSite
: brivacia_setting('dashboard.instance_name', 'Brivacia');
function assetOrPlaceholder(string $prefix, string $fallback, string $siteCode = ''): string {
$base = dirname(__DIR__);
foreach (['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif', 'ico'] as $ext) {
if ($siteCode !== '') {
$siteUrl = "/static/images/{$prefix}-{$siteCode}.{$ext}";
if (is_file($base . $siteUrl)) {
return $siteUrl;
}
}
$globalUrl = "/static/images/{$prefix}.{$ext}";
if (is_file($base . $globalUrl)) {
return $globalUrl;
}
}
return $fallback;
}
/*
|--------------------------------------------------------------------------
| Branding footer
|--------------------------------------------------------------------------
|
| Displays the Brivacia attribution footer.
|
*/
function brivaciaBreatfrLogoIcon(): string { return '/assets/images/icons/breat.fr.png'; }