WebOrbiton
v1.0.0

PriviMetrics 2

548 lines · 18.8 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: login.php');
  9. exit;
  10. }
  11. ​
  12. $errors = [];
  13. $step = $_POST['step'] ?? '1';
  14. ​
  15. if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'finish') {
  16. $siteName = sanitize($_POST['site_name'] ?? 'PriviMetrics');
  17. $username = trim($_POST['username'] ?? '');
  18. $password = $_POST['password'] ?? '';
  19. $passwordConfirm = $_POST['password_confirm'] ?? '';
  20. $useMysql = ($_POST['use_mysql'] ?? '') === '1';
  21. $geoMode = ($_POST['geo_mode'] ?? 'privacy') === 'external' ? 'external' : 'privacy-friendly';
  22. ​
  23. if (strlen($username) < 3) $errors[] = 'Username must be at least 3 characters.';
  24. if (strlen($password) < 8) $errors[] = 'Password must be at least 8 characters.';
  25. if ($password !== $passwordConfirm) $errors[] = 'Passwords do not match.';
  26. ​
  27. $dbHost = trim($_POST['db_host'] ?? 'localhost');
  28. $dbName = trim($_POST['db_name'] ?? '');
  29. $dbUser = trim($_POST['db_user'] ?? '');
  30. $dbPass = $_POST['db_pass'] ?? '';
  31. $dbPort = trim($_POST['db_port'] ?? '3306');
  32. ​
  33. if ($useMysql) {
  34. if ($dbName === '' || $dbUser === '') {
  35. $errors[] = 'Please provide MySQL database details (database name and user).';
  36. } elseif (($paramsError = pmDbParamsError($dbHost, $dbPort, $dbName)) !== null) {
  37. $errors[] = $paramsError;
  38. } else {
  39. try {
  40. $testPdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4", $dbUser, $dbPass, [
  41. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  42. ]);
  43. } catch (PDOException $e) {
  44. $errors[] = 'Could not connect to MySQL: ' . $e->getMessage();
  45. }
  46. }
  47. }
  48. ​
  49. if (!$errors) {
  50. $appKey = bin2hex(random_bytes(32));
  51. $passwordHash = hashPassword($password);
  52. ​
  53. $envContent = "# PriviMetrics 2 - Configuration\n";
  54. $envContent .= "APP_INSTALLED=true\n";
  55. $envContent .= "APP_KEY={$appKey}\n";
  56. $envContent .= "SITE_NAME=\"{$siteName}\"\n";
  57. $envContent .= "DEFAULT_THEME=dark\n\n";
  58. ​
  59. $envContent .= "# Admin credentials\n";
  60. $envContent .= "ADMIN_USERNAME=\"{$username}\"\n";
  61. $envContent .= "ADMIN_PASSWORD_HASH=\"{$passwordHash}\"\n\n";
  62. ​
  63. $envContent .= "# Session\n";
  64. $envContent .= "SESSION_TIMEOUT=86400\n\n";
  65. ​
  66. $envContent .= "# Dashboard\n";
  67. $envContent .= "TOP_LISTS_LIMIT=8\n\n";
  68. ​
  69. $envContent .= "# Storage - MySQL (optional, used per-site alongside XML)\n";
  70. $envContent .= "DB_ENABLED=" . ($useMysql ? 'true' : 'false') . "\n";
  71. $envContent .= "DB_HOST=\"{$dbHost}\"\n";
  72. $envContent .= "DB_PORT=\"{$dbPort}\"\n";
  73. $envContent .= "DB_NAME=\"{$dbName}\"\n";
  74. $envContent .= "DB_USER=\"{$dbUser}\"\n";
  75. $envContent .= "DB_PASS=\"{$dbPass}\"\n\n";
  76. ​
  77. $envContent .= "# Rate limiting (requests per second, per tracking code)\n";
  78. $envContent .= "RATE_LIMIT_XML=2\n";
  79. $envContent .= "RATE_LIMIT_MYSQL=5\n\n";
  80. ​
  81. $envContent .= "# Geolocation - privacy-friendly (default, no IP sent anywhere) or external\n";
  82. $envContent .= "GEO_PROVIDER={$geoMode}\n";
  83. $envContent .= "GEO_EXTERNAL_SERVICE=ip-api\n";
  84. $envContent .= "GEO_API_KEY=\n\n";
  85. ​
  86. $envContent .= "# Privacy\n";
  87. $envContent .= "RESPECT_DNT=true\n";
  88. $envContent .= "TRACK_IP_DEFAULT=false\n\n";
  89. ​
  90. $envContent .= "# Data collection - global defaults (each site can override in its own settings)\n";
  91. $envContent .= "FEATURE_TRAFFIC_SOURCES=true\n";
  92. $envContent .= "FEATURE_VISITOR_COUNTRIES=true\n";
  93. $envContent .= "FEATURE_SEARCH_QUERIES=true\n";
  94. ​
  95. if (file_put_contents(PM_ENV_FILE, $envContent) === false) {
  96. $errors[] = 'Could not write the .env file. Check the directory write permissions.';
  97. } else {
  98. @chmod(PM_ENV_FILE, 0640);
  99. ​
  100. if (!is_dir(PM_DATA_DIR)) mkdir(PM_DATA_DIR, 0755, true);
  101. if (!is_dir(PM_DATA_DIR . '/cache')) mkdir(PM_DATA_DIR . '/cache', 0755, true);
  102. ​
  103. $htaccess = "Require all denied\n<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n";
  104. @file_put_contents(PM_DATA_DIR . '/.htaccess', $htaccess);
  105. ​
  106. if ($useMysql) {
  107. require_once __DIR__ . '/storage.php';
  108. $storage = new StorageManager();
  109. if ($storage->isMySQLAvailable()) {
  110. $storage->ensureSchema();
  111. $storage->createAdmin($username, $passwordHash);
  112. }
  113. }
  114. ​
  115. header('Location: login.php?installed=1');
  116. exit;
  117. }
  118. }
  119. }
  120. ?>
  121. <!DOCTYPE html>
  122. <html lang="en" data-theme="dark">
  123. ​
  124. <head>
  125. <meta charset="UTF-8">
  126. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  127. <title>Installation - PriviMetrics 2</title>
  128. <?= fontFaceStyleTag() ?>
  129. <script>
  130. (function() {
  131. var t = localStorage.getItem('pm_theme') || 'dark';
  132. document.documentElement.setAttribute('data-theme', t);
  133. })();
  134. </script>
  135. <style>
  136. :root {
  137. --font-body: <?= fontFamilyCss('body') ?>;
  138. --font-heading: <?= fontFamilyCss('heading') ?>;
  139. --font-value: <?= fontFamilyCss('value') ?>;
  140. }
  141. ​
  142. :root[data-theme="dark"] {
  143. --bg-primary: #0a0a0a;
  144. --bg-secondary: #141414;
  145. --bg-tertiary: #1c1c1c;
  146. --border-color: #262626;
  147. --text-primary: #eaeaea;
  148. --text-secondary: #9a9a9a;
  149. --accent: #f1484e;
  150. --accent-hover: #d53b40;
  151. --accent-soft: rgba(241, 72, 78, 0.12);
  152. --success: #22c55e;
  153. --error-bg: #2a1414;
  154. --error-border: #5c2323;
  155. --error-text: #fca5a5;
  156. --radius: 12px;
  157. }
  158. ​
  159. :root[data-theme="light"] {
  160. --bg-primary: #f7f7f8;
  161. --bg-secondary: #ffffff;
  162. --bg-tertiary: #f2f2f3;
  163. --border-color: #e5e5e7;
  164. --text-primary: #16171a;
  165. --text-secondary: #6b6d73;
  166. --accent: #f1484e;
  167. --accent-hover: #d53b40;
  168. --accent-soft: rgba(241, 72, 78, 0.08);
  169. --success: #16a34a;
  170. --error-bg: #fee2e2;
  171. --error-border: #fecaca;
  172. --error-text: #991b1b;
  173. --radius: 12px;
  174. }
  175. ​
  176. * {
  177. margin: 0;
  178. padding: 0;
  179. box-sizing: border-box;
  180. }
  181. ​
  182. body {
  183. font-family: var(--font-body);
  184. background:
  185. radial-gradient(circle at 20% 0%, rgba(241, 72, 78, 0.08), transparent 40%),
  186. var(--bg-primary);
  187. color: var(--text-primary);
  188. min-height: 100vh;
  189. display: flex;
  190. align-items: center;
  191. justify-content: center;
  192. padding: 20px;
  193. }
  194. ​
  195. .wrap {
  196. width: 100%;
  197. max-width: 560px;
  198. }
  199. ​
  200. .brand {
  201. display: flex;
  202. align-items: center;
  203. gap: 12px;
  204. margin-bottom: 28px;
  205. }
  206. ​
  207. .brand .mark {
  208. width: 40px;
  209. height: 40px;
  210. border-radius: 10px;
  211. background: var(--accent);
  212. display: flex;
  213. align-items: center;
  214. justify-content: center;
  215. font-weight: 700;
  216. color: white;
  217. flex-shrink: 0;
  218. }
  219. ​
  220. .brand h1 {
  221. font-family: var(--font-heading);
  222. font-size: 18px;
  223. font-weight: 600;
  224. }
  225. ​
  226. .brand span {
  227. color: var(--text-secondary);
  228. font-size: 13px;
  229. }
  230. ​
  231. .card {
  232. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  233. border: 1px solid var(--border-color);
  234. border-radius: 16px;
  235. 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);
  236. padding: 32px;
  237. }
  238. ​
  239. .steps {
  240. display: flex;
  241. gap: 8px;
  242. margin-bottom: 24px;
  243. }
  244. ​
  245. .steps .dot {
  246. flex: 1;
  247. height: 3px;
  248. border-radius: 2px;
  249. background: var(--border-color);
  250. }
  251. ​
  252. .steps .dot.active {
  253. background: var(--accent);
  254. }
  255. ​
  256. h2 {
  257. font-size: 20px;
  258. margin-bottom: 4px;
  259. }
  260. ​
  261. .sub {
  262. color: var(--text-secondary);
  263. font-size: 14px;
  264. margin-bottom: 24px;
  265. }
  266. ​
  267. .form-group {
  268. margin-bottom: 18px;
  269. }
  270. ​
  271. label {
  272. display: block;
  273. margin-bottom: 6px;
  274. font-size: 13px;
  275. font-weight: 500;
  276. color: var(--text-secondary);
  277. }
  278. ​
  279. input,
  280. select {
  281. width: 100%;
  282. padding: 11px 14px;
  283. background: var(--bg-primary);
  284. border: 1px solid var(--border-color);
  285. border-radius: 10px;
  286. color: var(--text-primary);
  287. font-size: 14px;
  288. font-family: inherit;
  289. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
  290. transition: border-color 0.2s ease, box-shadow 0.2s ease;
  291. }
  292. ​
  293. input:focus,
  294. select:focus {
  295. outline: none;
  296. border-color: var(--accent);
  297. box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
  298. }
  299. ​
  300. .row {
  301. display: grid;
  302. grid-template-columns: 1fr 1fr;
  303. gap: 12px;
  304. }
  305. ​
  306. @media (max-width: 480px) {
  307. .row {
  308. grid-template-columns: 1fr;
  309. }
  310. }
  311. ​
  312. .toggle-row {
  313. display: flex;
  314. align-items: center;
  315. justify-content: space-between;
  316. padding: 14px 16px;
  317. background: var(--bg-primary);
  318. border: 1px solid var(--border-color);
  319. border-radius: 8px;
  320. margin-bottom: 18px;
  321. cursor: pointer;
  322. }
  323. ​
  324. .toggle-row .label {
  325. font-size: 14px;
  326. font-weight: 500;
  327. }
  328. ​
  329. .toggle-row .desc {
  330. font-size: 12px;
  331. color: var(--text-secondary);
  332. margin-top: 2px;
  333. }
  334. ​
  335. .switch {
  336. width: 42px;
  337. height: 24px;
  338. background: var(--border-color);
  339. border-radius: 20px;
  340. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
  341. position: relative;
  342. transition: background 0.25s ease, box-shadow 0.25s ease;
  343. flex-shrink: 0;
  344. }
  345. ​
  346. .switch.on {
  347. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  348. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
  349. }
  350. ​
  351. .switch::after {
  352. content: '';
  353. position: absolute;
  354. top: 3px;
  355. left: 3px;
  356. width: 18px;
  357. height: 18px;
  358. background: white;
  359. border-radius: 50%;
  360. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  361. transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
  362. }
  363. ​
  364. .switch.on::after {
  365. transform: translateX(18px);
  366. }
  367. ​
  368. #mysql-fields {
  369. display: none;
  370. margin-top: 4px;
  371. }
  372. ​
  373. #mysql-fields.show {
  374. display: block;
  375. }
  376. ​
  377. button {
  378. width: 100%;
  379. padding: 13px 16px;
  380. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  381. color: white;
  382. border: 1px solid var(--accent);
  383. border-radius: 10px;
  384. font-size: 14px;
  385. font-weight: 600;
  386. cursor: pointer;
  387. margin-top: 8px;
  388. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
  389. transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
  390. }
  391. ​
  392. button:hover {
  393. background: linear-gradient(155deg, var(--accent-hover), var(--accent-hover));
  394. box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.14);
  395. transform: translateY(-1px);
  396. }
  397. ​
  398. button:active {
  399. transform: translateY(0) scale(0.98);
  400. }
  401. ​
  402. .error {
  403. padding: 12px 16px;
  404. background: var(--error-bg);
  405. border: 1px solid var(--error-border);
  406. border-radius: 8px;
  407. color: var(--error-text);
  408. font-size: 13px;
  409. margin-bottom: 20px;
  410. }
  411. ​
  412. .error ul {
  413. margin-left: 18px;
  414. margin-top: 4px;
  415. }
  416. ​
  417. .hint {
  418. font-size: 12px;
  419. color: var(--text-secondary);
  420. margin-top: 8px;
  421. line-height: 1.5;
  422. }
  423. </style>
  424. </head>
  425. ​
  426. <body>
  427. <div class="wrap">
  428. <div class="brand">
  429. <div class="mark">
  430. <img src="logo.webp" alt="Logo" width="24" height="24" loading="lazy">
  431. </div>
  432. <div>
  433. <h1>PriviMetrics 2</h1>
  434. <span>Installation Wizard</span>
  435. </div>
  436. </div>
  437. ​
  438. <div class="card">
  439. <div class="steps">
  440. <div class="dot active"></div>
  441. <div class="dot active"></div>
  442. </div>
  443. <h2>Installation setup</h2>
  444. <p class="sub">This is a one-time step — your settings will be saved to an .env file in the app directory.</p>
  445. ​
  446. <?php if ($errors): ?>
  447. <div class="error">
  448. <strong>Please fix the following errors:</strong>
  449. <ul><?php foreach ($errors as $e): ?><li><?= sanitize($e) ?></li><?php endforeach; ?></ul>
  450. </div>
  451. <?php endif; ?>
  452. ​
  453. <form method="post">
  454. <input type="hidden" name="step" value="finish">
  455. ​
  456. <div class="form-group">
  457. <label for="site_name">Installation name</label>
  458. <input type="text" id="site_name" name="site_name" value="<?= sanitize($_POST['site_name'] ?? 'PriviMetrics') ?>" required>
  459. </div>
  460. ​
  461. <div class="row">
  462. <div class="form-group">
  463. <label for="username">Admin username</label>
  464. <input type="text" id="username" name="username" value="<?= sanitize($_POST['username'] ?? '') ?>" required autocomplete="username">
  465. </div>
  466. <div class="form-group"></div>
  467. </div>
  468. ​
  469. <div class="row">
  470. <div class="form-group">
  471. <label for="password">Password</label>
  472. <input type="password" id="password" name="password" required autocomplete="new-password" minlength="8">
  473. </div>
  474. <div class="form-group">
  475. <label for="password_confirm">Confirm password</label>
  476. <input type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password" minlength="8">
  477. </div>
  478. </div>
  479. ​
  480. <div class="toggle-row" onclick="toggleMysql()">
  481. <div>
  482. <div class="label">Enable MySQL support</div>
  483. <div class="desc">Without this, all sites use XML storage. You can enable MySQL as a per-site option.</div>
  484. </div>
  485. <div class="switch" id="mysql-switch"></div>
  486. </div>
  487. <input type="hidden" name="use_mysql" id="use_mysql" value="0">
  488. ​
  489. <div id="mysql-fields">
  490. <div class="row">
  491. <div class="form-group">
  492. <label for="db_host">Database host</label>
  493. <input type="text" id="db_host" name="db_host" value="<?= sanitize($_POST['db_host'] ?? 'localhost') ?>">
  494. </div>
  495. <div class="form-group">
  496. <label for="db_port">Port</label>
  497. <input type="text" id="db_port" name="db_port" value="<?= sanitize($_POST['db_port'] ?? '3306') ?>">
  498. </div>
  499. </div>
  500. <div class="form-group">
  501. <label for="db_name">Database name</label>
  502. <input type="text" id="db_name" name="db_name" value="<?= sanitize($_POST['db_name'] ?? '') ?>">
  503. </div>
  504. <div class="row">
  505. <div class="form-group">
  506. <label for="db_user">Database user</label>
  507. <input type="text" id="db_user" name="db_user" value="<?= sanitize($_POST['db_user'] ?? '') ?>">
  508. </div>
  509. <div class="form-group">
  510. <label for="db_pass">Database password</label>
  511. <input type="password" id="db_pass" name="db_pass" autocomplete="new-password">
  512. </div>
  513. </div>
  514. <p class="hint">The database and tables (pm_sites, pm_hits, pm_admin) will be created automatically. Make sure the database already exists on your hosting.</p>
  515. </div>
  516. ​
  517. <div class="form-group" style="margin-top: 18px;">
  518. <label for="geo_mode">Geolocation</label>
  519. <select id="geo_mode" name="geo_mode">
  520. <option value="privacy">Privacy-first (no IP, recommended)</option>
  521. <option value="external">External provider (sends IP, configure in Settings)</option>
  522. </select>
  523. </div>
  524. ​
  525. <button type="submit">Install PriviMetrics 2</button>
  526. </form>
  527. </div>
  528. </div>
  529. <script>
  530. function toggleMysql() {
  531. const sw = document.getElementById('mysql-switch');
  532. const input = document.getElementById('use_mysql');
  533. const fields = document.getElementById('mysql-fields');
  534. const on = sw.classList.toggle('on');
  535. input.value = on ? '1' : '0';
  536. fields.classList.toggle('show', on);
  537. }
  538. ​
  539. function toggleTheme() {
  540. var h = document.documentElement;
  541. var n = h.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
  542. h.setAttribute('data-theme', n);
  543. localStorage.setItem('pm_theme', n);
  544. }
  545. </script>
  546. </body>
  547. ​
  548. </html>