v1.0.3
PriviMetrics 2
- <?php
-
- declare(strict_types=1);
- require_once __DIR__ . '/functions.php';
- require_once __DIR__ . '/font-options.php';
-
- if (envIsInstalled()) {
- header('Location: login.php');
- exit;
- }
-
- $errors = [];
- $step = $_POST['step'] ?? '1';
-
- if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'finish') {
- $siteName = sanitize($_POST['site_name'] ?? 'PriviMetrics');
- $username = trim($_POST['username'] ?? '');
- $password = $_POST['password'] ?? '';
- $passwordConfirm = $_POST['password_confirm'] ?? '';
- $useMysql = ($_POST['use_mysql'] ?? '') === '1';
- $geoMode = ($_POST['geo_mode'] ?? 'privacy') === 'external' ? 'external' : 'privacy-friendly';
-
- if (strlen($username) < 3) $errors[] = 'Username must be at least 3 characters.';
- if (strlen($password) < 8) $errors[] = 'Password must be at least 8 characters.';
- if ($password !== $passwordConfirm) $errors[] = 'Passwords do not match.';
-
- $dbHost = trim($_POST['db_host'] ?? 'localhost');
- $dbName = trim($_POST['db_name'] ?? '');
- $dbUser = trim($_POST['db_user'] ?? '');
- $dbPass = $_POST['db_pass'] ?? '';
- $dbPort = trim($_POST['db_port'] ?? '3306');
-
- if ($useMysql) {
- if ($dbName === '' || $dbUser === '') {
- $errors[] = 'Please provide MySQL database details (database name and user).';
- } elseif (($paramsError = pmDbParamsError($dbHost, $dbPort, $dbName)) !== null) {
- $errors[] = $paramsError;
- } else {
- try {
- $testPdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4", $dbUser, $dbPass, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- ]);
- } catch (PDOException $e) {
- $errors[] = 'Could not connect to MySQL: ' . $e->getMessage();
- }
- }
- }
-
- if (!$errors) {
- $appKey = bin2hex(random_bytes(32));
- $passwordHash = hashPassword($password);
-
- $envContent = "# PriviMetrics 2 - Configuration\n";
- $envContent .= "APP_INSTALLED=true\n";
- $envContent .= "APP_KEY={$appKey}\n";
- $envContent .= "SITE_NAME=\"{$siteName}\"\n";
- $envContent .= "DEFAULT_THEME=dark\n\n";
-
- $envContent .= "# Admin credentials\n";
- $envContent .= "ADMIN_USERNAME=\"{$username}\"\n";
- $envContent .= "ADMIN_PASSWORD_HASH=\"{$passwordHash}\"\n\n";
-
- $envContent .= "# Session\n";
- $envContent .= "SESSION_TIMEOUT=86400\n\n";
-
- $envContent .= "# Dashboard\n";
- $envContent .= "TOP_LISTS_LIMIT=8\n\n";
-
- $envContent .= "# Storage - MySQL (optional, used per-site alongside XML)\n";
- $envContent .= "DB_ENABLED=" . ($useMysql ? 'true' : 'false') . "\n";
- $envContent .= "DB_HOST=\"{$dbHost}\"\n";
- $envContent .= "DB_PORT=\"{$dbPort}\"\n";
- $envContent .= "DB_NAME=\"{$dbName}\"\n";
- $envContent .= "DB_USER=\"{$dbUser}\"\n";
- $envContent .= "DB_PASS=\"{$dbPass}\"\n\n";
-
- $envContent .= "# Rate limiting (requests per second, per tracking code)\n";
- $envContent .= "RATE_LIMIT_XML=2\n";
- $envContent .= "RATE_LIMIT_MYSQL=5\n\n";
-
- $envContent .= "# Geolocation - privacy-friendly (default, no IP sent anywhere) or external\n";
- $envContent .= "GEO_PROVIDER={$geoMode}\n";
- $envContent .= "GEO_EXTERNAL_SERVICE=ip-api\n";
- $envContent .= "GEO_API_KEY=\n\n";
-
- $envContent .= "# Privacy\n";
- $envContent .= "RESPECT_DNT=true\n";
- $envContent .= "TRACK_IP_DEFAULT=false\n\n";
-
- $envContent .= "# Data collection - global defaults (each site can override in its own settings)\n";
- $envContent .= "FEATURE_TRAFFIC_SOURCES=true\n";
- $envContent .= "FEATURE_VISITOR_COUNTRIES=true\n";
- $envContent .= "FEATURE_SEARCH_QUERIES=true\n";
-
- if (file_put_contents(PM_ENV_FILE, $envContent) === false) {
- $errors[] = 'Could not write the .env file. Check the directory write permissions.';
- } else {
- @chmod(PM_ENV_FILE, 0640);
-
- if (!is_dir(PM_DATA_DIR)) mkdir(PM_DATA_DIR, 0755, true);
- if (!is_dir(PM_DATA_DIR . '/cache')) mkdir(PM_DATA_DIR . '/cache', 0755, true);
-
- $htaccess = "Require all denied\n<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n";
- @file_put_contents(PM_DATA_DIR . '/.htaccess', $htaccess);
-
- if ($useMysql) {
- require_once __DIR__ . '/storage.php';
- $storage = new StorageManager();
- if ($storage->isMySQLAvailable()) {
- $storage->ensureSchema();
- $storage->createAdmin($username, $passwordHash);
- }
- }
-
- header('Location: login.php?installed=1');
- exit;
- }
- }
- }
- ?>
- <!DOCTYPE html>
- <html lang="en" data-theme="dark">
-
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Installation - PriviMetrics 2</title>
- <?= fontFaceStyleTag() ?>
- <script>
- (function() {
- var t = localStorage.getItem('pm_theme') || 'dark';
- document.documentElement.setAttribute('data-theme', t);
- })();
- </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;
- --border-color: #262626;
- --text-primary: #eaeaea;
- --text-secondary: #9a9a9a;
- --accent: #f1484e;
- --accent-hover: #d53b40;
- --accent-soft: rgba(241, 72, 78, 0.12);
- --success: #22c55e;
- --error-bg: #2a1414;
- --error-border: #5c2323;
- --error-text: #fca5a5;
- --radius: 12px;
- }
-
- :root[data-theme="light"] {
- --bg-primary: #f7f7f8;
- --bg-secondary: #ffffff;
- --bg-tertiary: #f2f2f3;
- --border-color: #e5e5e7;
- --text-primary: #16171a;
- --text-secondary: #6b6d73;
- --accent: #f1484e;
- --accent-hover: #d53b40;
- --accent-soft: rgba(241, 72, 78, 0.08);
- --success: #16a34a;
- --error-bg: #fee2e2;
- --error-border: #fecaca;
- --error-text: #991b1b;
- --radius: 12px;
- }
-
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
-
- body {
- font-family: var(--font-body);
- background:
- radial-gradient(circle at 20% 0%, rgba(241, 72, 78, 0.08), transparent 40%),
- var(--bg-primary);
- color: var(--text-primary);
- min-height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 20px;
- }
-
- .wrap {
- width: 100%;
- max-width: 560px;
- }
-
- .brand {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 28px;
- }
-
- .brand .mark {
- width: 40px;
- height: 40px;
- border-radius: 10px;
- background: var(--accent);
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: 700;
- color: white;
- flex-shrink: 0;
- }
-
- .brand h1 {
- font-family: var(--font-heading);
- font-size: 18px;
- font-weight: 600;
- }
-
- .brand span {
- color: var(--text-secondary);
- font-size: 13px;
- }
-
- .card {
- 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: 32px;
- }
-
- .steps {
- display: flex;
- gap: 8px;
- margin-bottom: 24px;
- }
-
- .steps .dot {
- flex: 1;
- height: 3px;
- border-radius: 2px;
- background: var(--border-color);
- }
-
- .steps .dot.active {
- background: var(--accent);
- }
-
- h2 {
- font-size: 20px;
- margin-bottom: 4px;
- }
-
- .sub {
- color: var(--text-secondary);
- font-size: 14px;
- margin-bottom: 24px;
- }
-
- .form-group {
- margin-bottom: 18px;
- }
-
- label {
- display: block;
- margin-bottom: 6px;
- font-size: 13px;
- font-weight: 500;
- color: var(--text-secondary);
- }
-
- input,
- select {
- width: 100%;
- padding: 11px 14px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 10px;
- color: var(--text-primary);
- font-size: 14px;
- 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;
- }
-
- input:focus,
- 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: 1fr 1fr;
- gap: 12px;
- }
-
- @media (max-width: 480px) {
- .row {
- grid-template-columns: 1fr;
- }
- }
-
- .toggle-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 16px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- margin-bottom: 18px;
- cursor: pointer;
- }
-
- .toggle-row .label {
- font-size: 14px;
- font-weight: 500;
- }
-
- .toggle-row .desc {
- font-size: 12px;
- color: var(--text-secondary);
- margin-top: 2px;
- }
-
- .switch {
- width: 42px;
- height: 24px;
- background: var(--border-color);
- border-radius: 20px;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
- position: relative;
- transition: background 0.25s ease, box-shadow 0.25s ease;
- flex-shrink: 0;
- }
-
- .switch.on {
- 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);
- }
-
- .switch::after {
- content: '';
- position: absolute;
- top: 3px;
- left: 3px;
- 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.on::after {
- transform: translateX(18px);
- }
-
- #mysql-fields {
- display: none;
- margin-top: 4px;
- }
-
- #mysql-fields.show {
- display: block;
- }
-
- button {
- width: 100%;
- padding: 13px 16px;
- background: linear-gradient(155deg, var(--accent-hover), var(--accent));
- color: white;
- border: 1px solid var(--accent);
- border-radius: 10px;
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- margin-top: 8px;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
- transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
- }
-
- button: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);
- }
-
- button:active {
- transform: translateY(0) scale(0.98);
- }
-
- .error {
- padding: 12px 16px;
- background: var(--error-bg);
- border: 1px solid var(--error-border);
- border-radius: 8px;
- color: var(--error-text);
- font-size: 13px;
- margin-bottom: 20px;
- }
-
- .error ul {
- margin-left: 18px;
- margin-top: 4px;
- }
-
- .hint {
- font-size: 12px;
- color: var(--text-secondary);
- margin-top: 8px;
- line-height: 1.5;
- }
- </style>
- </head>
-
- <body>
- <div class="wrap">
- <div class="brand">
- <div class="mark">
- <img src="logo.webp" alt="Logo" width="24" height="24" loading="lazy">
- </div>
- <div>
- <h1>PriviMetrics 2</h1>
- <span>Installation Wizard</span>
- </div>
- </div>
-
- <div class="card">
- <div class="steps">
- <div class="dot active"></div>
- <div class="dot active"></div>
- </div>
- <h2>Installation setup</h2>
- <p class="sub">This is a one-time step — your settings will be saved to an .env file in the app directory.</p>
-
- <?php if ($errors): ?>
- <div class="error">
- <strong>Please fix the following errors:</strong>
- <ul><?php foreach ($errors as $e): ?><li><?= sanitize($e) ?></li><?php endforeach; ?></ul>
- </div>
- <?php endif; ?>
-
- <form method="post">
- <input type="hidden" name="step" value="finish">
-
- <div class="form-group">
- <label for="site_name">Installation name</label>
- <input type="text" id="site_name" name="site_name" value="<?= sanitize($_POST['site_name'] ?? 'PriviMetrics') ?>" required>
- </div>
-
- <div class="row">
- <div class="form-group">
- <label for="username">Admin username</label>
- <input type="text" id="username" name="username" value="<?= sanitize($_POST['username'] ?? '') ?>" required autocomplete="username">
- </div>
- <div class="form-group"></div>
- </div>
-
- <div class="row">
- <div class="form-group">
- <label for="password">Password</label>
- <input type="password" id="password" name="password" required autocomplete="new-password" minlength="8">
- </div>
- <div class="form-group">
- <label for="password_confirm">Confirm password</label>
- <input type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password" minlength="8">
- </div>
- </div>
-
- <div class="toggle-row" onclick="toggleMysql()">
- <div>
- <div class="label">Enable MySQL support</div>
- <div class="desc">Without this, all sites use XML storage. You can enable MySQL as a per-site option.</div>
- </div>
- <div class="switch" id="mysql-switch"></div>
- </div>
- <input type="hidden" name="use_mysql" id="use_mysql" value="0">
-
- <div id="mysql-fields">
- <div class="row">
- <div class="form-group">
- <label for="db_host">Database host</label>
- <input type="text" id="db_host" name="db_host" value="<?= sanitize($_POST['db_host'] ?? 'localhost') ?>">
- </div>
- <div class="form-group">
- <label for="db_port">Port</label>
- <input type="text" id="db_port" name="db_port" value="<?= sanitize($_POST['db_port'] ?? '3306') ?>">
- </div>
- </div>
- <div class="form-group">
- <label for="db_name">Database name</label>
- <input type="text" id="db_name" name="db_name" value="<?= sanitize($_POST['db_name'] ?? '') ?>">
- </div>
- <div class="row">
- <div class="form-group">
- <label for="db_user">Database user</label>
- <input type="text" id="db_user" name="db_user" value="<?= sanitize($_POST['db_user'] ?? '') ?>">
- </div>
- <div class="form-group">
- <label for="db_pass">Database password</label>
- <input type="password" id="db_pass" name="db_pass" autocomplete="new-password">
- </div>
- </div>
- <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>
- </div>
-
- <div class="form-group" style="margin-top: 18px;">
- <label for="geo_mode">Geolocation</label>
- <select id="geo_mode" name="geo_mode">
- <option value="privacy">Privacy-first (no IP, recommended)</option>
- <option value="external">External provider (sends IP, configure in Settings)</option>
- </select>
- </div>
-
- <button type="submit">Install PriviMetrics 2</button>
- </form>
- </div>
- </div>
- <script>
- function toggleMysql() {
- const sw = document.getElementById('mysql-switch');
- const input = document.getElementById('use_mysql');
- const fields = document.getElementById('mysql-fields');
- const on = sw.classList.toggle('on');
- input.value = on ? '1' : '0';
- fields.classList.toggle('show', on);
- }
-
- function toggleTheme() {
- var h = document.documentElement;
- var n = h.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
- h.setAttribute('data-theme', n);
- localStorage.setItem('pm_theme', n);
- }
- </script>
- </body>
-
- </html>