| Server IP : 213.255.246.8 / Your IP : 216.73.217.178 Web Server : Apache System : Linux dublin.stapolin.com 5.14.0-362.18.1.el9_3.x86_64 #1 SMP PREEMPT_DYNAMIC Mon Jan 29 07:05:48 EST 2024 x86_64 User : stapolin ( 1019) PHP Version : 8.4.24 Disable Function : exec,passthru,shell_exec,system MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /home/stapolin/public_html/tracking.stapolin.com/api/ |
Upload File : |
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once APP_ROOT . '/includes/conversions.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
handle_cors_preflight();
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(false, null, 'Unsupported request method.', 405);
}
$raw = file_get_contents('php://input') ?: '';
if (strlen($raw) > 16384) {
json_response(false, null, 'Payload too large.', 413);
}
$payload = json_decode($raw, true);
if (!is_array($payload)) {
json_response(false, null, 'Invalid payload.', 400);
}
$siteId = string_value($payload, 'site_id', 80);
$website = db_fetch_one('SELECT * FROM websites WHERE public_id = ? AND status = "active"', 's', [$siteId]);
if ($website === null) {
log_tracking_error(null, 'invalid_site', 'Unknown or inactive site ID.');
json_response(false, null, 'Invalid site.', 404);
}
if (!origin_is_allowed((int) $website['id'])) {
log_tracking_error((int) $website['id'], 'invalid_origin', 'Origin is not allowed.');
json_response(false, null, 'Origin is not allowed.', 403);
}
send_cors_headers();
if ((int) $website['bot_filtering_enabled'] === 1 && looks_like_bot()) {
log_tracking_error((int) $website['id'], 'bot_filtered', 'Known bot or automated browser filtered.');
json_response(true, ['accepted' => false, 'reason' => 'bot_filtered'], null, 202);
}
if (rate_limited((int) $website['id'])) {
log_tracking_error((int) $website['id'], 'rate_limited', 'Tracking request rate limit exceeded.');
json_response(false, null, 'Rate limit exceeded.', 429);
}
if ((int) $website['honour_do_not_track'] === 1 && boolean_value($payload['do_not_track'] ?? false)) {
json_response(true, ['accepted' => false, 'reason' => 'do_not_track'], null, 202);
}
$type = string_value($payload, 'type', 40);
if (!in_array($type, ['page_view', 'page_exit', 'custom_event'], true)) {
json_response(false, null, 'Unsupported event type.', 400);
}
try {
db()->begin_transaction();
$now = utc_now();
$visitorId = upsert_visitor((int) $website['id'], string_value($payload, 'visitor_id', 80), string_value($payload, 'consent_mode', 20), $now);
$sessionId = upsert_session((int) $website['id'], $visitorId, string_value($payload, 'session_id', 80), $payload, $now);
$pageId = upsert_page((int) $website['id'], normalised_path($payload, $website), string_value($payload, 'title', 300), $now);
if ($type === 'page_view') {
$pageInserted = save_page_view((int) $website['id'], $sessionId, $visitorId, $pageId, $payload, $now);
if ($pageInserted) {
detect_page_view_conversions((int) $website['id'], $sessionId, $visitorId, $pageId, normalised_path($payload, $website), $now);
}
} elseif ($type === 'page_exit') {
save_page_exit((int) $website['id'], $sessionId, $visitorId, $pageId, $payload, $now);
} else {
$eventId = save_event((int) $website['id'], $sessionId, $visitorId, $pageId, $payload, $type, $now);
if ($eventId !== null) {
detect_event_conversions((int) $website['id'], $sessionId, $visitorId, $eventId, event_name_from_payload($payload, $type), numeric_value($payload['value'] ?? null), string_value($payload, 'currency', 3) ?: null, $now);
}
}
update_live_activity((int) $website['id'], $sessionId, $visitorId, $pageId, $type, $now);
db()->commit();
json_response(true, ['accepted' => true], null, 202);
} catch (Throwable $exception) {
db()->rollback();
error_log('Collect failed: ' . $exception->getMessage());
log_tracking_error(
(int) $website['id'],
'collect_failed',
'Collection failed: ' . collect_error_message($exception),
collect_request_meta($payload, $exception)
);
json_response(false, null, 'Collection failed.', 500);
}
function handle_cors_preflight(): never
{
send_cors_headers();
http_response_code(204);
exit;
}
function send_cors_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Vary: Origin');
}
}
function origin_is_allowed(int $websiteId): bool
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$host = parse_url($origin !== '' ? $origin : $referer, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return false;
}
$host = strtolower($host);
$row = db_fetch_one('SELECT id FROM website_domains WHERE website_id = ? AND domain = ?', 'is', [$websiteId, $host]);
return $row !== null;
}
function looks_like_bot(): bool
{
$ua = strtolower($_SERVER['HTTP_USER_AGENT'] ?? '');
if ($ua === '') {
return true;
}
return preg_match('/bot|crawler|spider|slurp|headless|phantom|selenium|uptime|monitor|curl|wget|python-requests|httpclient/', $ua) === 1;
}
function rate_limited(int $websiteId): bool
{
$windowStart = gmdate('Y-m-d H:i:00');
$ipRange = ip_rate_bucket(client_ip());
$bucket = 'collect';
$limit = 180;
$row = db_fetch_one(
'SELECT id, hits FROM rate_limits WHERE website_id = ? AND ip_range = ? AND bucket_key = ? AND window_start = ?',
'isss',
[$websiteId, $ipRange, $bucket, $windowStart]
);
if ($row === null) {
db_execute(
'INSERT INTO rate_limits (website_id, ip_range, bucket_key, hits, window_start, updated_at) VALUES (?, ?, ?, 1, ?, ?)',
'issss',
[$websiteId, $ipRange, $bucket, $windowStart, utc_now()]
);
return false;
}
$hits = (int) $row['hits'] + 1;
db_execute('UPDATE rate_limits SET hits = ?, updated_at = ? WHERE id = ?', 'isi', [$hits, utc_now(), (int) $row['id']]);
return $hits > $limit;
}
function ip_rate_bucket(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.0/24';
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return substr($ip, 0, 20) . '::/64';
}
return 'unknown';
}
function string_value(array $payload, string $key, int $maxLength): string
{
$value = $payload[$key] ?? '';
if (!is_scalar($value)) {
return '';
}
return substr(trim((string) $value), 0, $maxLength);
}
function boolean_value(mixed $value): bool
{
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
function normalised_path(array $payload, array $website): string
{
$url = string_value($payload, 'url', 900);
$path = parse_url($url, PHP_URL_PATH) ?: (string) ($payload['path'] ?? '/');
$path = '/' . ltrim($path, '/');
$path = rtrim($path, '/') ?: '/';
$query = (string) (parse_url($url, PHP_URL_QUERY) ?? '');
$queryHandling = (string) ($website['query_handling'] ?? 'drop_all');
if ($query === '' || $queryHandling === 'drop_all') {
return $path;
}
parse_str($query, $params);
unset($params['utm_source'], $params['utm_medium'], $params['utm_campaign'], $params['utm_term'], $params['utm_content'], $params['gclid'], $params['fbclid']);
if ($queryHandling === 'keep_allowed') {
$allowed = array_filter(array_map('trim', explode(',', (string) ($website['allowed_query_params'] ?? ''))));
$params = array_intersect_key($params, array_flip($allowed));
}
if ($params === []) {
return $path;
}
ksort($params);
return $path . '?' . http_build_query($params);
}
function upsert_visitor(int $websiteId, string $publicId, string $consentMode, string $now): ?int
{
if ($publicId === '') {
return null;
}
$consentMode = in_array($consentMode, ['persistent', 'session', 'cookieless'], true) ? $consentMode : 'session';
$row = db_fetch_one('SELECT id FROM visitors WHERE website_id = ? AND public_id = ?', 'is', [$websiteId, $publicId]);
if ($row !== null) {
db_execute('UPDATE visitors SET last_seen_at = ?, consent_mode = ? WHERE id = ?', 'ssi', [$now, $consentMode, (int) $row['id']]);
return (int) $row['id'];
}
db_execute(
'INSERT INTO visitors (website_id, public_id, first_seen_at, last_seen_at, consent_mode) VALUES (?, ?, ?, ?, ?)',
'issss',
[$websiteId, $publicId, $now, $now, $consentMode]
);
return db_insert_id();
}
function upsert_session(int $websiteId, ?int $visitorId, string $publicId, array $payload, string $now): int
{
if ($publicId === '') {
$publicId = random_token(16);
}
$row = db_fetch_one('SELECT id FROM sessions WHERE website_id = ? AND public_id = ?', 'is', [$websiteId, $publicId]);
if ($row !== null) {
$existing = db_fetch_one('SELECT id, last_activity_at FROM sessions WHERE id = ?', 'i', [(int) $row['id']]);
$timeoutAt = gmdate('Y-m-d H:i:s', time() - (int) config_value('tracking.session_timeout_minutes', 30) * 60);
if ($existing !== null && (string) $existing['last_activity_at'] < $timeoutAt) {
db_execute('UPDATE sessions SET ended_at = COALESCE(ended_at, last_activity_at) WHERE id = ?', 'i', [(int) $existing['id']]);
$publicId = substr($publicId . '_' . random_token(6), 0, 80);
} else {
$geo = geo_from_request();
db_execute(
'UPDATE sessions SET visitor_id = ?, last_activity_at = ?, total_events = total_events + 1, country = COALESCE(country, ?), region = COALESCE(region, ?), city = COALESCE(city, ?) WHERE id = ?',
'issssi',
[$visitorId, $now, $geo['country'], $geo['region'], $geo['city'], (int) $row['id']]
);
return (int) $row['id'];
}
}
$agent = parse_user_agent($_SERVER['HTTP_USER_AGENT'] ?? '');
$referrer = string_value($payload, 'referrer', 700);
$geo = geo_from_request();
db_execute(
'INSERT INTO sessions (website_id, visitor_id, public_id, started_at, last_activity_at, referrer_url, referrer_domain, device_category, browser, browser_version, operating_system, consent_mode, country, region, city) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'iisssssssssssss',
[$websiteId, $visitorId, $publicId, $now, $now, $referrer, referrer_domain($referrer), $agent['device'], $agent['browser'], $agent['version'], $agent['os'], consent_mode(string_value($payload, 'consent_mode', 20)), $geo['country'], $geo['region'], $geo['city']]
);
return db_insert_id();
}
function geo_from_request(): array
{
$country = country_header_value([
'HTTP_CF_IPCOUNTRY',
'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
'HTTP_X_APPENGINE_COUNTRY',
'HTTP_X_VERCEL_IP_COUNTRY',
'HTTP_X_COUNTRY_CODE',
]);
$region = region_header_value([
'HTTP_CF_REGION',
'HTTP_X_APPENGINE_REGION',
'HTTP_X_VERCEL_IP_COUNTRY_REGION',
'HTTP_X_REGION',
]);
$city = city_header_value([
'HTTP_CF_IPCITY',
'HTTP_X_VERCEL_IP_CITY',
'HTTP_X_CITY',
]);
if ($country === null) {
$lookup = geoip_lookup_country(client_ip());
if ($lookup !== null) {
$country = $lookup['country'];
$region = $lookup['region'];
$city = $lookup['city'];
}
}
return ['country' => $country, 'region' => $region, 'city' => $city];
}
function country_header_value(array $headerNames): ?string
{
foreach ($headerNames as $headerName) {
$value = strtoupper(trim((string) ($_SERVER[$headerName] ?? '')));
if (preg_match('/^[A-Z]{2}$/', $value) === 1 && !in_array($value, ['XX', 'T1'], true)) {
return $value;
}
}
return null;
}
function region_header_value(array $headerNames): ?string
{
foreach ($headerNames as $headerName) {
$value = trim((string) ($_SERVER[$headerName] ?? ''));
if ($value !== '') {
return substr(preg_replace('/[^\p{L}\p{N}\s._-]/u', '', $value) ?: '', 0, 120) ?: null;
}
}
return null;
}
function city_header_value(array $headerNames): ?string
{
return region_header_value($headerNames);
}
function consent_mode(string $mode): string
{
return in_array($mode, ['persistent', 'session', 'cookieless'], true) ? $mode : 'session';
}
function referrer_domain(string $referrer): ?string
{
$host = parse_url($referrer, PHP_URL_HOST);
return is_string($host) && $host !== '' ? strtolower($host) : null;
}
function parse_user_agent(string $ua): array
{
$lower = strtolower($ua);
$device = str_contains($lower, 'tablet') || str_contains($lower, 'ipad') ? 'tablet' : (preg_match('/mobile|iphone|android/', $lower) === 1 ? 'mobile' : 'desktop');
$os = 'Unknown';
foreach (['Windows' => 'windows', 'macOS' => 'mac os', 'iOS' => 'iphone|ipad', 'Android' => 'android', 'Linux' => 'linux'] as $name => $pattern) {
if (preg_match('/' . $pattern . '/', $lower) === 1) {
$os = $name;
break;
}
}
$browser = 'Unknown';
$version = null;
$patterns = [
'Edge' => '/edg\/([\d.]+)/i',
'Chrome' => '/chrome\/([\d.]+)/i',
'Firefox' => '/firefox\/([\d.]+)/i',
'Safari' => '/version\/([\d.]+).*safari/i',
];
foreach ($patterns as $name => $pattern) {
if (preg_match($pattern, $ua, $match) === 1) {
$browser = $name;
$version = substr($match[1], 0, 60);
break;
}
}
return ['device' => $device, 'browser' => $browser, 'version' => $version, 'os' => $os];
}
function upsert_page(int $websiteId, string $path, string $title, string $now): int
{
$hash = hash('sha256', $path);
$row = db_fetch_one('SELECT id FROM pages WHERE website_id = ? AND path_hash = ?', 'is', [$websiteId, $hash]);
if ($row !== null) {
db_execute('UPDATE pages SET latest_title = ?, updated_at = ? WHERE id = ?', 'ssi', [$title, $now, (int) $row['id']]);
return (int) $row['id'];
}
db_execute(
'INSERT INTO pages (website_id, path_hash, path, latest_title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
'isssss',
[$websiteId, $hash, $path, $title, $now, $now]
);
return db_insert_id();
}
function save_page_view(int $websiteId, int $sessionId, ?int $visitorId, int $pageId, array $payload, string $now): bool
{
$eventUuid = string_value($payload, 'page_view_uuid', 36) ?: string_value($payload, 'event_uuid', 36) ?: random_token(16);
$stmt = db_execute(
'INSERT IGNORE INTO page_views (website_id, session_id, visitor_id, page_id, event_uuid, title, url, referrer_url, entered_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
'iiiisssss',
[$websiteId, $sessionId, $visitorId, $pageId, $eventUuid, string_value($payload, 'title', 300), string_value($payload, 'url', 900), string_value($payload, 'referrer', 700), $now]
);
if ($stmt->affected_rows === 0) {
log_tracking_error($websiteId, 'duplicate_event', 'Duplicate page-view event ignored.');
return false;
}
db_execute('UPDATE sessions SET page_views = page_views + 1, entry_page_id = COALESCE(entry_page_id, ?), exit_page_id = ?, last_activity_at = ? WHERE id = ?', 'iisi', [$pageId, $pageId, $now, $sessionId]);
return true;
}
function save_page_exit(int $websiteId, int $sessionId, ?int $visitorId, int $pageId, array $payload, string $now): void
{
$pageViewUuid = string_value($payload, 'page_view_uuid', 36);
$timeOnPage = max(0, min(86400, (int) ($payload['time_on_page'] ?? 0)));
$scrollDepth = max(0, min(100, (int) ($payload['scroll_depth'] ?? 0)));
if ($pageViewUuid !== '') {
db_execute(
'UPDATE page_views SET exited_at = ?, time_on_page_seconds = ?, max_scroll_depth = ? WHERE website_id = ? AND session_id = ? AND event_uuid = ?',
'siiiis',
[$now, $timeOnPage, $scrollDepth, $websiteId, $sessionId, $pageViewUuid]
);
}
save_event($websiteId, $sessionId, $visitorId, $pageId, $payload, 'page_exit', $now);
}
function save_event(int $websiteId, int $sessionId, ?int $visitorId, int $pageId, array $payload, string $type, string $now): ?int
{
$name = event_name_from_payload($payload, $type);
$eventUuid = string_value($payload, 'event_uuid', 36) ?: random_token(16);
$properties = $payload['properties'] ?? null;
$metadata = is_array($properties) ? json_encode($properties, JSON_THROW_ON_ERROR) : null;
$stmt = db_execute(
'INSERT IGNORE INTO events (website_id, session_id, visitor_id, page_id, event_uuid, event_type, category, label, numeric_value, metadata_json, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'iiiissssdss',
[$websiteId, $sessionId, $visitorId, $pageId, $eventUuid, $name, string_value($payload, 'category', 120), string_value($payload, 'label', 190), numeric_value($payload['value'] ?? null), $metadata, $now]
);
if ($stmt->affected_rows === 0) {
log_tracking_error($websiteId, 'duplicate_event', 'Duplicate event ignored.');
return null;
}
$eventId = db_insert_id();
db_execute('UPDATE sessions SET total_events = total_events + 1, last_activity_at = ? WHERE id = ?', 'si', [$now, $sessionId]);
return $eventId;
}
function event_name_from_payload(array $payload, string $type): string
{
return $type === 'custom_event' ? string_value($payload, 'event_name', 80) : $type;
}
function update_live_activity(int $websiteId, int $sessionId, ?int $visitorId, int $pageId, string $type, string $now): void
{
$previous = db_fetch_one('SELECT current_page_id FROM live_activity WHERE website_id = ? AND session_id = ?', 'ii', [$websiteId, $sessionId]);
$previousPageId = $previous !== null ? (int) $previous['current_page_id'] : null;
db_execute(
'REPLACE INTO live_activity (website_id, session_id, visitor_id, current_page_id, previous_page_id, last_event_type, last_seen_at, page_view_count) VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT page_views FROM sessions WHERE id = ?))',
'iiiiissi',
[$websiteId, $sessionId, $visitorId, $pageId, $previousPageId, $type, $now, $sessionId]
);
}
function numeric_value(mixed $value): ?float
{
return is_numeric($value) ? (float) $value : null;
}
function collect_error_message(Throwable $exception): string
{
return substr($exception::class . ': ' . $exception->getMessage(), 0, 450);
}
function collect_request_meta(array $payload, Throwable $exception): array
{
$url = string_value($payload, 'url', 900);
return [
'event_type' => string_value($payload, 'type', 40),
'url' => substr($url, 0, 300),
'path' => substr((string) (parse_url($url, PHP_URL_PATH) ?: ($payload['path'] ?? '')), 0, 200),
'origin' => substr((string) ($_SERVER['HTTP_ORIGIN'] ?? ''), 0, 200),
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 300),
'exception' => substr($exception::class, 0, 120),
'exception_line' => basename($exception->getFile()) . ':' . $exception->getLine(),
];
}
function log_tracking_error(?int $websiteId, string $type, string $message, array $meta = []): void
{
try {
$message = substr($message, 0, 500);
$metaJson = $meta === [] ? null : json_encode($meta, JSON_THROW_ON_ERROR);
db_execute(
'INSERT INTO tracking_errors (website_id, error_type, message, ip_address, request_meta_json, created_at) VALUES (?, ?, ?, ?, ?, ?)',
'isssss',
[$websiteId, $type, $message, client_ip(), $metaJson, utc_now()]
);
} catch (Throwable $exception) {
error_log('Tracking error log failed: ' . $exception->getMessage());
}
}