aboutsummaryrefslogtreecommitdiffstats
path: root/api/hit.php
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--api/hit.php148
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']);
+}