v1.0.0
PriviMetrics 2
- <?php
-
- declare(strict_types=1);
- require_once __DIR__ . '/functions.php';
- require_once __DIR__ . '/font-options.php';
-
- if (!envIsInstalled()) {
- header('Location: install.php');
- exit;
- }
- require_once __DIR__ . '/storage.php';
- require_once __DIR__ . '/updater-functions.php';
-
- requireLogin();
- pmSecurityHeaders();
-
- $storage = new StorageManager();
- $csrfToken = generateCSRFToken();
- $flash = null;
-
- function resolveReturnUrl(?string $raw): string
- {
- $default = 'dashboard.php';
- if (!$raw) return $default;
- if (preg_match('#^(https?:)?//#i', $raw) || str_starts_with($raw, '\\')) {
- return $default;
- }
- if (!preg_match('#^[A-Za-z0-9_\-]+\.php(\?[^\s]*)?$#', $raw)) {
- return $default;
- }
- return $raw;
- }
-
- $returnUrl = resolveReturnUrl($_POST['return'] ?? $_GET['return'] ?? null);
-
-
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- if (!verifyCSRFToken($_POST['csrf_token'] ?? null)) {
- $flash = ['type' => 'error', 'text' => 'Invalid security token.'];
- } else {
- $action = $_POST['action'] ?? '';
-
- if ($action === 'general') {
- $ok = envSetMany([
- 'SITE_NAME' => sanitize($_POST['site_name'] ?? 'PriviMetrics'),
- 'DEFAULT_THEME' => in_array($_POST['default_theme'] ?? '', ['dark', 'light'], true) ? $_POST['default_theme'] : 'dark',
- 'RESPECT_DNT' => isset($_POST['respect_dnt']) ? 'true' : 'false',
- 'TRACK_IP_DEFAULT' => isset($_POST['track_ip_default']) ? 'true' : 'false',
- 'SESSION_TIMEOUT' => (string) max(300, (int) ($_POST['session_timeout'] ?? 86400)),
- 'TOP_LISTS_LIMIT' => (string) max(1, min(50, (int) ($_POST['top_lists_limit'] ?? 8))),
- ]);
- $flash = $ok ? ['type' => 'success', 'text' => 'General settings saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
- } elseif ($action === 'fonts') {
- $bodySlug = $_POST['font_body'] ?? '';
- $headingSlug = $_POST['font_heading'] ?? '';
- $valueSlug = $_POST['font_value'] ?? '';
-
- $ok = envSetMany([
- 'FONT_BODY' => array_key_exists($bodySlug, fontOptions('body')) ? $bodySlug : fontDefaultSlug('body'),
- 'FONT_HEADING' => array_key_exists($headingSlug, fontOptions('heading')) ? $headingSlug : fontDefaultSlug('heading'),
- 'FONT_VALUE' => array_key_exists($valueSlug, fontOptions('value')) ? $valueSlug : fontDefaultSlug('value'),
- ]);
- $flash = $ok ? ['type' => 'success', 'text' => 'Fonts saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
- } elseif ($action === 'geo') {
- $ok = envSetMany([
- 'GEO_PROVIDER' => $_POST['geo_provider'] === 'external' ? 'external' : 'privacy-friendly',
- 'GEO_EXTERNAL_SERVICE' => in_array($_POST['geo_external_service'] ?? '', ['ip-api', 'ipinfo', 'ipstack', 'ip2location', 'dbip'], true) ? $_POST['geo_external_service'] : 'ip-api',
- 'GEO_API_KEY' => trim((string) ($_POST['geo_api_key'] ?? '')),
- ]);
- $flash = $ok ? ['type' => 'success', 'text' => 'Geolocation settings saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
- } elseif ($action === 'data_collection') {
- $ok = envSetMany([
- 'FEATURE_TRAFFIC_SOURCES' => isset($_POST['feature_traffic_sources']) ? 'true' : 'false',
- 'FEATURE_VISITOR_COUNTRIES' => isset($_POST['feature_visitor_countries']) ? 'true' : 'false',
- 'FEATURE_SEARCH_QUERIES' => isset($_POST['feature_search_queries']) ? 'true' : 'false',
- 'FEATURE_DEVICE_TYPE' => isset($_POST['feature_device_type']) ? 'true' : 'false',
- ]);
- $flash = $ok ? ['type' => 'success', 'text' => 'Data collection settings saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
- } elseif ($action === 'rate_limits') {
- $ok = envSetMany([
- 'RATE_LIMIT_XML' => (string) max(0, (int) ($_POST['rate_limit_xml'] ?? 2)),
- 'RATE_LIMIT_MYSQL' => (string) max(0, (int) ($_POST['rate_limit_mysql'] ?? 5)),
- 'RATE_LIMIT_PMAF' => (string) max(0, (int) ($_POST['rate_limit_pmaf'] ?? 5)),
- ]);
- $flash = $ok ? ['type' => 'success', 'text' => 'Rate limits saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
- } elseif ($action === 'mysql') {
- $enable = isset($_POST['db_enabled']);
- $dbHost = trim((string) ($_POST['db_host'] ?? 'localhost'));
- $dbPort = trim((string) ($_POST['db_port'] ?? '3306'));
- $dbName = trim((string) ($_POST['db_name'] ?? ''));
- $dbUser = trim((string) ($_POST['db_user'] ?? ''));
- $dbPass = (string) ($_POST['db_pass'] ?? '');
- $keepPass = $dbPass === '' ? env('DB_PASS', '') : $dbPass;
-
- $testOk = true;
- $paramsError = $enable ? pmDbParamsError($dbHost, $dbPort, $dbName) : null;
- if ($paramsError !== null) {
- $testOk = false;
- $flash = ['type' => 'error', 'text' => $paramsError];
- } elseif ($enable) {
- try {
- $testPdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4", $dbUser, $keepPass, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- ]);
- } catch (PDOException $e) {
- $testOk = false;
- $flash = ['type' => 'error', 'text' => 'Could not connect to MySQL: ' . $e->getMessage()];
- }
- }
-
- if ($testOk) {
- $ok = envSetMany([
- 'DB_ENABLED' => $enable ? 'true' : 'false',
- 'DB_HOST' => $dbHost,
- 'DB_PORT' => $dbPort,
- 'DB_NAME' => $dbName,
- 'DB_USER' => $dbUser,
- 'DB_PASS' => $keepPass,
- ]);
- if ($ok && $enable) {
- $freshStorage = new StorageManager();
- if ($freshStorage->isMySQLAvailable()) {
- $freshStorage->ensureSchema();
- }
- }
- $flash = $ok ? ['type' => 'success', 'text' => 'MySQL configuration saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
- }
- } elseif ($action === 'password') {
- $current = (string) ($_POST['current_password'] ?? '');
- $new = (string) ($_POST['new_password'] ?? '');
- $confirm = (string) ($_POST['new_password_confirm'] ?? '');
- $validHash = env('ADMIN_PASSWORD_HASH', '');
-
- if (!verifyPassword($current, $validHash)) {
- $flash = ['type' => 'error', 'text' => 'Current password is incorrect.'];
- } elseif (strlen($new) < 8) {
- $flash = ['type' => 'error', 'text' => 'New password must be at least 8 characters.'];
- } elseif ($new !== $confirm) {
- $flash = ['type' => 'error', 'text' => 'New passwords do not match.'];
- } else {
- $ok = envSetMany(['ADMIN_PASSWORD_HASH' => hashPassword($new)]);
- $flash = $ok ? ['type' => 'success', 'text' => 'Password changed successfully.'] : ['type' => 'error', 'text' => 'Save failed.'];
- }
- }
- }
- }
-
- $currentVersion = pmUpdateCurrent();
-
- $siteName = env('SITE_NAME', 'PriviMetrics');
- $theme = env('DEFAULT_THEME', 'dark');
- ?>
- <!DOCTYPE html>
- <html lang="en" data-theme="<?= sanitize($theme) ?>">
-
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Settings - <?= sanitize($siteName) ?></title>
- <?= fontFaceStyleTag() ?>
- <script>
- (function() {
- var t = localStorage.getItem('pm_theme') || '<?= sanitize($theme) ?>';
- document.documentElement.setAttribute('data-theme', t);
-
- if (sessionStorage.getItem('pm_intro_played')) {
- document.documentElement.setAttribute('data-no-intro', '1');
- } else {
- sessionStorage.setItem('pm_intro_played', '1');
- }
- })();
- </script>
- <style>
- :root {
- --font-body: <?= fontFamilyCss('body') ?>;
- --font-heading: <?= fontFamilyCss('heading') ?>;
- --font-value: <?= fontFamilyCss('value') ?>;
- }
-
- :root[data-theme="dark"] {
- --bg-primary: #0a0a0a;
- --bg-secondary: #141414;
- --bg-tertiary: #1c1c1c;
- --bg-hover: #222222;
- --border-color: #262626;
- --text-primary: #eaeaea;
- --text-secondary: #9a9a9a;
- --text-tertiary: #6b6b6b;
- --accent: #f1484e;
- --accent-hover: #d53b40;
- --accent-soft: rgba(241, 72, 78, 0.12);
- --success: #22c55e;
- --success-soft: rgba(34, 197, 94, 0.12);
- }
-
- :root[data-theme="light"] {
- --bg-primary: #f7f7f8;
- --bg-secondary: #ffffff;
- --bg-tertiary: #f2f2f3;
- --bg-hover: #ececed;
- --border-color: #e5e5e7;
- --text-primary: #16171a;
- --text-secondary: #6b6d73;
- --text-tertiary: #9799a0;
- --accent: #f1484e;
- --accent-hover: #d53b40;
- --accent-soft: rgba(241, 72, 78, 0.08);
- --success: #16a34a;
- --success-soft: rgba(22, 163, 74, 0.1);
- }
-
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
-
- html {
- overflow-y: scroll;
- scrollbar-gutter: stable;
- }
-
- body {
- font-family: var(--font-body);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 14px;
- line-height: 1.5;
- }
-
- h1 {
- font-family: var(--font-heading);
- }
-
- a {
- color: inherit;
- text-decoration: none;
- }
-
- .app {
- display: flex;
- min-height: 100vh;
- }
-
- .sidebar {
- width: 250px;
- flex-shrink: 0;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- display: flex;
- flex-direction: column;
- padding: 18px 14px;
- position: sticky;
- top: 14px;
- align-self: flex-start;
- margin: 14px 0 14px 14px;
- height: calc(100vh - 28px);
- overflow-y: auto;
- scrollbar-width: none;
- -ms-overflow-style: none;
- z-index: 10;
- }
-
- .sidebar::-webkit-scrollbar {
- display: none;
- }
-
- .brand {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 8px 18px;
- margin-bottom: 12px;
- border-bottom: 1px solid var(--border-color);
- }
-
- .brand .mark {
- width: 34px;
- height: 34px;
- border-radius: 9px;
- background: var(--accent);
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: 700;
- color: white;
- font-size: 14px;
- flex-shrink: 0;
- }
-
- .brand .name {
- font-family: var(--font-heading);
- font-weight: 600;
- font-size: 15px;
- }
-
- .brand .sub {
- font-size: 11px;
- color: var(--text-tertiary);
- }
-
- .nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
-
- .nav a {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 9px 12px;
- border-radius: 10px;
- font-size: 13.5px;
- font-weight: 500;
- color: var(--text-secondary);
- border: 1px solid transparent;
- transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
- }
-
- .nav a svg {
- width: 17px;
- height: 17px;
- flex-shrink: 0;
- }
-
- .nav a:hover {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- transform: translateX(2px);
- }
-
- .nav a.active {
- background: var(--accent-soft);
- color: var(--accent);
- font-weight: 600;
- border-color: color-mix(in srgb, var(--accent) 22%, transparent);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
- transform: none;
- }
-
- .sidebar-footer {
- margin-top: auto;
- padding-top: 14px;
- border-top: 1px solid var(--border-color);
- }
-
- .sidebar-footer a {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 9px 12px;
- border-radius: 8px;
- font-size: 13px;
- color: var(--text-secondary);
- }
-
- .sidebar-footer a:hover {
- background: var(--bg-hover);
- color: var(--text-primary);
- }
-
- .sidebar-footer .user {
- font-size: 12px;
- color: var(--text-tertiary);
- padding: 6px 12px 10px;
- }
-
- .main {
- flex: 1;
- min-width: 0;
- }
-
- .topbar {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 16px 28px;
- margin: 14px 14px 0;
- border: 1px solid var(--border-color);
- border-radius: 16px;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- min-height: 68px;
- box-sizing: border-box;
- }
-
- .topbar h1 {
- font-size: 17px;
- font-weight: 600;
- }
-
- .mobile-topbar {
- display: none;
- }
-
- .mobile-menu-btn {
- width: 38px;
- height: 38px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- }
-
- @media (max-width: 880px) {
- .app {
- flex-direction: column;
- }
-
- .sidebar {
- position: fixed;
- top: auto;
- bottom: calc(8px + env(safe-area-inset-bottom));
- left: calc(8px + env(safe-area-inset-left));
- right: calc(8px + env(safe-area-inset-right));
- width: auto;
- height: auto;
- margin: 0;
- flex-direction: row;
- align-items: center;
- padding: 6px;
- border-radius: 24px;
- border-color: var(--border-color);
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
- overflow: visible;
- z-index: 60;
- }
-
- .sidebar .brand,
- .sidebar-footer .user {
- display: none;
- }
-
- .nav {
- flex: 3 1 0;
- min-width: 0;
- flex-direction: row;
- justify-content: space-around;
- gap: 0;
- }
-
- .sidebar-footer {
- margin: 0;
- padding: 0;
- border: none;
- flex: 1 1 0;
- min-width: 0;
- display: flex;
- }
-
- .nav a,
- .sidebar-footer a {
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 3px;
- padding: 8px 4px;
- font-size: 10px;
- font-weight: 600;
- border-radius: 18px;
- text-align: center;
- flex: 1;
- transform: none;
- }
-
- .nav a:hover {
- transform: none;
- }
-
- .nav a svg {
- width: 20px;
- height: 20px;
- }
-
- .sidebar-footer a svg {
- width: 20px;
- height: 20px;
- }
-
- .sidebar-backdrop,
- .sidebar-backdrop.show {
- display: none;
- }
-
- .mobile-menu-btn {
- display: none;
- }
-
- .main {
- background: var(--bg-secondary);
- overflow-x: clip;
- }
-
- .mobile-topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- background: var(--bg-primary);
- position: sticky;
- top: 0;
- z-index: 30;
- }
-
- .mobile-topbar::after {
- content: "";
- position: absolute;
- left: 0;
- right: 0;
- top: calc(100% - 1px);
- height: 24px;
- pointer-events: none;
- background:
- radial-gradient(circle at 100% 100%, transparent 23.5px, var(--bg-primary) 24.5px) left top / 24px 24px no-repeat,
- radial-gradient(circle at 0 100%, transparent 23.5px, var(--bg-primary) 24.5px) right top / 24px 24px no-repeat;
- }
-
- .content {
- padding: 18px 16px 110px;
- }
-
- .topbar {
- padding: 14px 16px;
- margin: 12px 12px 0;
- }
- }
-
- .content {
- max-width: 720px;
- margin: 0 auto;
- padding: 28px 20px 60px;
- }
-
- .panel {
- position: relative;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- padding: 24px;
- margin-bottom: 18px;
- opacity: 0;
- transform: translateY(10px);
- animation: panelIn 0.45s ease forwards;
- }
-
- .content .panel:nth-of-type(2) {
- animation-delay: 0.05s;
- }
-
- .content .panel:nth-of-type(3) {
- animation-delay: 0.1s;
- }
-
- .content .panel:nth-of-type(4) {
- animation-delay: 0.15s;
- }
-
- .content .panel:nth-of-type(5) {
- animation-delay: 0.2s;
- }
-
- .content .panel:nth-of-type(6) {
- animation-delay: 0.25s;
- }
-
- @keyframes panelIn {
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
-
- @media (prefers-reduced-motion: reduce) {
- .panel {
- animation: none;
- opacity: 1;
- transform: none;
- }
- }
-
- :root[data-no-intro] .panel {
- animation: none;
- opacity: 1;
- transform: none;
- }
-
- .panel h2 {
- font-size: 15px;
- font-weight: 600;
- margin-bottom: 4px;
- }
-
- .panel .desc {
- font-size: 12.5px;
- color: var(--text-secondary);
- margin-bottom: 18px;
- }
-
- .form-group {
- margin-bottom: 16px;
- }
-
- .form-group label {
- display: block;
- font-size: 12.5px;
- font-weight: 500;
- color: var(--text-secondary);
- margin-bottom: 6px;
- }
-
- .form-group input,
- .form-group select {
- width: 100%;
- padding: 10px 12px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 10px;
- color: var(--text-primary);
- font-size: 13.5px;
- font-family: inherit;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
- }
-
- .form-group input:focus,
- .form-group select:focus {
- outline: none;
- border-color: var(--accent);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
- }
-
- .row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- gap: 12px;
- }
-
- @media (max-width: 560px) {
- .row {
- grid-template-columns: minmax(0, 1fr);
- }
- }
-
- .checkbox-row {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 16px;
- }
-
- .checkbox-row label {
- margin: 0;
- font-size: 13px;
- color: var(--text-primary);
- font-weight: 500;
- cursor: pointer;
- }
-
- .switch {
- position: relative;
- display: inline-block;
- width: 42px;
- height: 24px;
- flex-shrink: 0;
- }
-
- .switch input {
- position: absolute;
- opacity: 0;
- width: 100%;
- height: 100%;
- margin: 0;
- cursor: pointer;
- z-index: 1;
- }
-
- .switch .track {
- position: absolute;
- inset: 0;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 20px;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
- transition: background 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
- }
-
- .switch .track::after {
- content: '';
- position: absolute;
- top: 2px;
- left: 2px;
- width: 18px;
- height: 18px;
- background: white;
- border-radius: 50%;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
- transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
- }
-
- .switch input:checked+.track {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent));
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
- border-color: var(--accent);
- }
-
- .switch input:checked+.track::after {
- transform: translateX(18px);
- }
-
- .switch input:focus-visible+.track {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
- }
-
- .btn {
- padding: 9px 16px;
- border-radius: 10px;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- border: 1px solid color-mix(in srgb, var(--border-color) 60%, var(--text-tertiary));
- background: var(--bg-tertiary);
- color: var(--text-primary);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
- transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
- }
-
- .btn:hover {
- background: var(--bg-hover);
- }
-
- .btn:active {
- transform: scale(0.97);
- }
-
- .btn.primary {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent));
- border-color: var(--accent);
- color: white;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
- }
-
- .btn.primary:hover {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent-hover));
- box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.14);
- transform: translateY(-1px);
- }
-
- .btn.primary:active {
- transform: translateY(0) scale(0.98);
- }
-
- .flash {
- padding: 12px 16px;
- border-radius: 10px;
- font-size: 13px;
- margin-bottom: 18px;
- }
-
- .flash.success {
- background: var(--success-soft);
- color: var(--success);
- border: 1px solid var(--success);
- }
-
- .flash.error {
- background: var(--accent-soft);
- color: var(--accent);
- border: 1px solid var(--accent);
- }
-
- .hint {
- font-size: 12px;
- color: var(--text-tertiary);
- margin-top: -8px;
- margin-bottom: 16px;
- line-height: 1.5;
- }
-
- .field-warning {
- display: none;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: #b45309;
- background: rgba(180, 83, 9, 0.1);
- border: 1px solid rgba(180, 83, 9, 0.35);
- border-radius: 6px;
- padding: 6px 10px;
- margin-top: 6px;
- line-height: 1.4;
- }
-
- .field-warning.show {
- display: flex;
- }
-
- .field-warning svg {
- flex-shrink: 0;
- }
-
- #external-fields {
- display: <?= env('GEO_PROVIDER', 'privacy-friendly') === 'external' ? 'block' : 'none' ?>;
- }
-
- #mysql-cred-fields {
- display: <?= env('DB_ENABLED', 'false') === 'true' ? 'block' : 'none' ?>;
- }
- </style>
- </head>
-
- <body>
- <div class="app">
-
- <div class="sidebar-backdrop" id="backdrop" onclick="closeSidebar()"></div>
- <aside class="sidebar" id="sidebar">
- <div class="brand">
- <div class="mark">
- <img src="logo.webp" alt="Logo" width="24" height="24" loading="lazy">
- </div>
- <div>
- <div class="name"><?= sanitize($siteName) ?></div>
- <div class="sub">PriviMetrics 2</div>
- </div>
- </div>
- <nav class="nav">
- <a href="dashboard.php?view=overview">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M7 12v7M12 4v15M17 8v11"></path>
- </svg>
- Analytics
- </a>
- <a href="dashboard.php?view=sites">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"></path>
- <path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"></path>
- </svg>
- Sites
- </a>
- <a href="settings.php" class="active">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <circle cx="12" cy="12" r="3" />
- <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
- </svg>
- Settings
- </a>
- </nav>
- <div class="sidebar-footer">
- <div class="user">Signed in as <strong><?= sanitize($_SESSION['admin_username'] ?? '') ?></strong></div>
- <a href="logout.php">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
- <path d="M16 17l5-5-5-5" />
- <path d="M21 12H9" />
- </svg>
- Sign out
- </a>
- </div>
- </aside>
-
- <div class="main">
- <div class="mobile-topbar">
- <div class="mobile-menu-btn" onclick="openSidebar()">
- <svg width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <line x1="3" y1="12" x2="21" y2="12" />
- <line x1="3" y1="6" x2="21" y2="6" />
- <line x1="3" y1="18" x2="21" y2="18" />
- </svg>
- </div>
- <strong style="font-size:14px;"><?= sanitize($siteName) ?></strong>
- <div style="width:38px;"></div>
- </div>
-
- <div class="topbar">
- <h1>Settings</h1>
- <button type="button" class="btn" onclick="toggleTheme()" style="margin-left:auto;width:36px;height:36px;padding:0;display:flex;align-items:center;justify-content:center;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:8px;cursor:pointer;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <circle cx="12" cy="12" r="5" />
- <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
- </svg></button>
- </div>
-
- <div class="content">
- <?php if ($flash): ?><div class="flash <?= $flash['type'] ?>"><?= sanitize($flash['text']) ?></div><?php endif; ?>
-
- <div class="panel">
- <h2>General</h2>
- <p class="desc">Basic configuration for this installation.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="general">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
- <line x1="3" y1="9" x2="21" y2="9"></line>
- <line x1="9" y1="21" x2="9" y2="9"></line>
- </svg>Installation name</label>
- <input type="text" name="site_name" value="<?= sanitize(env('SITE_NAME', 'PriviMetrics')) ?>" required>
- </div>
- <div class="row">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="12" cy="12" r="5"></circle>
- <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
- </svg>Default theme</label>
- <select name="default_theme" id="default_theme" onchange="setTheme(this.value)">
- <option value="dark" <?= $theme === 'dark' ? 'selected' : '' ?>>Dark</option>
- <option value="light" <?= $theme === 'light' ? 'selected' : '' ?>>Light</option>
- </select>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="12" cy="12" r="10"></circle>
- <polyline points="12 6 12 12 16 14"></polyline>
- </svg>Session timeout (seconds)</label>
- <input type="number" name="session_timeout" value="<?= sanitize(env('SESSION_TIMEOUT', '86400')) ?>" min="300">
- </div>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <line x1="8" y1="6" x2="21" y2="6"></line>
- <line x1="8" y1="12" x2="21" y2="12"></line>
- <line x1="8" y1="18" x2="21" y2="18"></line>
- <line x1="3" y1="6" x2="3.01" y2="6"></line>
- <line x1="3" y1="12" x2="3.01" y2="12"></line>
- <line x1="3" y1="18" x2="3.01" y2="18"></line>
- </svg>Top lists max rows</label>
- <input type="number" name="top_lists_limit" value="<?= sanitize(env('TOP_LISTS_LIMIT', '8')) ?>" min="1" max="50">
- </div>
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="respect_dnt" id="respect_dnt" <?= env('RESPECT_DNT', 'true') === 'true' ? 'checked' : '' ?>>
- <span class="track"></span>
- </span>
- <label for="respect_dnt"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>Respect the Do Not Track header</label>
- </div>
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="track_ip_default" id="track_ip_default" <?= env('TRACK_IP_DEFAULT', 'false') === 'true' ? 'checked' : '' ?>>
- <span class="track"></span>
- </span>
- <label for="track_ip_default"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
- <circle cx="12" cy="12" r="3"></circle>
- </svg>Track partial IP by default (unless the script says otherwise)</label>
- </div>
- <button type="submit" class="btn primary">Save</button>
- </form>
- </div>
-
- <div class="panel">
- <h2>Data Collection</h2>
- <p class="desc">Turn specific kinds of tracking on or off across all sites. Any individual site can still override these from its own settings (Add/Edit site).</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="data_collection">
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="feature_traffic_sources" id="feature_traffic_sources" <?= pmFeatureGlobalEnabled('traffic_sources') ? 'checked' : '' ?>>
- <span class="track"></span>
- </span>
- <label for="feature_traffic_sources"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="2" y1="12" x2="22" y2="12"></line>
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
- </svg>Track traffic sources (referrers and utm_source)</label>
- </div>
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="feature_visitor_countries" id="feature_visitor_countries" <?= pmFeatureGlobalEnabled('visitor_countries') ? 'checked' : '' ?>>
- <span class="track"></span>
- </span>
- <label for="feature_visitor_countries"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
- <circle cx="12" cy="10" r="3"></circle>
- </svg>Track visitor countries</label>
- </div>
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="feature_search_queries" id="feature_search_queries" <?= pmFeatureGlobalEnabled('search_queries') ? 'checked' : '' ?>>
- <span class="track"></span>
- </span>
- <label for="feature_search_queries"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="11" cy="11" r="8"></circle>
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
- </svg>Track on-site search queries</label>
- </div>
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="feature_device_type" id="feature_device_type" <?= pmFeatureGlobalEnabled('device_type') ? 'checked' : '' ?>>
- <span class="track"></span>
- </span>
- <label for="feature_device_type"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
- <line x1="12" y1="18" x2="12.01" y2="18"></line>
- </svg>Track device type (mobile/tablet/desktop)</label>
- </div>
- <button type="submit" class="btn primary">Save</button>
- </form>
- </div>
-
- <div class="panel">
- <h2>System Version</h2>
- <p class="desc">Current application build and release details.</p>
-
- <?php
- $versionInfo = include __DIR__ . '/version.php';
- ?>
-
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
- <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
- <line x1="12" y1="22.08" x2="12" y2="12"></line>
- </svg>Version</label>
- <input type="text" value="<?= sanitize($versionInfo['version'] . ' ' . $versionInfo['stage']) ?>" disabled style="opacity: 0.8; cursor: not-allowed;">
- </div>
-
- <div class="row">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
- <line x1="16" y1="2" x2="16" y2="6"></line>
- <line x1="8" y1="2" x2="8" y2="6"></line>
- <line x1="3" y1="10" x2="21" y2="10"></line>
- </svg>Release Date</label>
- <input type="text" value="<?= sanitize($versionInfo['release_date']) ?>" disabled style="opacity: 0.8; cursor: not-allowed;">
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
- </svg>Stability Status</label>
- <input type="text" value="<?= $versionInfo['stable'] ? 'Stable' : 'Unstable / Testing' ?>" disabled style="opacity: 0.8; cursor: not-allowed;">
- </div>
- </div>
- </div>
-
- <div class="panel">
- <h2>Fonts</h2>
- <p class="desc">Choose the typeface used for each part of the interface — body text, headings, and the big stat numbers on the dashboard.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="fonts">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <line x1="4" y1="8" x2="18" y2="8"></line>
- <line x1="4" y1="12" x2="18" y2="12"></line>
- <line x1="4" y1="16" x2="13" y2="16"></line>
- </svg>Body text</label>
- <select name="font_body">
- <?php foreach (fontOptions('body') as $slug => $opt): ?>
- <option value="<?= sanitize($slug) ?>" style="font-family:<?= sanitize($opt['family']) ?>;" <?= fontSlug('body') === $slug ? 'selected' : '' ?>><?= sanitize($opt['label']) ?></option>
- <?php endforeach; ?>
- </select>
- </div>
- <div class="form-group">
- <label>
- <svg viewBox="0 0 40 24" width="18" height="10.5" style="vertical-align:-2px;margin-right:6px;">
- <text x="0" y="20" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="currentColor">Aa</text>
- </svg>
- Headings
- </label>
- <select name="font_heading">
- <?php foreach (fontOptions('heading') as $slug => $opt): ?>
- <option value="<?= sanitize($slug) ?>" style="font-family:<?= sanitize($opt['family']) ?>;" <?= fontSlug('heading') === $slug ? 'selected' : '' ?>><?= sanitize($opt['label']) ?></option>
- <?php endforeach; ?>
- </select>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <line x1="7" y1="17" x2="7" y2="12"></line>
- <line x1="12" y1="17" x2="12" y2="8"></line>
- <line x1="17" y1="17" x2="17" y2="4"></line>
- </svg>Dashboard stat numbers</label>
- <select name="font_value">
- <?php foreach (fontOptions('value') as $slug => $opt): ?>
- <option value="<?= sanitize($slug) ?>" style="font-family:<?= sanitize($opt['family']) ?>;" <?= fontSlug('value') === $slug ? 'selected' : '' ?>><?= sanitize($opt['label']) ?></option>
- <?php endforeach; ?>
- </select>
- </div>
- <button type="submit" class="btn primary">Save</button>
- </form>
- </div>
-
- <div class="panel">
- <h2>Geolocation</h2>
- <p class="desc">By default, PriviMetrics 2 never sends any IP address anywhere — the country is guessed from the visitor's timezone and browser language. An external provider requires sending the IP address off-server.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="geo">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="2" y1="12" x2="22" y2="12"></line>
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
- </svg>Method</label>
- <select name="geo_provider" id="geo_provider" onchange="document.getElementById('external-fields').style.display = this.value === 'external' ? 'block' : 'none'">
- <option value="privacy-friendly" <?= env('GEO_PROVIDER', 'privacy-friendly') !== 'external' ? 'selected' : '' ?>>Privacy-first (no IP) — recommended</option>
- <option value="external" <?= env('GEO_PROVIDER', 'privacy-friendly') === 'external' ? 'selected' : '' ?>>External provider (sends IP)</option>
- </select>
- </div>
- <div id="external-fields">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
- <rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
- <line x1="6" y1="6" x2="6.01" y2="6"></line>
- <line x1="6" y1="18" x2="6.01" y2="18"></line>
- </svg>Provider</label>
- <select name="geo_external_service">
- <?php $svc = env('GEO_EXTERNAL_SERVICE', 'ip-api'); ?>
- <option value="ip-api" <?= $svc === 'ip-api' ? 'selected' : '' ?>>ip-api.com (free, no key)</option>
- <option value="ipinfo" <?= $svc === 'ipinfo' ? 'selected' : '' ?>>IPinfo.io</option>
- <option value="ipstack" <?= $svc === 'ipstack' ? 'selected' : '' ?>>ipstack</option>
- <option value="ip2location" <?= $svc === 'ip2location' ? 'selected' : '' ?>>IP2Location.io</option>
- <option value="dbip" <?= $svc === 'dbip' ? 'selected' : '' ?>>DB-IP (free, no key)</option>
- </select>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
- </svg>API key (if required)</label>
- <input type="text" name="geo_api_key" value="<?= sanitize(env('GEO_API_KEY', '')) ?>" placeholder="leave blank for ip-api / dbip">
- </div>
- </div>
- <button type="submit" class="btn primary">Save</button>
- </form>
- </div>
-
- <div class="panel">
- <h2>Rate limits</h2>
- <p class="desc">How many requests per second are allowed per tracking code, separately for XML, PMAF and MySQL.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="rate_limits">
- <div class="row">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
- <polyline points="14 2 14 8 20 8"></polyline>
- <line x1="16" y1="13" x2="8" y2="13"></line>
- <line x1="16" y1="17" x2="8" y2="17"></line>
- <polyline points="10 9 9 9 8 9"></polyline>
- </svg>XML — requests / second</label>
- <input type="number" name="rate_limit_xml" value="<?= sanitize(env('RATE_LIMIT_XML', '2')) ?>" min="0" max="50">
- <div class="field-warning" id="warn_rate_limit_xml">
- <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
- <line x1="12" y1="9" x2="12" y2="13"></line>
- <line x1="12" y1="17" x2="12.01" y2="17"></line>
- </svg>
- <span></span>
- </div>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
- </svg>MySQL — requests / second</label>
- <input type="number" name="rate_limit_mysql" value="<?= sanitize(env('RATE_LIMIT_MYSQL', '5')) ?>" min="0" max="100">
- <div class="field-warning" id="warn_rate_limit_mysql">
- <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
- <line x1="12" y1="9" x2="12" y2="13"></line>
- <line x1="12" y1="17" x2="12.01" y2="17"></line>
- </svg>
- <span></span>
- </div>
- </div>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 813 800" width="14" height="14" fill="currentColor" style="vertical-align:-2px;margin-right:6px;">
- <g transform="translate(0,800) scale(1,-1)">
- <path d="M763 707V443L660 340H360V40H440V0H50V40H130V760H50V800H670ZM315 40V760H175V40ZM360 380H533V760H360ZM718 690 648 760H578V380H638L718 460Z"></path>
- </g>
- </svg>PriviMetrics Analytics Format — requests / second</label>
- <input type="number" name="rate_limit_pmaf" value="<?= sanitize(env('RATE_LIMIT_PMAF', '4')) ?>" min="0" max="80">
- <div class="field-warning" id="warn_rate_limit_pmaf">
- <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
- <line x1="12" y1="9" x2="12" y2="13"></line>
- <line x1="12" y1="17" x2="12.01" y2="17"></line>
- </svg>
- <span></span>
- </div>
- </div>
- <button type="submit" class="btn primary">Save</button>
- </form>
- </div>
-
- <div class="panel">
- <h2>MySQL database</h2>
- <p class="desc">Optional. You can enable MySQL as an additional storage engine — individual sites can still use XML.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="mysql">
- <div class="checkbox-row">
- <span class="switch">
- <input type="checkbox" name="db_enabled" id="db_enabled" <?= env('DB_ENABLED', 'false') === 'true' ? 'checked' : '' ?> onchange="document.getElementById('mysql-cred-fields').style.display = this.checked ? 'block' : 'none'">
- <span class="track"></span>
- </span>
- <label for="db_enabled"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
- </svg>Enable MySQL</label>
- </div>
- <div id="mysql-cred-fields">
- <div class="row">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
- <rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
- <line x1="6" y1="6" x2="6.01" y2="6"></line>
- <line x1="6" y1="18" x2="6.01" y2="18"></line>
- </svg>Host</label>
- <input type="text" name="db_host" value="<?= sanitize(env('DB_HOST', 'localhost')) ?>">
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="2" y1="12" x2="22" y2="12"></line>
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
- </svg>Port</label>
- <input type="text" name="db_port" value="<?= sanitize(env('DB_PORT', '3306')) ?>">
- </div>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
- </svg>Database name</label>
- <input type="text" name="db_name" value="<?= sanitize(env('DB_NAME', '')) ?>">
- </div>
- <div class="row">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
- <circle cx="12" cy="7" r="4"></circle>
- </svg>User</label>
- <input type="text" name="db_user" value="<?= sanitize(env('DB_USER', '')) ?>">
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
- <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
- </svg>Password</label>
- <input type="password" name="db_pass" placeholder="leave blank to keep unchanged" autocomplete="new-password">
- </div>
- </div>
- </div>
- <button type="submit" class="btn primary">Save & test connection</button>
- </form>
- </div>
-
- <div class="panel" id="updates">
- <h2>Updates</h2>
- <p class="desc">Installed version <strong><?= sanitize($currentVersion['version'] ?? '?') ?></strong><?= !empty($currentVersion['stage']) ? ' (' . sanitize($currentVersion['stage']) . ')' : '' ?>. <?= env('AUTO_UPDATE', 'false') === 'true' ? '<strong>Automatic updates are enabled:</strong> new versions are installed by cron without review.' : 'The update server is contacted only when you open the updater and press a button.' ?></p>
- <a href="updater.php" class="btn" style="display:inline-block;">Check for updates</a>
- </div>
-
- <div class="panel">
- <h2>Change password</h2>
- <p class="desc">Updates the admin password stored in .env.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
- <input type="hidden" name="action" value="password">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
- <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
- </svg>Current password</label>
- <input type="password" name="current_password" required autocomplete="current-password">
- </div>
- <div class="row">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
- <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
- <circle cx="12" cy="16" r="1"></circle>
- </svg>New password</label>
- <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
- <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
- <polyline points="13 16 11 18 13 20"></polyline>
- </svg>Confirm new password</label>
- <input type="password" name="new_password_confirm" required minlength="8" autocomplete="new-password">
- </div>
- </div>
- <button type="submit" class="btn primary">Change password</button>
- </form>
- </div>
- </div>
- </div>
- </div>
- <script>
- function checkRateLimit(input, lowThreshold, highThreshold, tooHighMsg) {
- var warn = document.getElementById('warn_' + input.name);
- if (!warn) return;
- var span = warn.querySelector('span');
- var val = input.value === '' ? NaN : parseInt(input.value, 10);
-
- if (isNaN(val)) {
- warn.classList.remove('show');
- return;
- }
- if (val === 0) {
- span.textContent = 'Set to 0 — this format will be blocked entirely.';
- warn.classList.add('show');
- } else if (val < lowThreshold) {
- span.textContent = 'Very low limit — busy sites may have hits dropped and lose tracking data.';
- warn.classList.add('show');
- } else if (val > highThreshold) {
- span.textContent = tooHighMsg;
- warn.classList.add('show');
- } else {
- warn.classList.remove('show');
- }
- }
-
- (function() {
- var el;
- el = document.getElementsByName('rate_limit_xml')[0];
- if (el) el.addEventListener('input', function() {
- checkRateLimit(this, 1, 5, 'High for XML — every hit rewrites the whole file, so cost grows with total history, not just rate. Consider PMAF or MySQL instead.');
- });
- el = document.getElementsByName('rate_limit_mysql')[0];
- if (el) el.addEventListener('input', function() {
- checkRateLimit(this, 1, 50, 'Very high — check your MySQL max_connections and server capacity can actually sustain this.');
- });
- el = document.getElementsByName('rate_limit_pmaf')[0];
- if (el) el.addEventListener('input', function() {
- checkRateLimit(this, 1, 15, 'High — writes serialize on a per-site file lock, so requests will queue rather than run in parallel at this rate.');
- });
-
- ['rate_limit_xml', 'rate_limit_mysql', 'rate_limit_pmaf'].forEach(function(name) {
- var el = document.getElementsByName(name)[0];
- if (el) el.dispatchEvent(new Event('input'));
- });
- })();
-
- function setTheme(t) {
- document.documentElement.setAttribute('data-theme', t);
- localStorage.setItem('pm_theme', t);
- }
-
- function toggleTheme() {
- var h = document.documentElement;
- var n = h.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
- setTheme(n);
- }
-
- function openSidebar() {
- document.getElementById('sidebar').classList.add('open');
- document.getElementById('backdrop').classList.add('show');
- }
-
- function closeSidebar() {
- document.getElementById('sidebar').classList.remove('open');
- document.getElementById('backdrop').classList.remove('show');
- }
- (function() {
- var sel = document.getElementById('default_theme');
- var saved = localStorage.getItem('pm_theme');
- if (sel && saved) sel.value = saved;
- new MutationObserver(function(ms) {
- ms.forEach(function(m) {
- if (m.attributeName === 'data-theme' && sel) {
- sel.value = document.documentElement.getAttribute('data-theme');
- }
- });
- }).observe(document.documentElement, {
- attributes: true
- });
- })();
- </script>
- </body>
-
- </html>