WebOrbiton
v1.0.2

PriviMetrics 2

1,376 lines · 67.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. require_once __DIR__ . '/functions.php';
  5. require_once __DIR__ . '/font-options.php';
  6. ​
  7. if (!envIsInstalled()) {
  8. header('Location: install.php');
  9. exit;
  10. }
  11. require_once __DIR__ . '/storage.php';
  12. require_once __DIR__ . '/updater-functions.php';
  13. ​
  14. requireLogin();
  15. pmSecurityHeaders();
  16. ​
  17. $storage = new StorageManager();
  18. $csrfToken = generateCSRFToken();
  19. $flash = null;
  20. ​
  21. function resolveReturnUrl(?string $raw): string
  22. {
  23. $default = 'dashboard.php';
  24. if (!$raw) return $default;
  25. if (preg_match('#^(https?:)?//#i', $raw) || str_starts_with($raw, '\\')) {
  26. return $default;
  27. }
  28. if (!preg_match('#^[A-Za-z0-9_\-]+\.php(\?[^\s]*)?$#', $raw)) {
  29. return $default;
  30. }
  31. return $raw;
  32. }
  33. ​
  34. $returnUrl = resolveReturnUrl($_POST['return'] ?? $_GET['return'] ?? null);
  35. ​
  36. ​
  37. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  38. if (!verifyCSRFToken($_POST['csrf_token'] ?? null)) {
  39. $flash = ['type' => 'error', 'text' => 'Invalid security token.'];
  40. } else {
  41. $action = $_POST['action'] ?? '';
  42. ​
  43. if ($action === 'general') {
  44. $ok = envSetMany([
  45. 'SITE_NAME' => sanitize($_POST['site_name'] ?? 'PriviMetrics'),
  46. 'DEFAULT_THEME' => in_array($_POST['default_theme'] ?? '', ['dark', 'light'], true) ? $_POST['default_theme'] : 'dark',
  47. 'RESPECT_DNT' => isset($_POST['respect_dnt']) ? 'true' : 'false',
  48. 'TRACK_IP_DEFAULT' => isset($_POST['track_ip_default']) ? 'true' : 'false',
  49. 'SESSION_TIMEOUT' => (string) max(300, (int) ($_POST['session_timeout'] ?? 86400)),
  50. 'TOP_LISTS_LIMIT' => (string) max(1, min(50, (int) ($_POST['top_lists_limit'] ?? 8))),
  51. ]);
  52. $flash = $ok ? ['type' => 'success', 'text' => 'General settings saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
  53. } elseif ($action === 'fonts') {
  54. $bodySlug = $_POST['font_body'] ?? '';
  55. $headingSlug = $_POST['font_heading'] ?? '';
  56. $valueSlug = $_POST['font_value'] ?? '';
  57. ​
  58. $ok = envSetMany([
  59. 'FONT_BODY' => array_key_exists($bodySlug, fontOptions('body')) ? $bodySlug : fontDefaultSlug('body'),
  60. 'FONT_HEADING' => array_key_exists($headingSlug, fontOptions('heading')) ? $headingSlug : fontDefaultSlug('heading'),
  61. 'FONT_VALUE' => array_key_exists($valueSlug, fontOptions('value')) ? $valueSlug : fontDefaultSlug('value'),
  62. ]);
  63. $flash = $ok ? ['type' => 'success', 'text' => 'Fonts saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
  64. } elseif ($action === 'geo') {
  65. $ok = envSetMany([
  66. 'GEO_PROVIDER' => $_POST['geo_provider'] === 'external' ? 'external' : 'privacy-friendly',
  67. 'GEO_EXTERNAL_SERVICE' => in_array($_POST['geo_external_service'] ?? '', ['ip-api', 'ipinfo', 'ipstack', 'ip2location', 'dbip'], true) ? $_POST['geo_external_service'] : 'ip-api',
  68. 'GEO_API_KEY' => trim((string) ($_POST['geo_api_key'] ?? '')),
  69. ]);
  70. $flash = $ok ? ['type' => 'success', 'text' => 'Geolocation settings saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
  71. } elseif ($action === 'data_collection') {
  72. $ok = envSetMany([
  73. 'FEATURE_TRAFFIC_SOURCES' => isset($_POST['feature_traffic_sources']) ? 'true' : 'false',
  74. 'FEATURE_VISITOR_COUNTRIES' => isset($_POST['feature_visitor_countries']) ? 'true' : 'false',
  75. 'FEATURE_SEARCH_QUERIES' => isset($_POST['feature_search_queries']) ? 'true' : 'false',
  76. 'FEATURE_DEVICE_TYPE' => isset($_POST['feature_device_type']) ? 'true' : 'false',
  77. ]);
  78. $flash = $ok ? ['type' => 'success', 'text' => 'Data collection settings saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
  79. } elseif ($action === 'rate_limits') {
  80. $ok = envSetMany([
  81. 'RATE_LIMIT_XML' => (string) max(0, (int) ($_POST['rate_limit_xml'] ?? 2)),
  82. 'RATE_LIMIT_MYSQL' => (string) max(0, (int) ($_POST['rate_limit_mysql'] ?? 5)),
  83. 'RATE_LIMIT_PMAF' => (string) max(0, (int) ($_POST['rate_limit_pmaf'] ?? 5)),
  84. ]);
  85. $flash = $ok ? ['type' => 'success', 'text' => 'Rate limits saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
  86. } elseif ($action === 'mysql') {
  87. $enable = isset($_POST['db_enabled']);
  88. $dbHost = trim((string) ($_POST['db_host'] ?? 'localhost'));
  89. $dbPort = trim((string) ($_POST['db_port'] ?? '3306'));
  90. $dbName = trim((string) ($_POST['db_name'] ?? ''));
  91. $dbUser = trim((string) ($_POST['db_user'] ?? ''));
  92. $dbPass = (string) ($_POST['db_pass'] ?? '');
  93. $keepPass = $dbPass === '' ? env('DB_PASS', '') : $dbPass;
  94. ​
  95. $testOk = true;
  96. $paramsError = $enable ? pmDbParamsError($dbHost, $dbPort, $dbName) : null;
  97. if ($paramsError !== null) {
  98. $testOk = false;
  99. $flash = ['type' => 'error', 'text' => $paramsError];
  100. } elseif ($enable) {
  101. try {
  102. $testPdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4", $dbUser, $keepPass, [
  103. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  104. ]);
  105. } catch (PDOException $e) {
  106. $testOk = false;
  107. $flash = ['type' => 'error', 'text' => 'Could not connect to MySQL: ' . $e->getMessage()];
  108. }
  109. }
  110. ​
  111. if ($testOk) {
  112. $ok = envSetMany([
  113. 'DB_ENABLED' => $enable ? 'true' : 'false',
  114. 'DB_HOST' => $dbHost,
  115. 'DB_PORT' => $dbPort,
  116. 'DB_NAME' => $dbName,
  117. 'DB_USER' => $dbUser,
  118. 'DB_PASS' => $keepPass,
  119. ]);
  120. if ($ok && $enable) {
  121. $freshStorage = new StorageManager();
  122. if ($freshStorage->isMySQLAvailable()) {
  123. $freshStorage->ensureSchema();
  124. }
  125. }
  126. $flash = $ok ? ['type' => 'success', 'text' => 'MySQL configuration saved.'] : ['type' => 'error', 'text' => 'Save failed.'];
  127. }
  128. } elseif ($action === 'password') {
  129. $current = (string) ($_POST['current_password'] ?? '');
  130. $new = (string) ($_POST['new_password'] ?? '');
  131. $confirm = (string) ($_POST['new_password_confirm'] ?? '');
  132. $validHash = env('ADMIN_PASSWORD_HASH', '');
  133. ​
  134. if (!verifyPassword($current, $validHash)) {
  135. $flash = ['type' => 'error', 'text' => 'Current password is incorrect.'];
  136. } elseif (strlen($new) < 8) {
  137. $flash = ['type' => 'error', 'text' => 'New password must be at least 8 characters.'];
  138. } elseif ($new !== $confirm) {
  139. $flash = ['type' => 'error', 'text' => 'New passwords do not match.'];
  140. } else {
  141. $ok = envSetMany(['ADMIN_PASSWORD_HASH' => hashPassword($new)]);
  142. $flash = $ok ? ['type' => 'success', 'text' => 'Password changed successfully.'] : ['type' => 'error', 'text' => 'Save failed.'];
  143. }
  144. }
  145. }
  146. }
  147. ​
  148. $currentVersion = pmUpdateCurrent();
  149. ​
  150. $siteName = env('SITE_NAME', 'PriviMetrics');
  151. $theme = env('DEFAULT_THEME', 'dark');
  152. ?>
  153. <!DOCTYPE html>
  154. <html lang="en" data-theme="<?= sanitize($theme) ?>">
  155. ​
  156. <head>
  157. <meta charset="UTF-8">
  158. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  159. <title>Settings - <?= sanitize($siteName) ?></title>
  160. <?= fontFaceStyleTag() ?>
  161. <script>
  162. (function() {
  163. var t = localStorage.getItem('pm_theme') || '<?= sanitize($theme) ?>';
  164. document.documentElement.setAttribute('data-theme', t);
  165. ​
  166. if (sessionStorage.getItem('pm_intro_played')) {
  167. document.documentElement.setAttribute('data-no-intro', '1');
  168. } else {
  169. sessionStorage.setItem('pm_intro_played', '1');
  170. }
  171. })();
  172. </script>
  173. <style>
  174. :root {
  175. --font-body: <?= fontFamilyCss('body') ?>;
  176. --font-heading: <?= fontFamilyCss('heading') ?>;
  177. --font-value: <?= fontFamilyCss('value') ?>;
  178. }
  179. ​
  180. :root[data-theme="dark"] {
  181. --bg-primary: #0a0a0a;
  182. --bg-secondary: #141414;
  183. --bg-tertiary: #1c1c1c;
  184. --bg-hover: #222222;
  185. --border-color: #262626;
  186. --text-primary: #eaeaea;
  187. --text-secondary: #9a9a9a;
  188. --text-tertiary: #6b6b6b;
  189. --accent: #f1484e;
  190. --accent-hover: #d53b40;
  191. --accent-soft: rgba(241, 72, 78, 0.12);
  192. --success: #22c55e;
  193. --success-soft: rgba(34, 197, 94, 0.12);
  194. }
  195. ​
  196. :root[data-theme="light"] {
  197. --bg-primary: #f7f7f8;
  198. --bg-secondary: #ffffff;
  199. --bg-tertiary: #f2f2f3;
  200. --bg-hover: #ececed;
  201. --border-color: #e5e5e7;
  202. --text-primary: #16171a;
  203. --text-secondary: #6b6d73;
  204. --text-tertiary: #9799a0;
  205. --accent: #f1484e;
  206. --accent-hover: #d53b40;
  207. --accent-soft: rgba(241, 72, 78, 0.08);
  208. --success: #16a34a;
  209. --success-soft: rgba(22, 163, 74, 0.1);
  210. }
  211. ​
  212. * {
  213. margin: 0;
  214. padding: 0;
  215. box-sizing: border-box;
  216. }
  217. ​
  218. html {
  219. overflow-y: scroll;
  220. scrollbar-gutter: stable;
  221. }
  222. ​
  223. body {
  224. font-family: var(--font-body);
  225. background: var(--bg-primary);
  226. color: var(--text-primary);
  227. font-size: 14px;
  228. line-height: 1.5;
  229. }
  230. ​
  231. h1 {
  232. font-family: var(--font-heading);
  233. }
  234. ​
  235. a {
  236. color: inherit;
  237. text-decoration: none;
  238. }
  239. ​
  240. .app {
  241. display: flex;
  242. min-height: 100vh;
  243. }
  244. ​
  245. .sidebar {
  246. width: 250px;
  247. flex-shrink: 0;
  248. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  249. border: 1px solid var(--border-color);
  250. border-radius: 16px;
  251. 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);
  252. display: flex;
  253. flex-direction: column;
  254. padding: 18px 14px;
  255. position: sticky;
  256. top: 14px;
  257. align-self: flex-start;
  258. margin: 14px 0 14px 14px;
  259. height: calc(100vh - 28px);
  260. overflow-y: auto;
  261. scrollbar-width: none;
  262. -ms-overflow-style: none;
  263. z-index: 10;
  264. }
  265. ​
  266. .sidebar::-webkit-scrollbar {
  267. display: none;
  268. }
  269. ​
  270. .brand {
  271. display: flex;
  272. align-items: center;
  273. gap: 10px;
  274. padding: 6px 8px 18px;
  275. margin-bottom: 12px;
  276. border-bottom: 1px solid var(--border-color);
  277. }
  278. ​
  279. .brand .mark {
  280. width: 34px;
  281. height: 34px;
  282. border-radius: 9px;
  283. background: var(--accent);
  284. display: flex;
  285. align-items: center;
  286. justify-content: center;
  287. font-weight: 700;
  288. color: white;
  289. font-size: 14px;
  290. flex-shrink: 0;
  291. }
  292. ​
  293. .brand .name {
  294. font-family: var(--font-heading);
  295. font-weight: 600;
  296. font-size: 15px;
  297. }
  298. ​
  299. .brand .sub {
  300. font-size: 11px;
  301. color: var(--text-tertiary);
  302. }
  303. ​
  304. .nav {
  305. display: flex;
  306. flex-direction: column;
  307. gap: 2px;
  308. }
  309. ​
  310. .nav a {
  311. display: flex;
  312. align-items: center;
  313. gap: 10px;
  314. padding: 9px 12px;
  315. border-radius: 10px;
  316. font-size: 13.5px;
  317. font-weight: 500;
  318. color: var(--text-secondary);
  319. border: 1px solid transparent;
  320. transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
  321. }
  322. ​
  323. .nav a svg {
  324. width: 17px;
  325. height: 17px;
  326. flex-shrink: 0;
  327. }
  328. ​
  329. .nav a:hover {
  330. background: var(--bg-tertiary);
  331. color: var(--text-primary);
  332. transform: translateX(2px);
  333. }
  334. ​
  335. .nav a.active {
  336. background: var(--accent-soft);
  337. color: var(--accent);
  338. font-weight: 600;
  339. border-color: color-mix(in srgb, var(--accent) 22%, transparent);
  340. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
  341. transform: none;
  342. }
  343. ​
  344. .sidebar-footer {
  345. margin-top: auto;
  346. padding-top: 14px;
  347. border-top: 1px solid var(--border-color);
  348. }
  349. ​
  350. .sidebar-footer a {
  351. display: flex;
  352. align-items: center;
  353. gap: 10px;
  354. padding: 9px 12px;
  355. border-radius: 8px;
  356. font-size: 13px;
  357. color: var(--text-secondary);
  358. }
  359. ​
  360. .sidebar-footer a:hover {
  361. background: var(--bg-hover);
  362. color: var(--text-primary);
  363. }
  364. ​
  365. .sidebar-footer .user {
  366. font-size: 12px;
  367. color: var(--text-tertiary);
  368. padding: 6px 12px 10px;
  369. }
  370. ​
  371. .main {
  372. flex: 1;
  373. min-width: 0;
  374. }
  375. ​
  376. .topbar {
  377. display: flex;
  378. align-items: center;
  379. gap: 14px;
  380. padding: 16px 28px;
  381. margin: 14px 14px 0;
  382. border: 1px solid var(--border-color);
  383. border-radius: 16px;
  384. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  385. 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);
  386. min-height: 68px;
  387. box-sizing: border-box;
  388. }
  389. ​
  390. .topbar h1 {
  391. font-size: 17px;
  392. font-weight: 600;
  393. }
  394. ​
  395. .mobile-topbar {
  396. display: none;
  397. }
  398. ​
  399. .mobile-menu-btn {
  400. width: 38px;
  401. height: 38px;
  402. display: flex;
  403. align-items: center;
  404. justify-content: center;
  405. background: var(--bg-tertiary);
  406. border: 1px solid var(--border-color);
  407. border-radius: 8px;
  408. }
  409. ​
  410. @media (max-width: 880px) {
  411. .app {
  412. flex-direction: column;
  413. }
  414. ​
  415. .sidebar {
  416. position: fixed;
  417. top: auto;
  418. bottom: calc(8px + env(safe-area-inset-bottom));
  419. left: calc(8px + env(safe-area-inset-left));
  420. right: calc(8px + env(safe-area-inset-right));
  421. width: auto;
  422. height: auto;
  423. margin: 0;
  424. flex-direction: row;
  425. align-items: center;
  426. padding: 6px;
  427. border-radius: 24px;
  428. border-color: var(--border-color);
  429. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
  430. overflow: visible;
  431. z-index: 60;
  432. }
  433. ​
  434. .sidebar .brand,
  435. .sidebar-footer .user {
  436. display: none;
  437. }
  438. ​
  439. .nav {
  440. flex: 3 1 0;
  441. min-width: 0;
  442. flex-direction: row;
  443. justify-content: space-around;
  444. gap: 0;
  445. }
  446. ​
  447. .sidebar-footer {
  448. margin: 0;
  449. padding: 0;
  450. border: none;
  451. flex: 1 1 0;
  452. min-width: 0;
  453. display: flex;
  454. }
  455. ​
  456. .nav a,
  457. .sidebar-footer a {
  458. flex-direction: column;
  459. align-items: center;
  460. justify-content: center;
  461. gap: 3px;
  462. padding: 8px 4px;
  463. font-size: 10px;
  464. font-weight: 600;
  465. border-radius: 18px;
  466. text-align: center;
  467. flex: 1;
  468. transform: none;
  469. }
  470. ​
  471. .nav a:hover {
  472. transform: none;
  473. }
  474. ​
  475. .nav a svg {
  476. width: 20px;
  477. height: 20px;
  478. }
  479. ​
  480. .sidebar-footer a svg {
  481. width: 20px;
  482. height: 20px;
  483. }
  484. ​
  485. .sidebar-backdrop,
  486. .sidebar-backdrop.show {
  487. display: none;
  488. }
  489. ​
  490. .mobile-menu-btn {
  491. display: none;
  492. }
  493. ​
  494. .main {
  495. background: var(--bg-secondary);
  496. overflow-x: clip;
  497. }
  498. ​
  499. .mobile-topbar {
  500. display: flex;
  501. align-items: center;
  502. justify-content: space-between;
  503. padding: 12px 16px;
  504. background: var(--bg-primary);
  505. position: sticky;
  506. top: 0;
  507. z-index: 30;
  508. }
  509. ​
  510. .mobile-topbar::after {
  511. content: "";
  512. position: absolute;
  513. left: 0;
  514. right: 0;
  515. top: calc(100% - 1px);
  516. height: 24px;
  517. pointer-events: none;
  518. background:
  519. radial-gradient(circle at 100% 100%, transparent 23.5px, var(--bg-primary) 24.5px) left top / 24px 24px no-repeat,
  520. radial-gradient(circle at 0 100%, transparent 23.5px, var(--bg-primary) 24.5px) right top / 24px 24px no-repeat;
  521. }
  522. ​
  523. .content {
  524. padding: 18px 16px 110px;
  525. }
  526. ​
  527. .topbar {
  528. padding: 14px 16px;
  529. margin: 12px 12px 0;
  530. }
  531. }
  532. ​
  533. .content {
  534. max-width: 720px;
  535. margin: 0 auto;
  536. padding: 28px 20px 60px;
  537. }
  538. ​
  539. .panel {
  540. position: relative;
  541. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  542. border: 1px solid var(--border-color);
  543. border-radius: 16px;
  544. 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);
  545. padding: 24px;
  546. margin-bottom: 18px;
  547. opacity: 0;
  548. transform: translateY(10px);
  549. animation: panelIn 0.45s ease forwards;
  550. }
  551. ​
  552. .content .panel:nth-of-type(2) {
  553. animation-delay: 0.05s;
  554. }
  555. ​
  556. .content .panel:nth-of-type(3) {
  557. animation-delay: 0.1s;
  558. }
  559. ​
  560. .content .panel:nth-of-type(4) {
  561. animation-delay: 0.15s;
  562. }
  563. ​
  564. .content .panel:nth-of-type(5) {
  565. animation-delay: 0.2s;
  566. }
  567. ​
  568. .content .panel:nth-of-type(6) {
  569. animation-delay: 0.25s;
  570. }
  571. ​
  572. @keyframes panelIn {
  573. to {
  574. opacity: 1;
  575. transform: translateY(0);
  576. }
  577. }
  578. ​
  579. @media (prefers-reduced-motion: reduce) {
  580. .panel {
  581. animation: none;
  582. opacity: 1;
  583. transform: none;
  584. }
  585. }
  586. ​
  587. :root[data-no-intro] .panel {
  588. animation: none;
  589. opacity: 1;
  590. transform: none;
  591. }
  592. ​
  593. .panel h2 {
  594. font-size: 15px;
  595. font-weight: 600;
  596. margin-bottom: 4px;
  597. }
  598. ​
  599. .panel .desc {
  600. font-size: 12.5px;
  601. color: var(--text-secondary);
  602. margin-bottom: 18px;
  603. }
  604. ​
  605. .form-group {
  606. margin-bottom: 16px;
  607. }
  608. ​
  609. .form-group label {
  610. display: block;
  611. font-size: 12.5px;
  612. font-weight: 500;
  613. color: var(--text-secondary);
  614. margin-bottom: 6px;
  615. }
  616. ​
  617. .form-group input,
  618. .form-group select {
  619. width: 100%;
  620. padding: 10px 12px;
  621. background: var(--bg-primary);
  622. border: 1px solid var(--border-color);
  623. border-radius: 10px;
  624. color: var(--text-primary);
  625. font-size: 13.5px;
  626. font-family: inherit;
  627. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
  628. transition: border-color 0.2s ease, box-shadow 0.2s ease;
  629. }
  630. ​
  631. .form-group input:focus,
  632. .form-group select:focus {
  633. outline: none;
  634. border-color: var(--accent);
  635. box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
  636. }
  637. ​
  638. .row {
  639. display: grid;
  640. grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  641. gap: 12px;
  642. }
  643. ​
  644. @media (max-width: 560px) {
  645. .row {
  646. grid-template-columns: minmax(0, 1fr);
  647. }
  648. }
  649. ​
  650. .checkbox-row {
  651. display: flex;
  652. align-items: center;
  653. gap: 10px;
  654. margin-bottom: 16px;
  655. }
  656. ​
  657. .checkbox-row label {
  658. margin: 0;
  659. font-size: 13px;
  660. color: var(--text-primary);
  661. font-weight: 500;
  662. cursor: pointer;
  663. }
  664. ​
  665. .switch {
  666. position: relative;
  667. display: inline-block;
  668. width: 42px;
  669. height: 24px;
  670. flex-shrink: 0;
  671. }
  672. ​
  673. .switch input {
  674. position: absolute;
  675. opacity: 0;
  676. width: 100%;
  677. height: 100%;
  678. margin: 0;
  679. cursor: pointer;
  680. z-index: 1;
  681. }
  682. ​
  683. .switch .track {
  684. position: absolute;
  685. inset: 0;
  686. background: var(--bg-tertiary);
  687. border: 1px solid var(--border-color);
  688. border-radius: 20px;
  689. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
  690. transition: background 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
  691. }
  692. ​
  693. .switch .track::after {
  694. content: '';
  695. position: absolute;
  696. top: 2px;
  697. left: 2px;
  698. width: 18px;
  699. height: 18px;
  700. background: white;
  701. border-radius: 50%;
  702. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  703. transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
  704. }
  705. ​
  706. .switch input:checked+.track {
  707. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  708. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
  709. border-color: var(--accent);
  710. }
  711. ​
  712. .switch input:checked+.track::after {
  713. transform: translateX(18px);
  714. }
  715. ​
  716. .switch input:focus-visible+.track {
  717. outline: 2px solid var(--accent);
  718. outline-offset: 2px;
  719. }
  720. ​
  721. .btn {
  722. padding: 9px 16px;
  723. border-radius: 10px;
  724. font-size: 13px;
  725. font-weight: 600;
  726. cursor: pointer;
  727. border: 1px solid color-mix(in srgb, var(--border-color) 60%, var(--text-tertiary));
  728. background: var(--bg-tertiary);
  729. color: var(--text-primary);
  730. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
  731. transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
  732. }
  733. ​
  734. .btn:hover {
  735. background: var(--bg-hover);
  736. }
  737. ​
  738. .btn:active {
  739. transform: scale(0.97);
  740. }
  741. ​
  742. .btn.primary {
  743. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  744. border-color: var(--accent);
  745. color: white;
  746. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
  747. }
  748. ​
  749. .btn.primary:hover {
  750. background: linear-gradient(155deg, var(--accent-hover), var(--accent-hover));
  751. box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.14);
  752. transform: translateY(-1px);
  753. }
  754. ​
  755. .btn.primary:active {
  756. transform: translateY(0) scale(0.98);
  757. }
  758. ​
  759. .flash {
  760. padding: 12px 16px;
  761. border-radius: 10px;
  762. font-size: 13px;
  763. margin-bottom: 18px;
  764. }
  765. ​
  766. .flash.success {
  767. background: var(--success-soft);
  768. color: var(--success);
  769. border: 1px solid var(--success);
  770. }
  771. ​
  772. .flash.error {
  773. background: var(--accent-soft);
  774. color: var(--accent);
  775. border: 1px solid var(--accent);
  776. }
  777. ​
  778. .hint {
  779. font-size: 12px;
  780. color: var(--text-tertiary);
  781. margin-top: -8px;
  782. margin-bottom: 16px;
  783. line-height: 1.5;
  784. }
  785. ​
  786. .field-warning {
  787. display: none;
  788. align-items: center;
  789. gap: 6px;
  790. font-size: 12px;
  791. color: #b45309;
  792. background: rgba(180, 83, 9, 0.1);
  793. border: 1px solid rgba(180, 83, 9, 0.35);
  794. border-radius: 6px;
  795. padding: 6px 10px;
  796. margin-top: 6px;
  797. line-height: 1.4;
  798. }
  799. ​
  800. .field-warning.show {
  801. display: flex;
  802. }
  803. ​
  804. .field-warning svg {
  805. flex-shrink: 0;
  806. }
  807. ​
  808. #external-fields {
  809. display: <?= env('GEO_PROVIDER', 'privacy-friendly') === 'external' ? 'block' : 'none' ?>;
  810. }
  811. ​
  812. #mysql-cred-fields {
  813. display: <?= env('DB_ENABLED', 'false') === 'true' ? 'block' : 'none' ?>;
  814. }
  815. </style>
  816. </head>
  817. ​
  818. <body>
  819. <div class="app">
  820. ​
  821. <div class="sidebar-backdrop" id="backdrop" onclick="closeSidebar()"></div>
  822. <aside class="sidebar" id="sidebar">
  823. <div class="brand">
  824. <div class="mark">
  825. <img src="logo.webp" alt="Logo" width="24" height="24" loading="lazy">
  826. </div>
  827. <div>
  828. <div class="name"><?= sanitize($siteName) ?></div>
  829. <div class="sub">PriviMetrics 2</div>
  830. </div>
  831. </div>
  832. <nav class="nav">
  833. <a href="dashboard.php?view=overview">
  834. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  835. <path d="M7 12v7M12 4v15M17 8v11"></path>
  836. </svg>
  837. Analytics
  838. </a>
  839. <a href="dashboard.php?view=sites">
  840. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  841. <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"></path>
  842. <path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"></path>
  843. </svg>
  844. Sites
  845. </a>
  846. <a href="settings.php" class="active">
  847. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  848. <circle cx="12" cy="12" r="3" />
  849. <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" />
  850. </svg>
  851. Settings
  852. </a>
  853. </nav>
  854. <div class="sidebar-footer">
  855. <div class="user">Signed in as <strong><?= sanitize($_SESSION['admin_username'] ?? '') ?></strong></div>
  856. <a href="logout.php">
  857. <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
  858. <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
  859. <path d="M16 17l5-5-5-5" />
  860. <path d="M21 12H9" />
  861. </svg>
  862. Sign out
  863. </a>
  864. </div>
  865. </aside>
  866. ​
  867. <div class="main">
  868. <div class="mobile-topbar">
  869. <div class="mobile-menu-btn" onclick="openSidebar()">
  870. <svg width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  871. <line x1="3" y1="12" x2="21" y2="12" />
  872. <line x1="3" y1="6" x2="21" y2="6" />
  873. <line x1="3" y1="18" x2="21" y2="18" />
  874. </svg>
  875. </div>
  876. <strong style="font-size:14px;"><?= sanitize($siteName) ?></strong>
  877. <div style="width:38px;"></div>
  878. </div>
  879. ​
  880. <div class="topbar">
  881. <h1>Settings</h1>
  882. <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">
  883. <circle cx="12" cy="12" r="5" />
  884. <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" />
  885. </svg></button>
  886. </div>
  887. ​
  888. <div class="content">
  889. <?php if ($flash): ?><div class="flash <?= $flash['type'] ?>"><?= sanitize($flash['text']) ?></div><?php endif; ?>
  890. ​
  891. <div class="panel">
  892. <h2>General</h2>
  893. <p class="desc">Basic configuration for this installation.</p>
  894. <form method="post">
  895. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  896. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  897. <input type="hidden" name="action" value="general">
  898. <div class="form-group">
  899. <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;">
  900. <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
  901. <line x1="3" y1="9" x2="21" y2="9"></line>
  902. <line x1="9" y1="21" x2="9" y2="9"></line>
  903. </svg>Installation name</label>
  904. <input type="text" name="site_name" value="<?= sanitize(env('SITE_NAME', 'PriviMetrics')) ?>" required>
  905. </div>
  906. <div class="row">
  907. <div class="form-group">
  908. <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;">
  909. <circle cx="12" cy="12" r="5"></circle>
  910. <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>
  911. </svg>Default theme</label>
  912. <select name="default_theme" id="default_theme" onchange="setTheme(this.value)">
  913. <option value="dark" <?= $theme === 'dark' ? 'selected' : '' ?>>Dark</option>
  914. <option value="light" <?= $theme === 'light' ? 'selected' : '' ?>>Light</option>
  915. </select>
  916. </div>
  917. <div class="form-group">
  918. <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;">
  919. <circle cx="12" cy="12" r="10"></circle>
  920. <polyline points="12 6 12 12 16 14"></polyline>
  921. </svg>Session timeout (seconds)</label>
  922. <input type="number" name="session_timeout" value="<?= sanitize(env('SESSION_TIMEOUT', '86400')) ?>" min="300">
  923. </div>
  924. </div>
  925. <div class="form-group">
  926. <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;">
  927. <line x1="8" y1="6" x2="21" y2="6"></line>
  928. <line x1="8" y1="12" x2="21" y2="12"></line>
  929. <line x1="8" y1="18" x2="21" y2="18"></line>
  930. <line x1="3" y1="6" x2="3.01" y2="6"></line>
  931. <line x1="3" y1="12" x2="3.01" y2="12"></line>
  932. <line x1="3" y1="18" x2="3.01" y2="18"></line>
  933. </svg>Top lists max rows</label>
  934. <input type="number" name="top_lists_limit" value="<?= sanitize(env('TOP_LISTS_LIMIT', '8')) ?>" min="1" max="50">
  935. </div>
  936. <div class="checkbox-row">
  937. <span class="switch">
  938. <input type="checkbox" name="respect_dnt" id="respect_dnt" <?= env('RESPECT_DNT', 'true') === 'true' ? 'checked' : '' ?>>
  939. <span class="track"></span>
  940. </span>
  941. <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;">
  942. <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
  943. <line x1="12" y1="8" x2="12" y2="12"></line>
  944. <line x1="12" y1="16" x2="12.01" y2="16"></line>
  945. </svg>Respect the Do Not Track header</label>
  946. </div>
  947. <div class="checkbox-row">
  948. <span class="switch">
  949. <input type="checkbox" name="track_ip_default" id="track_ip_default" <?= env('TRACK_IP_DEFAULT', 'false') === 'true' ? 'checked' : '' ?>>
  950. <span class="track"></span>
  951. </span>
  952. <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;">
  953. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
  954. <circle cx="12" cy="12" r="3"></circle>
  955. </svg>Track partial IP by default (unless the script says otherwise)</label>
  956. </div>
  957. <button type="submit" class="btn primary">Save</button>
  958. </form>
  959. </div>
  960. ​
  961. <div class="panel">
  962. <h2>Data Collection</h2>
  963. <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>
  964. <form method="post">
  965. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  966. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  967. <input type="hidden" name="action" value="data_collection">
  968. <div class="checkbox-row">
  969. <span class="switch">
  970. <input type="checkbox" name="feature_traffic_sources" id="feature_traffic_sources" <?= pmFeatureGlobalEnabled('traffic_sources') ? 'checked' : '' ?>>
  971. <span class="track"></span>
  972. </span>
  973. <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;">
  974. <circle cx="12" cy="12" r="10"></circle>
  975. <line x1="2" y1="12" x2="22" y2="12"></line>
  976. <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>
  977. </svg>Track traffic sources (referrers and utm_source)</label>
  978. </div>
  979. <div class="checkbox-row">
  980. <span class="switch">
  981. <input type="checkbox" name="feature_visitor_countries" id="feature_visitor_countries" <?= pmFeatureGlobalEnabled('visitor_countries') ? 'checked' : '' ?>>
  982. <span class="track"></span>
  983. </span>
  984. <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;">
  985. <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
  986. <circle cx="12" cy="10" r="3"></circle>
  987. </svg>Track visitor countries</label>
  988. </div>
  989. <div class="checkbox-row">
  990. <span class="switch">
  991. <input type="checkbox" name="feature_search_queries" id="feature_search_queries" <?= pmFeatureGlobalEnabled('search_queries') ? 'checked' : '' ?>>
  992. <span class="track"></span>
  993. </span>
  994. <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;">
  995. <circle cx="11" cy="11" r="8"></circle>
  996. <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
  997. </svg>Track on-site search queries</label>
  998. </div>
  999. <div class="checkbox-row">
  1000. <span class="switch">
  1001. <input type="checkbox" name="feature_device_type" id="feature_device_type" <?= pmFeatureGlobalEnabled('device_type') ? 'checked' : '' ?>>
  1002. <span class="track"></span>
  1003. </span>
  1004. <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;">
  1005. <rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
  1006. <line x1="12" y1="18" x2="12.01" y2="18"></line>
  1007. </svg>Track device type (mobile/tablet/desktop)</label>
  1008. </div>
  1009. <button type="submit" class="btn primary">Save</button>
  1010. </form>
  1011. </div>
  1012. ​
  1013. <div class="panel">
  1014. <h2>System Version</h2>
  1015. <p class="desc">Current application version.</p>
  1016. ​
  1017. <div class="form-group">
  1018. <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;">
  1019. <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>
  1020. <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
  1021. <line x1="12" y1="22.08" x2="12" y2="12"></line>
  1022. </svg>Version</label>
  1023. <input type="text" value="<?= sanitize($currentVersion) ?>" disabled style="opacity: 0.8; cursor: not-allowed;">
  1024. </div>
  1025. </div>
  1026. ​
  1027. <div class="panel">
  1028. <h2>Fonts</h2>
  1029. <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>
  1030. <form method="post">
  1031. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  1032. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  1033. <input type="hidden" name="action" value="fonts">
  1034. <div class="form-group">
  1035. <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;">
  1036. <line x1="4" y1="8" x2="18" y2="8"></line>
  1037. <line x1="4" y1="12" x2="18" y2="12"></line>
  1038. <line x1="4" y1="16" x2="13" y2="16"></line>
  1039. </svg>Body text</label>
  1040. <select name="font_body">
  1041. <?php foreach (fontOptions('body') as $slug => $opt): ?>
  1042. <option value="<?= sanitize($slug) ?>" style="font-family:<?= sanitize($opt['family']) ?>;" <?= fontSlug('body') === $slug ? 'selected' : '' ?>><?= sanitize($opt['label']) ?></option>
  1043. <?php endforeach; ?>
  1044. </select>
  1045. </div>
  1046. <div class="form-group">
  1047. <label>
  1048. <svg viewBox="0 0 40 24" width="18" height="10.5" style="vertical-align:-2px;margin-right:6px;">
  1049. <text x="0" y="20" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="currentColor">Aa</text>
  1050. </svg>
  1051. Headings
  1052. </label>
  1053. <select name="font_heading">
  1054. <?php foreach (fontOptions('heading') as $slug => $opt): ?>
  1055. <option value="<?= sanitize($slug) ?>" style="font-family:<?= sanitize($opt['family']) ?>;" <?= fontSlug('heading') === $slug ? 'selected' : '' ?>><?= sanitize($opt['label']) ?></option>
  1056. <?php endforeach; ?>
  1057. </select>
  1058. </div>
  1059. <div class="form-group">
  1060. <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;">
  1061. <line x1="7" y1="17" x2="7" y2="12"></line>
  1062. <line x1="12" y1="17" x2="12" y2="8"></line>
  1063. <line x1="17" y1="17" x2="17" y2="4"></line>
  1064. </svg>Dashboard stat numbers</label>
  1065. <select name="font_value">
  1066. <?php foreach (fontOptions('value') as $slug => $opt): ?>
  1067. <option value="<?= sanitize($slug) ?>" style="font-family:<?= sanitize($opt['family']) ?>;" <?= fontSlug('value') === $slug ? 'selected' : '' ?>><?= sanitize($opt['label']) ?></option>
  1068. <?php endforeach; ?>
  1069. </select>
  1070. </div>
  1071. <button type="submit" class="btn primary">Save</button>
  1072. </form>
  1073. </div>
  1074. ​
  1075. <div class="panel">
  1076. <h2>Geolocation</h2>
  1077. <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>
  1078. <form method="post">
  1079. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  1080. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  1081. <input type="hidden" name="action" value="geo">
  1082. <div class="form-group">
  1083. <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;">
  1084. <circle cx="12" cy="12" r="10"></circle>
  1085. <line x1="2" y1="12" x2="22" y2="12"></line>
  1086. <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>
  1087. </svg>Method</label>
  1088. <select name="geo_provider" id="geo_provider" onchange="document.getElementById('external-fields').style.display = this.value === 'external' ? 'block' : 'none'">
  1089. <option value="privacy-friendly" <?= env('GEO_PROVIDER', 'privacy-friendly') !== 'external' ? 'selected' : '' ?>>Privacy-first (no IP) — recommended</option>
  1090. <option value="external" <?= env('GEO_PROVIDER', 'privacy-friendly') === 'external' ? 'selected' : '' ?>>External provider (sends IP)</option>
  1091. </select>
  1092. </div>
  1093. <div id="external-fields">
  1094. <div class="form-group">
  1095. <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;">
  1096. <rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
  1097. <rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
  1098. <line x1="6" y1="6" x2="6.01" y2="6"></line>
  1099. <line x1="6" y1="18" x2="6.01" y2="18"></line>
  1100. </svg>Provider</label>
  1101. <select name="geo_external_service">
  1102. <?php $svc = env('GEO_EXTERNAL_SERVICE', 'ip-api'); ?>
  1103. <option value="ip-api" <?= $svc === 'ip-api' ? 'selected' : '' ?>>ip-api.com (free, no key)</option>
  1104. <option value="ipinfo" <?= $svc === 'ipinfo' ? 'selected' : '' ?>>IPinfo.io</option>
  1105. <option value="ipstack" <?= $svc === 'ipstack' ? 'selected' : '' ?>>ipstack</option>
  1106. <option value="ip2location" <?= $svc === 'ip2location' ? 'selected' : '' ?>>IP2Location.io</option>
  1107. <option value="dbip" <?= $svc === 'dbip' ? 'selected' : '' ?>>DB-IP (free, no key)</option>
  1108. </select>
  1109. </div>
  1110. <div class="form-group">
  1111. <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;">
  1112. <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>
  1113. </svg>API key (if required)</label>
  1114. <input type="text" name="geo_api_key" value="<?= sanitize(env('GEO_API_KEY', '')) ?>" placeholder="leave blank for ip-api / dbip">
  1115. </div>
  1116. </div>
  1117. <button type="submit" class="btn primary">Save</button>
  1118. </form>
  1119. </div>
  1120. ​
  1121. <div class="panel">
  1122. <h2>Rate limits</h2>
  1123. <p class="desc">How many requests per second are allowed per tracking code, separately for XML, PMAF and MySQL.</p>
  1124. <form method="post">
  1125. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  1126. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  1127. <input type="hidden" name="action" value="rate_limits">
  1128. <div class="row">
  1129. <div class="form-group">
  1130. <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;">
  1131. <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
  1132. <polyline points="14 2 14 8 20 8"></polyline>
  1133. <line x1="16" y1="13" x2="8" y2="13"></line>
  1134. <line x1="16" y1="17" x2="8" y2="17"></line>
  1135. <polyline points="10 9 9 9 8 9"></polyline>
  1136. </svg>XML — requests / second</label>
  1137. <input type="number" name="rate_limit_xml" value="<?= sanitize(env('RATE_LIMIT_XML', '2')) ?>" min="0" max="50">
  1138. <div class="field-warning" id="warn_rate_limit_xml">
  1139. <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1140. <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>
  1141. <line x1="12" y1="9" x2="12" y2="13"></line>
  1142. <line x1="12" y1="17" x2="12.01" y2="17"></line>
  1143. </svg>
  1144. <span></span>
  1145. </div>
  1146. </div>
  1147. <div class="form-group">
  1148. <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;">
  1149. <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
  1150. <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
  1151. <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
  1152. </svg>MySQL — requests / second</label>
  1153. <input type="number" name="rate_limit_mysql" value="<?= sanitize(env('RATE_LIMIT_MYSQL', '5')) ?>" min="0" max="100">
  1154. <div class="field-warning" id="warn_rate_limit_mysql">
  1155. <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1156. <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>
  1157. <line x1="12" y1="9" x2="12" y2="13"></line>
  1158. <line x1="12" y1="17" x2="12.01" y2="17"></line>
  1159. </svg>
  1160. <span></span>
  1161. </div>
  1162. </div>
  1163. </div>
  1164. <div class="form-group">
  1165. <label><svg viewBox="0 0 813 800" width="14" height="14" fill="currentColor" style="vertical-align:-2px;margin-right:6px;">
  1166. <g transform="translate(0,800) scale(1,-1)">
  1167. <path d="M763 707V443L660 340H360V40H440V0H50V40H130V760H50V800H670ZM315 40V760H175V40ZM360 380H533V760H360ZM718 690 648 760H578V380H638L718 460Z"></path>
  1168. </g>
  1169. </svg>PriviMetrics Analytics Format — requests / second</label>
  1170. <input type="number" name="rate_limit_pmaf" value="<?= sanitize(env('RATE_LIMIT_PMAF', '4')) ?>" min="0" max="80">
  1171. <div class="field-warning" id="warn_rate_limit_pmaf">
  1172. <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1173. <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>
  1174. <line x1="12" y1="9" x2="12" y2="13"></line>
  1175. <line x1="12" y1="17" x2="12.01" y2="17"></line>
  1176. </svg>
  1177. <span></span>
  1178. </div>
  1179. </div>
  1180. <button type="submit" class="btn primary">Save</button>
  1181. </form>
  1182. </div>
  1183. ​
  1184. <div class="panel">
  1185. <h2>MySQL database</h2>
  1186. <p class="desc">Optional. You can enable MySQL as an additional storage engine — individual sites can still use XML.</p>
  1187. <form method="post">
  1188. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  1189. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  1190. <input type="hidden" name="action" value="mysql">
  1191. <div class="checkbox-row">
  1192. <span class="switch">
  1193. <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'">
  1194. <span class="track"></span>
  1195. </span>
  1196. <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;">
  1197. <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
  1198. <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
  1199. <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
  1200. </svg>Enable MySQL</label>
  1201. </div>
  1202. <div id="mysql-cred-fields">
  1203. <div class="row">
  1204. <div class="form-group">
  1205. <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;">
  1206. <rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
  1207. <rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
  1208. <line x1="6" y1="6" x2="6.01" y2="6"></line>
  1209. <line x1="6" y1="18" x2="6.01" y2="18"></line>
  1210. </svg>Host</label>
  1211. <input type="text" name="db_host" value="<?= sanitize(env('DB_HOST', 'localhost')) ?>">
  1212. </div>
  1213. <div class="form-group">
  1214. <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;">
  1215. <circle cx="12" cy="12" r="10"></circle>
  1216. <line x1="2" y1="12" x2="22" y2="12"></line>
  1217. <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>
  1218. </svg>Port</label>
  1219. <input type="text" name="db_port" value="<?= sanitize(env('DB_PORT', '3306')) ?>">
  1220. </div>
  1221. </div>
  1222. <div class="form-group">
  1223. <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;">
  1224. <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>
  1225. </svg>Database name</label>
  1226. <input type="text" name="db_name" value="<?= sanitize(env('DB_NAME', '')) ?>">
  1227. </div>
  1228. <div class="row">
  1229. <div class="form-group">
  1230. <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;">
  1231. <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
  1232. <circle cx="12" cy="7" r="4"></circle>
  1233. </svg>User</label>
  1234. <input type="text" name="db_user" value="<?= sanitize(env('DB_USER', '')) ?>">
  1235. </div>
  1236. <div class="form-group">
  1237. <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;">
  1238. <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
  1239. <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
  1240. </svg>Password</label>
  1241. <input type="password" name="db_pass" placeholder="leave blank to keep unchanged" autocomplete="new-password">
  1242. </div>
  1243. </div>
  1244. </div>
  1245. <button type="submit" class="btn primary">Save &amp; test connection</button>
  1246. </form>
  1247. </div>
  1248. ​
  1249. <div class="panel" id="updates">
  1250. <h2>Updates</h2>
  1251. <p class="desc">Installed version <strong><?= sanitize($currentVersion) ?></strong>. <?= 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>
  1252. <a href="updater.php" class="btn" style="display:inline-block;">Check for updates</a>
  1253. </div>
  1254. ​
  1255. <div class="panel">
  1256. <h2>Change password</h2>
  1257. <p class="desc">Updates the admin password stored in .env.</p>
  1258. <form method="post">
  1259. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  1260. <input type="hidden" name="return" value="<?= sanitize($returnUrl) ?>">
  1261. <input type="hidden" name="action" value="password">
  1262. <div class="form-group">
  1263. <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;">
  1264. <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
  1265. <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
  1266. </svg>Current password</label>
  1267. <input type="password" name="current_password" required autocomplete="current-password">
  1268. </div>
  1269. <div class="row">
  1270. <div class="form-group">
  1271. <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;">
  1272. <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
  1273. <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
  1274. <circle cx="12" cy="16" r="1"></circle>
  1275. </svg>New password</label>
  1276. <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
  1277. </div>
  1278. <div class="form-group">
  1279. <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;">
  1280. <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
  1281. <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
  1282. <polyline points="13 16 11 18 13 20"></polyline>
  1283. </svg>Confirm new password</label>
  1284. <input type="password" name="new_password_confirm" required minlength="8" autocomplete="new-password">
  1285. </div>
  1286. </div>
  1287. <button type="submit" class="btn primary">Change password</button>
  1288. </form>
  1289. </div>
  1290. </div>
  1291. </div>
  1292. </div>
  1293. <script>
  1294. function checkRateLimit(input, lowThreshold, highThreshold, tooHighMsg) {
  1295. var warn = document.getElementById('warn_' + input.name);
  1296. if (!warn) return;
  1297. var span = warn.querySelector('span');
  1298. var val = input.value === '' ? NaN : parseInt(input.value, 10);
  1299. ​
  1300. if (isNaN(val)) {
  1301. warn.classList.remove('show');
  1302. return;
  1303. }
  1304. if (val === 0) {
  1305. span.textContent = 'Set to 0 — this format will be blocked entirely.';
  1306. warn.classList.add('show');
  1307. } else if (val < lowThreshold) {
  1308. span.textContent = 'Very low limit — busy sites may have hits dropped and lose tracking data.';
  1309. warn.classList.add('show');
  1310. } else if (val > highThreshold) {
  1311. span.textContent = tooHighMsg;
  1312. warn.classList.add('show');
  1313. } else {
  1314. warn.classList.remove('show');
  1315. }
  1316. }
  1317. ​
  1318. (function() {
  1319. var el;
  1320. el = document.getElementsByName('rate_limit_xml')[0];
  1321. if (el) el.addEventListener('input', function() {
  1322. 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.');
  1323. });
  1324. el = document.getElementsByName('rate_limit_mysql')[0];
  1325. if (el) el.addEventListener('input', function() {
  1326. checkRateLimit(this, 1, 50, 'Very high — check your MySQL max_connections and server capacity can actually sustain this.');
  1327. });
  1328. el = document.getElementsByName('rate_limit_pmaf')[0];
  1329. if (el) el.addEventListener('input', function() {
  1330. 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.');
  1331. });
  1332. ​
  1333. ['rate_limit_xml', 'rate_limit_mysql', 'rate_limit_pmaf'].forEach(function(name) {
  1334. var el = document.getElementsByName(name)[0];
  1335. if (el) el.dispatchEvent(new Event('input'));
  1336. });
  1337. })();
  1338. ​
  1339. function setTheme(t) {
  1340. document.documentElement.setAttribute('data-theme', t);
  1341. localStorage.setItem('pm_theme', t);
  1342. }
  1343. ​
  1344. function toggleTheme() {
  1345. var h = document.documentElement;
  1346. var n = h.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
  1347. setTheme(n);
  1348. }
  1349. ​
  1350. function openSidebar() {
  1351. document.getElementById('sidebar').classList.add('open');
  1352. document.getElementById('backdrop').classList.add('show');
  1353. }
  1354. ​
  1355. function closeSidebar() {
  1356. document.getElementById('sidebar').classList.remove('open');
  1357. document.getElementById('backdrop').classList.remove('show');
  1358. }
  1359. (function() {
  1360. var sel = document.getElementById('default_theme');
  1361. var saved = localStorage.getItem('pm_theme');
  1362. if (sel && saved) sel.value = saved;
  1363. new MutationObserver(function(ms) {
  1364. ms.forEach(function(m) {
  1365. if (m.attributeName === 'data-theme' && sel) {
  1366. sel.value = document.documentElement.getAttribute('data-theme');
  1367. }
  1368. });
  1369. }).observe(document.documentElement, {
  1370. attributes: true
  1371. });
  1372. })();
  1373. </script>
  1374. </body>
  1375. ​
  1376. </html>