diff options
| author | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
|---|---|---|
| committer | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
| commit | 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch) | |
| tree | 85c6ee5ad58b860144551184d4cf86b560c62b91 /api/hit.php | |
| download | www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip | |
Diffstat (limited to '')
| -rw-r--r-- | api/hit.php | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/api/hit.php b/api/hit.php new file mode 100644 index 0000000..03c3965 --- /dev/null +++ b/api/hit.php @@ -0,0 +1,148 @@ +<?php +/** + * Privacy-friendly page hit logger. + * + * Stores: host, path, coarse browser/OS label, language, referrer host, + * local visitor number, timestamp. + * Never stores: IP addresses, full User-Agent strings, cookies, or query params. + * + * Called by assets/js/visit-counter.js via sendBeacon/fetch from any + * *.sillylaird.ca page (CORS allowlisted). + */ + +declare(strict_types=1); + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); +header('Referrer-Policy: no-referrer'); + +// --- CORS: only sillylaird.ca hosts (and www) may POST hits ----------------- +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +$originHost = ''; +$corsOk = false; +if (is_string($origin) && $origin !== '') { + $oh = parse_url($origin, PHP_URL_HOST); + if (is_string($oh) && ( + $oh === 'sillylaird.ca' + || $oh === 'www.sillylaird.ca' + || str_ends_with($oh, '.sillylaird.ca') + )) { + $originHost = $oh; + header('Access-Control-Allow-Origin: ' . $origin); + header('Vary: Origin'); + header('Access-Control-Allow-Methods: POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + header('Access-Control-Max-Age: 86400'); + $corsOk = true; + } else { + // Browser sent a foreign Origin — refuse entirely (no logging). + // Use 400 (not 403): www nginx maps 403 → /403.php via fastcgi_intercept_errors. + http_response_code(400); + echo json_encode(['ok' => false, 'err' => 'origin not allowed']); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + if ($corsOk) { + http_response_code(204); + } else { + http_response_code(400); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['ok' => false, 'err' => 'POST only']); + exit; +} + +require_once __DIR__ . '/../partials/proxy_helpers.php'; +require_once __DIR__ . '/../partials/traffic.php'; + +// Rate limit abuse without logging the IP into the traffic DB. +if (!proxy_rate_limit('hit_log', 60, 60)) { + http_response_code(429); + echo json_encode(['ok' => false, 'err' => 'rate limited']); + exit; +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; +if (!is_array($data)) { + // Also accept form-urlencoded beacons + $data = $_POST; +} + +$clientHost = trim((string)($data['host'] ?? '')); +$path = (string)($data['path'] ?? '/'); +$localCount = (int)($data['local_count'] ?? 0); +$isNew = !empty($data['unique']) || !empty($data['is_new']); +$lang = trim((string)($data['lang'] ?? '')); +$ref = trim((string)($data['ref'] ?? '')); + +// Resolve host without trusting arbitrary input. +// Prefer CORS Origin (browser-set), then allowed client host, then request Host. +$host = ''; +if ($originHost !== '' && traffic_host_allowed($originHost)) { + $host = $originHost; +} elseif (traffic_host_allowed($clientHost)) { + $host = $clientHost; +} else { + $rh = (string)($_SERVER['HTTP_HOST'] ?? ''); + if (traffic_host_allowed($rh)) { + $host = $rh; + } +} +if ($host === '' || !traffic_host_allowed($host)) { + http_response_code(400); + echo json_encode(['ok' => false, 'err' => 'host not allowed']); + exit; +} + +$path = traffic_normalize_path($path); +if ($path === '' || strlen($path) > 500) { + http_response_code(400); + echo json_encode(['ok' => false, 'err' => 'bad path']); + exit; +} + +// Skip noisy/internal paths even if a client tries to report them. +if (preg_match('#^/(admin|api|partials|locales|tools|docs)(/|$)#i', $path)) { + echo json_encode(['ok' => true, 'skipped' => true]); + exit; +} + +$uaHeader = (string)($_SERVER['HTTP_USER_AGENT'] ?? ''); +$isBot = traffic_is_bot($uaHeader); +$visitor = traffic_visitor_label($uaHeader); // e.g. "Firefox · Linux" or "Bot · Google" — not an IP +$refHost = traffic_ref_host($ref !== '' ? $ref : (string)($_SERVER['HTTP_REFERER'] ?? '')); +$lang = traffic_lang($lang !== '' ? $lang : (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '')); +$localCount = max(0, min($localCount, 99999999)); + +try { + $result = traffic_record_hit([ + 'host' => $host, + 'path' => $path, + 'visitor' => $visitor, + 'lang' => $lang, + 'ref_host' => $refHost, + 'local_count' => $localCount, + // Bots never count as unique human sessions. + 'is_new' => ($isNew && !$isBot) ? 1 : 0, + 'is_bot' => $isBot ? 1 : 0, + ]); + echo json_encode([ + 'ok' => true, + 'page' => $result['page_key'], + 'hits' => $result['hits'], + 'uniq' => $result['unique_sessions'], + 'bots' => $result['bot_hits'] ?? 0, + 'bot' => !empty($result['is_bot']), + ]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['ok' => false, 'err' => 'store failed']); +} |
