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();
}
/*
|--------------------------------------------------------------------------
| 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 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') . ' ' . $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 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,
PRIMARY KEY (site, day, visitor_hash)
);
");
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, '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 ''");
$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(): bool {
$file = archiveDir() . '/check-year.txt';
$year = date('Y');
return !is_file($file) || trim((string)file_get_contents($file)) !== $year;
}
function brivaciaMarkYearArchiveChecked(): void {
file_put_contents(archiveDir() . '/check-year.txt', date('Y'), LOCK_EX);
}
/*
|--------------------------------------------------------------------------
| 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;
}
function param(string $key, string $default = ''): string {
return trim((string)($_GET[$key] ?? $default));
}
/*
|--------------------------------------------------------------------------
| Visitor identity and classification
|--------------------------------------------------------------------------
*/
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 ipPrefix(string $ip): string {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return ($parts[0] ?? '') . '.' . ($parts[1] ?? '');
}
return substr($ip, 0, 12);
}
function visitorHash(string $day, string $ua, string $ip): string {
return hash(
'sha256',
$day . '|' .
ipPrefix($ip) . '|' .
$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]);
}
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;
$db->prepare('
UPDATE visitor_sessions
SET last_seen = ?
WHERE site = ? AND day = ? AND visitor_hash = ?
')->execute([$now, $site, $day, $hash]);
return $isNewSession;
}
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 {
$isFallback = brivaciaShouldLogPageFallback($key, $title, $url);
if ($isFallback) {
brivaciaLog(
'pixel/page-fallback.log',
'site=' . $site .
' key=' . $key .
' title=' . ($title !== '' ? $title : '[empty]') .
' url=' . ($url !== '' ? $url : '[empty]')
);
}
$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();
}
/*
|--------------------------------------------------------------------------
| Internal HTTP client
|--------------------------------------------------------------------------
*/
function brivaciaHttpContext(int $timeout = 1) {
return stream_context_create([
'http' => [
'timeout' => $timeout,
'user_agent' => 'Brivacia privacy-focused analytics favicon fetcher',
],
]);
}
/*
|--------------------------------------------------------------------------
| 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=' . ($url !== '' ? $url : '[empty]') .
' failures=' . $failures
);
$resolved = brivaciaResolveMetadata($site, $pageKey, $url);
brivaciaLog(
'pages/recheck.log',
'RESULT site=' . $site .
' key=' . $pageKey .
' title=' . (($resolved['title'] ?? '') !== '' ? $resolved['title'] : '[empty]') .
' url=' . (($resolved['url'] ?? '') !== '' ? $resolved['url'] : '[empty]')
);
$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=' . $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=' . ($url !== '' ? $url : '[empty]') .
' 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=' . $resolved['url']
);
$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');
}
if (class_exists('Locale')) {
return \Locale::getDisplayRegion('-' . $code, currentLang()) ?: $code;
}
return $code;
}
function countryFlagUrl(string $country): string {
$country = strtolower(normalizeCountryCode($country));
foreach (['svg', 'png', 'webp', 'jpg', 'jpeg', 'ico'] as $ext) {
$file = '/assets/images/flags/' . $country . '.' . $ext;
if (is_file(dirname(__DIR__) . $file)) {
return $file;
}
}
return '/assets/images/flags/xx.png';
}
function countryFlag(string $country): string {
return '<img class="flag" src="' . h(countryFlagUrl($country)) . '" alt="">';
}
/*
|--------------------------------------------------------------------------
| 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' => '',
'country' => 'XX',
'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((string)($data['country_code'] ?? BRIVACIA_UNKNOWN)),
'vpn' => array_key_exists('is_vpn', $data) ? (bool)$data['is_vpn'] : null,
'prefix' => $prefix,
];
}
function blurLocIpPrefix(string $ip): string {
if ($ip === '') {
brivaciaLog('providers/blurloc.log', 'error=empty_ip');
return '';
}
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
brivaciaLog(
'providers/blurloc.log',
'error=not_ipv4 family=' .
(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6' : 'invalid')
);
return '';
}
$parts = explode('.', $ip);
$octets = max(
1,
min(3, brivacia_setting('privacy.ip_prefix_octets', 2))
);
if (count($parts) < $octets) {
brivaciaLog('providers/blurloc.log', 'error=too_few_octets');
return '';
}
return implode('.', array_slice($parts, 0, $octets)) . '.';
}
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 '';
}
$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 {
$totalStart = microtime(true);
$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")) {
brivaciaLog(
'referrers/favicon.log',
'CACHE host=' . $source .
' time=' . round((microtime(true) - $totalStart) * 1000, 1) . 'ms'
);
return "$name.$ext";
}
}
if (!brivacia_setting('referrers.auto_referrer_icons', true)) {
return '';
}
if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $source)) {
return '';
}
$failFile = "$dir/$name.fail";
if (is_file($failFile) && filemtime($failFile) > time() - 86400) {
brivaciaLog(
'referrers/favicon.log',
'SKIP cooldown host=' . $source .
' time=' . round((microtime(true) - $totalStart) * 1000, 1) . 'ms'
);
return '';
}
$fetchStart = microtime(true);
if (fetchFavicon($source, "$dir/$name")) {
@unlink($failFile);
brivaciaLog(
'referrers/favicon.log',
'OK host=' . $source .
' fetch=' . round((microtime(true) - $fetchStart) * 1000, 1) . 'ms' .
' total=' . round((microtime(true) - $totalStart) * 1000, 1) . 'ms'
);
} else {
@touch($failFile);
brivaciaLog(
'referrers/favicon.log',
'FAIL host=' . $source .
' fetch=' . round((microtime(true) - $fetchStart) * 1000, 1) . 'ms' .
' total=' . round((microtime(true) - $totalStart) * 1000, 1) . 'ms'
);
}
foreach (['svg', 'png', 'webp', 'jpg', 'ico'] as $ext) {
if (is_file("$dir/$name.$ext")) {
return "$name.$ext";
}
}
return '';
}
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;
}
$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[] = [70, "https://$host/favicon.svg"];
$candidates[] = [65, "https://$host/favicon.png"];
$candidates[] = [60, "https://$host/apple-touch-icon.png"];
$candidates[] = [55, "https://$host/apple-touch-icon-precomposed.png"];
$candidates[] = [0, "https://$host/favicon.ico"];
rsort($candidates);
foreach ($candidates as [, $url]) {
if ($url === '' || !preg_match('~^https?://~i', $url)) continue;
if (downloadIcon($url, $targetBase)) return true;
}
return false;
}
function iconScore(string $tag): int {
$tagLower = strtolower($tag);
$score = 0;
if (str_contains($tagLower, 'apple-touch-icon')) $score += 100;
if (str_contains($tagLower, 'mask-icon')) $score += 20;
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, '/');
}
function downloadIcon(string $url, string $targetBase): bool {
$data = @file_get_contents($url, false, brivaciaHttpContext());
if (!$data || strlen($data) < 50 || strlen($data) > brivacia_setting('referrers.max_icon_bytes', 102400)) {
return false;
}
$ext = iconExtFromUrlOrData($url, $data);
if ($ext !== 'svg') {
$resized = resizeIcon($data);
if ($resized !== $data) {
$data = $resized;
$ext = 'png';
}
}
file_put_contents($targetBase . '.' . $ext, $data, LOCK_EX);
return true;
}
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;
}
function referrerRuleForHost(string $host): array {
$host = normalizeHost($host);
if ($host === '' || $host === BRIVACIA_UNKNOWN) return [BRIVACIA_UNKNOWN, []];
$labels = referrerLabels();
if (isset($labels[$host])) return [$host, $labels[$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, $rule];
}
}
}
$canonical = rootHost($host);
if (isset($labels[$canonical])) return [$canonical, $labels[$canonical]];
return [$canonical, []];
}
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;
$canonical = rootHost($host);
if ($canonical === '') return;
$labels = referrerLabels();
$changed = false;
if (!isset($labels[$canonical])) {
$labels[$canonical] = [
'auto' => brivacia_setting('referrers.auto_referrers', true),
'category' => 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);
$changed = false;
$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) continue;
$label = fetchSiteLabel($canonical);
$labels[$canonical]['last_try'] = date('c');
$changed = true;
if ($label === '') {
$labels[$canonical]['failures'] = $failures + 1;
} else {
$labels[$canonical]['label'] = $label;
$labels[$canonical]['resolved'] = true;
$labels[$canonical]['failures'] = 0;
$labels[$canonical]['updated'] = date('Y-m-d');
}
$done++;
}
if ($changed) saveReferrerLabels($labels);
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
/* Label discovery */
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));
if ($html) {
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;
}
}
}
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;
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();
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 '/assets/images/favicon.ico'; }
function brivaciaLogoPlaceholder(): string { return '/assets/images/logo.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 {
foreach (['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif', 'ico'] as $ext) {
$url = "/static/images/{$prefix}-{$siteCode}.{$ext}";
if ($siteCode !== '' && is_file(dirname(__DIR__) . $url)) {
return $url;
}
}
return $fallback;
}
/*
|--------------------------------------------------------------------------
| Branding footer
|--------------------------------------------------------------------------
|
| Displays the Brivacia attribution footer.
|
| The free version keeps this footer visible.
| Supporter builds may choose to hide it.
|
*/
function brivaciaLogoIcon(): string { return '/assets/images/icons/breat.fr.png'; }
function brivaciaBreatfrLogoIcon(): string { return '/assets/images/icons/breat.fr.png'; }