Files
fitme3070.pt/shared/platform-tools/functions_tracking.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

483 lines
20 KiB
PHP

<?php
// ─── Origem das visitas ─────────────────────────────────────────────────────
//
// Responde a "de onde veio esta pessoa?" em três eixos:
//
// URL /?source=montra, /?utm_source=instagram&utm_campaign=natal,
// ou o domínio de quem enviou o link
// dispositivo telemóvel/tablet/desktop, sistema, browser, idioma
// geografia país, pelo índice de shared/platform-tools/data/geo
//
// Nasceu na fotografia (config/functions_tracking.php) e veio para aqui para os
// outros sites o usarem. As mudanças em relação ao original estão assinaladas
// onde aparecem; a que mais importa é o IP.
//
// Contar visitas por origem é a metade fácil e a menos útil. O que diz se vale
// a pena reimprimir um QR code é quantos PEDIDOS produziu, por isso a origem de
// primeiro toque fica guardada e é carimbada na conversão que a pessoa venha a
// submeter — ver platform_track_conversion().
//
// Primeiro toque ganha durante toda a janela de atribuição: quem chega por um
// QR code, sai, e volta dias depois pelo Google continua a ser creditado ao QR
// code que fez o trabalho.
//
// Nunca se guarda o endereço IP. O hash existe só para aproximar visitantes
// únicos e é salgado com um segredo do servidor, por isso não se reverte.
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_geo.php';
const PLATFORM_TRACKING_COOKIE = 'am_src';
/**
* Cria as tabelas se faltarem. Chamada pelas funções de escrita, para um site
* poder adoptar isto sem migração à mão.
*/
function platform_tracking_ensure_tables(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$pdo->exec("
CREATE TABLE IF NOT EXISTS `visit_sources` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`source` VARCHAR(60) NOT NULL,
`medium` VARCHAR(60) NULL,
`campaign` VARCHAR(80) NULL,
`referrer_host` VARCHAR(120) NULL,
`landing_path` VARCHAR(190) NULL,
`ip_hash` CHAR(64) NULL,
`user_agent` VARCHAR(255) NULL,
`is_bot` TINYINT(1) NOT NULL DEFAULT 0,
`device_type` VARCHAR(12) NULL,
`os` VARCHAR(24) NULL,
`browser` VARCHAR(24) NULL,
`language` VARCHAR(5) NULL,
`country` CHAR(2) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_vs_source` (`source`),
KEY `idx_vs_bot` (`is_bot`, `created_at`),
KEY `idx_vs_country` (`country`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// Conversões numa tabela só, com o nome do formulário como chave, em vez de
// uma coluna `source` em cada tabela de destino. Um site com contactos,
// aulas experimentais e registos precisaria de três migrações e três sítios
// por onde a atribuição se pode perder; assim é uma linha por handler e o
// relatório é o mesmo em toda a quinta.
$pdo->exec("
CREATE TABLE IF NOT EXISTS `tracking_conversions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`form` VARCHAR(40) NOT NULL,
`source` VARCHAR(60) NOT NULL,
`campaign` VARCHAR(80) NULL,
`country` CHAR(2) NULL,
`ip_hash` CHAR(64) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_tc_form` (`form`, `created_at`),
KEY `idx_tc_source` (`source`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// Instalações que já tinham a tabela antes de haver país.
try {
$pdo->exec("ALTER TABLE `visit_sources` ADD COLUMN `country` CHAR(2) NULL AFTER `language`");
$pdo->exec("ALTER TABLE `visit_sources` ADD KEY `idx_vs_country` (`country`)");
} catch (PDOException) { /* já existe */ }
}
/**
* Regista este pedido como visita, quando é o primeiro da sessão.
*
* Seguro em qualquer página pública: escreve no máximo uma linha por sessão e
* nunca lança — a analítica não vale uma página em baixo.
*/
function platform_track_visit(PDO $pdo): void
{
try {
if (!empty($_SESSION['_visit_tracked'])) {
return;
}
$_SESSION['_visit_tracked'] = 1;
platform_tracking_ensure_tables($pdo);
$parsed = platform_tracking_parse_request();
$existing = platform_tracking_stored_source();
if ($parsed['explicit'] || $existing === null) {
platform_tracking_store_source($pdo, $parsed['source'], $parsed['campaign']);
}
$ua = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
$dev = platform_tracking_parse_user_agent($ua);
$lang = platform_tracking_parse_language((string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''));
$pdo->prepare("
INSERT INTO visit_sources
(source, medium, campaign, referrer_host, landing_path, ip_hash, user_agent, is_bot,
device_type, os, browser, language, country)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
")->execute([
$parsed['source'],
$parsed['medium'],
$parsed['campaign'],
$parsed['referrer_host'],
$parsed['landing_path'],
platform_tracking_ip_hash(),
$ua,
platform_tracking_is_bot($ua) ? 1 : 0,
$dev['device_type'],
$dev['os'],
$dev['browser'],
$lang,
geo_ip_country(platform_client_ip()),
]);
} catch (\Throwable $e) {
error_log('[tracking] ' . $e->getMessage());
}
}
/**
* Carimba uma conversão com a origem que a pessoa trouxe.
*
* `$form` é o nome do formulário ('contacto', 'trial', 'registo'…). Chamar
* depois de a submissão ter sido aceite, nunca antes: uma tentativa recusada
* pelo antispam não é uma conversão, e contá-la faria uma campanha parecer
* melhor por estar a atrair bots.
*/
function platform_track_conversion(PDO $pdo, string $form): void
{
try {
platform_tracking_ensure_tables($pdo);
$attr = platform_tracking_current_source();
$pdo->prepare("
INSERT INTO tracking_conversions (form, source, campaign, country, ip_hash)
VALUES (?, ?, ?, ?, ?)
")->execute([
substr($form, 0, 40),
$attr['source'],
$attr['campaign'],
geo_ip_country(platform_client_ip()),
platform_tracking_ip_hash(),
]);
} catch (\Throwable $e) {
error_log('[tracking] conversão: ' . $e->getMessage());
}
}
/**
* De onde veio este pedido.
*
* `explicit` diz se o visitante trazia um parâmetro de campanha, que é o que
* decide se deve sobrepor-se a uma origem já guardada.
*/
function platform_tracking_parse_request(): array
{
$get = fn(string $k): string => trim((string) ($_GET[$k] ?? ''));
$source = $get('source') ?: $get('utm_source') ?: $get('ref');
$medium = $get('utm_medium') ?: ($get('source') ? 'campaign' : null);
$campaign = $get('utm_campaign') ?: null;
$explicit = $source !== '';
$refHost = null;
if (!empty($_SERVER['HTTP_REFERER'])) {
$host = parse_url((string) $_SERVER['HTTP_REFERER'], PHP_URL_HOST);
if ($host) {
$refHost = strtolower(preg_replace('/^www\./', '', $host));
// Um referrer do nosso próprio site não é uma origem de aquisição.
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : '';
$self = strtolower((string) parse_url($base, PHP_URL_HOST));
if ($self !== '' && $refHost === preg_replace('/^www\./', '', $self)) {
$refHost = null;
}
// O host actual também conta como próprio: o APP_BASE_URL da quinta
// aponta para o domínio de serviço e não para o público, por isso
// sozinho deixava passar navegação interna como se fosse referral.
$here = strtolower(preg_replace('/^www\./', '', (string) ($_SERVER['HTTP_HOST'] ?? '')));
if ($here !== '' && $refHost === $here) {
$refHost = null;
}
}
}
if ($source === '') {
$source = $refHost ?: 'direct';
$medium = $medium ?: ($refHost ? 'referral' : 'direct');
}
return [
'source' => platform_tracking_normalise($source),
'medium' => $medium ? platform_tracking_normalise($medium, 60) : null,
'campaign' => $campaign ? platform_tracking_normalise($campaign, 80) : null,
'referrer_host' => $refHost ? substr($refHost, 0, 120) : null,
'landing_path' => substr((string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '/'), 0, 190),
'explicit' => $explicit,
];
}
/**
* Reduz um valor cru a uma chave estável, para "Montra", "montra " e "MONTRA"
* não darem três linhas no relatório.
*/
function platform_tracking_normalise(string $v, int $max = 60): string
{
$v = trim($v);
// Transliterar em vez de remover: tirar os acentos à bruta transformava
// "café" em "caf" e "são-joão" em "so-joo", que é exactamente o género de
// nome que uma campanha portuguesa usa.
if (function_exists('iconv')) {
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v);
if ($t !== false && $t !== '') $v = $t;
}
$v = strtolower($v);
// O TRANSLIT do iconv pode emitir formas como "c'e" ou "~a", conforme o locale.
$v = preg_replace("/['\"^~`]/", '', $v) ?? '';
$v = preg_replace('/[^a-z0-9._\- ]+/', '', $v) ?? '';
$v = preg_replace('/\s+/', '-', trim($v)) ?? '';
$v = trim(preg_replace('/-+/', '-', $v) ?? '', '-');
return substr($v, 0, $max) ?: 'direct';
}
/**
* Hash salgado e não reversível do visitante. O endereço nunca é guardado.
*
* Usa platform_client_ip() e não REMOTE_ADDR. Era essa a versão original, e
* atrás do NPM o REMOTE_ADDR é sempre o proxy: todos os visitantes ficavam com
* o mesmo hash, o que fazia o COUNT(DISTINCT ip_hash) do relatório -- a coluna
* "visitantes" -- dar sempre 1.
*/
function platform_tracking_ip_hash(): ?string
{
$ip = platform_client_ip();
if ($ip === null || $ip === '') {
return null;
}
$env = fn(string $k) => function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
$salt = $env('ANTISPAM_SECRET') ?: $env('APP_SECRET_KEY') ?: $env('BACKUP_TOKEN') ?: 'am-tracking';
return hash('sha256', $salt . '|' . $ip);
}
/**
* Categorias grosseiras de dispositivo e plataforma a partir do User-Agent.
*
* A ordem importa em todo o lado: praticamente todos os browsers mentem no
* User-Agent por compatibilidade. O Edge e o Opera dizem-se Chrome, o Chrome
* diz-se Safari, e qualquer tablet Android diz-se Android. Cada teste tem de
* excluir os impostores antes de o mais geral correr.
*
* Grosseiro de propósito: a pergunta é "desktop ou Android?", não "que versão
* de correcção?". Números de versão estreitariam o visitante sem acrescentar
* nada à resposta.
*/
function platform_tracking_parse_user_agent(string $ua): array
{
$out = ['device_type' => 'other', 'os' => 'Desconhecido', 'browser' => 'Desconhecido'];
if ($ua === '') return $out;
// O iPadOS 13+ diz-se "Macintosh", por isso o teste do iPad precisa da
// pista de toque antes de o do macOS poder ser acreditado.
if (preg_match('/iPad/i', $ua) || (preg_match('/Macintosh/i', $ua) && preg_match('/Mobile\/|Touch/i', $ua))) {
$out['os'] = 'iPadOS'; $out['device_type'] = 'tablet';
} elseif (preg_match('/iPhone|iPod/i', $ua)) {
$out['os'] = 'iOS'; $out['device_type'] = 'mobile';
} elseif (preg_match('/Android/i', $ua)) {
$out['os'] = 'Android';
// Os tablets Android omitem "Mobile"; os telemóveis incluem-no.
$out['device_type'] = preg_match('/Mobile/i', $ua) ? 'mobile' : 'tablet';
} elseif (preg_match('/Windows Phone/i', $ua)) {
$out['os'] = 'Windows Phone'; $out['device_type'] = 'mobile';
} elseif (preg_match('/Windows NT/i', $ua)) {
$out['os'] = 'Windows'; $out['device_type'] = 'desktop';
} elseif (preg_match('/CrOS/i', $ua)) {
$out['os'] = 'ChromeOS'; $out['device_type'] = 'desktop';
} elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) {
$out['os'] = 'macOS'; $out['device_type'] = 'desktop';
} elseif (preg_match('/Linux/i', $ua)) {
$out['os'] = 'Linux'; $out['device_type'] = 'desktop';
}
// Edge antes do Chrome (Edg/ contém Chrome/), Opera antes do Chrome, Chrome
// antes do Safari, e os browsers embutidos antes de todos — quem toca num
// link dentro do Instagram está a navegar na webview do Instagram, e contar
// isso como Safari esconderia quanto tráfego as redes realmente mandam.
if (preg_match('/FBAN|FBAV|FB_IAB/i', $ua)) $out['browser'] = 'Facebook (in-app)';
elseif (preg_match('/Instagram/i', $ua)) $out['browser'] = 'Instagram (in-app)';
elseif (preg_match('/\bEdgA?\//i', $ua)) $out['browser'] = 'Edge';
elseif (preg_match('/OPR\/|Opera/i', $ua)) $out['browser'] = 'Opera';
elseif (preg_match('/SamsungBrowser/i', $ua)) $out['browser'] = 'Samsung Internet';
elseif (preg_match('/Firefox\/|FxiOS/i', $ua)) $out['browser'] = 'Firefox';
elseif (preg_match('/CriOS/i', $ua)) $out['browser'] = 'Chrome';
elseif (preg_match('/Chrome\//i', $ua)) $out['browser'] = 'Chrome';
elseif (preg_match('/Safari\//i', $ua)) $out['browser'] = 'Safari';
return $out;
}
/**
* Idioma principal que o browser pede, em duas letras ('pt', 'en'…). Serve para
* distinguir visitantes locais de estrangeiros; nada mais granular é guardado.
*/
function platform_tracking_parse_language(string $header): ?string
{
if ($header === '') return null;
// "pt-PT,pt;q=0.9,en;q=0.8" → pt
if (!preg_match('/^\s*([a-zA-Z]{2})/', $header, $m)) return null;
return strtolower($m[1]);
}
/** Filtro de crawlers, rude mas eficaz — mantém o relatório sobre pessoas. */
function platform_tracking_is_bot(string $ua): bool
{
if ($ua === '') return true;
return (bool) preg_match(
'/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|whatsapp|telegram|preview|monitor|curl|wget|python-requests|headless|lighthouse|pingdom|uptime/i',
$ua
);
}
// ─── Guardar o primeiro toque ───────────────────────────────────────────────
function platform_tracking_store_source(PDO $pdo, string $source, ?string $campaign): void
{
$days = 30;
if (function_exists('platform_settings_get')) {
$days = max(1, (int) (platform_settings_get($pdo, 'tracking_attribution_days', '30') ?: 30));
}
$payload = $source . '|' . ($campaign ?? '');
$_SESSION['_visit_source'] = $payload;
// Cookie para quem volta dias depois continuar a ser creditado à campanha
// que o trouxe da primeira vez.
@setcookie(PLATFORM_TRACKING_COOKIE, $payload, [
'expires' => time() + ($days * 86400),
'path' => '/',
'secure' => (($_SERVER['HTTPS'] ?? '') !== '' || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'httponly' => true,
'samesite' => 'Lax',
]);
}
/** "source|campaign" guardado, ou null quando não há nada. */
function platform_tracking_stored_source(): ?string
{
$raw = $_SESSION['_visit_source'] ?? $_COOKIE[PLATFORM_TRACKING_COOKIE] ?? null;
return is_string($raw) && $raw !== '' ? $raw : null;
}
/**
* A origem a creditar a uma conversão, como ['source' => …, 'campaign' => …].
* Cai para o pedido actual, para um formulário submetido logo na primeira
* página continuar a ser atribuído.
*/
function platform_tracking_current_source(): array
{
$raw = platform_tracking_stored_source();
if ($raw !== null) {
[$source, $campaign] = array_pad(explode('|', $raw, 2), 2, '');
return [
'source' => platform_tracking_normalise($source ?: 'direct'),
'campaign' => $campaign !== '' ? platform_tracking_normalise($campaign, 80) : null,
];
}
$p = platform_tracking_parse_request();
return ['source' => $p['source'], 'campaign' => $p['campaign']];
}
// ─── Relatórios ─────────────────────────────────────────────────────────────
/**
* Visitas e conversões por origem, nos últimos N dias.
*
* As duas metades são contadas numa passagem por tabela e juntas em PHP, e não
* com um JOIN — uma visita e uma conversão não têm relação ao nível da linha,
* só a chave de origem em comum.
*/
function platform_tracking_report(PDO $pdo, int $days = 30): array
{
$days = max(1, $days);
$visits = $pdo->prepare("
SELECT source,
COUNT(*) AS visits,
COUNT(DISTINCT ip_hash) AS visitors,
MAX(created_at) AS last_seen
FROM visit_sources
WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY
GROUP BY source
");
$visits->execute([$days]);
$visits = $visits->fetchAll(PDO::FETCH_ASSOC);
$conv = $pdo->prepare("
SELECT source, COUNT(*) AS conversions
FROM tracking_conversions
WHERE created_at >= NOW() - INTERVAL ? DAY
GROUP BY source
");
$conv->execute([$days]);
$conv = $conv->fetchAll(PDO::FETCH_KEY_PAIR);
$rows = [];
foreach ($visits as $v) {
$src = (string) $v['source'];
$rows[$src] = [
'source' => $src,
'visits' => (int) $v['visits'],
'visitors' => (int) $v['visitors'],
'conversions' => (int) ($conv[$src] ?? 0),
'last_seen' => $v['last_seen'],
];
unset($conv[$src]);
}
// Conversões cuja origem já não tem visitas na janela — uma pessoa que
// chegou há dois meses e só agora submeteu. Continuam a contar.
foreach ($conv as $src => $n) {
$rows[$src] = ['source' => (string) $src, 'visits' => 0, 'visitors' => 0,
'conversions' => (int) $n, 'last_seen' => null];
}
uasort($rows, fn($a, $b) => [$b['conversions'], $b['visits']] <=> [$a['conversions'], $a['visits']]);
return array_values($rows);
}
/** Contagem por uma coluna qualquer de visit_sources (device_type, os, country…). */
function platform_tracking_breakdown(PDO $pdo, string $column, int $days = 30, int $limit = 12): array
{
$allowed = ['device_type', 'os', 'browser', 'language', 'country', 'referrer_host'];
if (!in_array($column, $allowed, true)) {
return []; // o nome entra numa query -- só os da lista
}
$st = $pdo->prepare("
SELECT COALESCE(NULLIF(`$column`, ''), '—') AS label,
COUNT(*) AS visits,
COUNT(DISTINCT ip_hash) AS visitors
FROM visit_sources
WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY
GROUP BY label
ORDER BY visits DESC
LIMIT " . max(1, min(100, $limit))
);
$st->execute([$days]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Conversões por formulário, nos últimos N dias. */
function platform_tracking_conversions_by_form(PDO $pdo, int $days = 30): array
{
$st = $pdo->prepare("
SELECT form, COUNT(*) AS n, MAX(created_at) AS last_seen
FROM tracking_conversions
WHERE created_at >= NOW() - INTERVAL ? DAY
GROUP BY form
ORDER BY n DESC
");
$st->execute([$days]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}