aboutsummaryrefslogtreecommitdiffstats
path: root/api/hit.php
blob: 03c3965bdec29d2ad866436cecd185eb283450ca (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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']);
}