feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado

- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 07:28:13 +01:00
1 parent 5e300fc341
commit 0e8b53f620
65 files changed
+5852 -455

No files matched your search

+9 -24
View File
@@ -44,31 +44,16 @@ function env(string $key, $default = null) {
return $v === false ? $default : $v; return $v === false ? $default : $v;
} }
// IP do visitante. Em produção o Apache só vê o Nginx Proxy Manager // IP do visitante — a regra vive no platform-tools (platform_client_ip():
// (REMOTE_ADDR = 172.18.0.2 para toda a gente), e o throttle do login contava // X-Real-IP, senão o último X-Forwarded-For, e só vindo do proxy com
// tudo contra esse IP — cinco falhas de qualquer pessoa bloqueavam todos. // TRUSTED_PROXY). Em produção o Apache só vê o Nginx Proxy Manager
// // (REMOTE_ADDR = 172.18.0.2 para toda a gente); o throttle do login contava
// Só se confia no proxy quando TRUSTED_PROXY=true E o pedido vem mesmo de uma // tudo contra esse IP. A correção nasceu aqui e foi promovida ao partilhado
// rede privada. Nesse caso: // (platform-tools 12f26fd), para throttle, audit e IP ban usarem a mesma.
// - X-Real-IP primeiro: o NPM escreve-o com o $remote_addr que viu, por cima require_once __DIR__ . '/../shared/platform-tools/functions_client_ip.php';
// de qualquer valor que o cliente tenha mandado;
// - senão o ÚLTIMO valor do X-Forwarded-For, que é o que o proxy acrescentou.
// O primeiro é escrito pelo cliente (o NPM acrescenta, não substitui) e
// serve para forjar identidades — o platform_client_ip() partilhado ainda
// o usa.
function client_ip(): string { function client_ip(): string {
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); return platform_client_ip() ?? '0.0.0.0';
$trusted = filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN);
$fromProxy = $remote !== '' && filter_var($remote, FILTER_VALIDATE_IP)
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($trusted && $fromProxy) {
$real = trim((string) ($_SERVER['HTTP_X_REAL_IP'] ?? ''));
if (filter_var($real, FILTER_VALIDATE_IP)) return $real;
$xff = array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')));
$last = end($xff);
if ($last !== false && filter_var($last, FILTER_VALIDATE_IP)) return $last;
}
return $remote !== '' ? $remote : '0.0.0.0';
} }
// True when the current request is HTTPS — including when TLS terminates at a // True when the current request is HTTPS — including when TLS terminates at a
+1
View File
@@ -13,6 +13,7 @@ require_once __DIR__ . '/config.php'; // APP_ENV + hub notify URL +
require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache
require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_secret() + AES-GCM at rest require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_secret() + AES-GCM at rest
require_once __DIR__ . '/functions_db.php'; // generic DB helpers require_once __DIR__ . '/functions_db.php'; // generic DB helpers
require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (platform-tools) + audit()
require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
+25
View File
@@ -0,0 +1,25 @@
<?php
// ─── Registo de auditoria ────────────────────────────────────────────────────
// O registo é o do platform-tools (shared/platform-tools/functions_audit.php):
// tabela audit_log criada no primeiro uso, autor e IP tirados da sessão e do
// proxy, segredos ocultados em `changes`, nunca parte a ação. Consulta em
// Sistema → Auditoria (views/sysadmin/audit.php).
//
// audit() é só um atalho que vai buscar o $pdo global, para as funções de
// modelo que não o recebem. Convenção das ações: <entidade>.<verbo>
// (team.update, trash.purge, auth.login_failed, export.membership_leads…).
require_once __DIR__ . '/../shared/platform-tools/functions_audit.php';
function audit(string $action, array $d = []): void {
platform_audit($GLOBALS['pdo'] ?? null, $action, $d);
}
// Email mascarado para registos de falhas: permite ver padrões (o mesmo
// endereço atacado muitas vezes) sem guardar o endereço de quem se enganou.
function audit_mask_email(string $email): string {
$email = strtolower(trim($email));
if (!str_contains($email, '@')) return $email === '' ? '' : mb_substr($email, 0, 1) . '…';
[$u, $d] = explode('@', $email, 2);
return mb_substr($u, 0, 1) . '…@' . $d;
}
+16
View File
@@ -80,6 +80,16 @@ function setting(string $key, $default = null) {
} }
function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId = null): void { function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId = null): void {
// Valor anterior para o audit (os segredos são ocultados pelo módulo, pelo
// nome da chave: *password*, *token*, *key*, *secret*).
$old = null;
try {
$o = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
$o->execute([$key]);
$old = $o->fetchColumn();
$old = $old === false ? null : (string) $old;
} catch (Throwable $e) {}
$s = $pdo->prepare( $s = $pdo->prepare(
"INSERT INTO system_settings (setting_key, setting_value, updated_by) "INSERT INTO system_settings (setting_key, setting_value, updated_by)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -88,4 +98,10 @@ function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId =
); );
$s->execute([$key, $value, $userId]); $s->execute([$key, $value, $userId]);
settings_cache_clear(); settings_cache_clear();
if ($old !== $value && function_exists('platform_audit')) {
platform_audit($pdo, 'settings.update', [
'target_type' => 'setting', 'target_id' => $key, 'target_label' => $key,
'changes' => [$key => ['antes' => $old, 'depois' => $value]],
]);
}
} }
+32 -2
View File
@@ -88,7 +88,10 @@ function trash_delete(PDO $pdo, string $type, int $id): bool {
$stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NOW(), deleted_by = ? WHERE `{$t['pk']}` = ? AND deleted_at IS NULL"); $stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NOW(), deleted_by = ? WHERE `{$t['pk']}` = ? AND deleted_at IS NULL");
$stmt->execute([_trash_user_id(), $id]); $stmt->execute([_trash_user_id(), $id]);
$ok = $stmt->rowCount() > 0; $ok = $stmt->rowCount() > 0;
if ($ok) _trash_after($pdo, $type, $id, 'delete'); if ($ok) {
_trash_after($pdo, $type, $id, 'delete');
_trash_audit($pdo, 'trash.delete', $type, $id, 'Enviado para a lixeira');
}
return $ok; return $ok;
} }
@@ -97,7 +100,10 @@ function trash_restore(PDO $pdo, string $type, int $id): bool {
$stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NULL, deleted_by = NULL WHERE `{$t['pk']}` = ? AND deleted_at IS NOT NULL"); $stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NULL, deleted_by = NULL WHERE `{$t['pk']}` = ? AND deleted_at IS NOT NULL");
$stmt->execute([$id]); $stmt->execute([$id]);
$ok = $stmt->rowCount() > 0; $ok = $stmt->rowCount() > 0;
if ($ok) _trash_after($pdo, $type, $id, 'restore'); if ($ok) {
_trash_after($pdo, $type, $id, 'restore');
_trash_audit($pdo, 'trash.restore', $type, $id, 'Restaurado da lixeira');
}
return $ok; return $ok;
} }
@@ -128,6 +134,7 @@ function trash_purge(PDO $pdo, string $type, int $id): bool {
$stmt->execute([$id]); $stmt->execute([$id]);
$ok = $stmt->rowCount() > 0; $ok = $stmt->rowCount() > 0;
$ok ? $pdo->commit() : $pdo->rollBack(); $ok ? $pdo->commit() : $pdo->rollBack();
if ($ok) _trash_audit($pdo, 'trash.purge', $type, $id, 'Eliminado de vez', (string) $row['title']);
return $ok; return $ok;
} catch (Throwable $e) { } catch (Throwable $e) {
$pdo->rollBack(); $pdo->rollBack();
@@ -181,6 +188,29 @@ function trash_count(PDO $pdo): int {
return count(trash_list($pdo)); return count(trash_list($pdo));
} }
// Cada passo da lixeira fica no audit_log, com o nome do registo (na
// eliminação definitiva é a única coisa que resta dele).
function _trash_audit(PDO $pdo, string $action, string $type, int $id, string $summary, ?string $label = null): void {
if (!function_exists('platform_audit')) return;
if ($label === null) {
$row = trash_get($pdo, $type, $id);
if ($row === null) {
// Restaurado: já não está na lixeira; o nome vem do próprio registo.
$t = trash_type($type);
try {
$st = $pdo->prepare("SELECT {$t['title']} FROM `{$t['table']}` t WHERE t.`{$t['pk']}` = ?");
$st->execute([$id]);
$label = (string) $st->fetchColumn();
} catch (Throwable $e) { $label = ''; }
} else {
$label = (string) $row['title'];
}
}
platform_audit($pdo, $action, [
'target_type' => $type, 'target_id' => $id, 'target_label' => $label, 'summary' => $summary,
]);
}
// ─── Efeitos em ficheiros ──────────────────────────────────────────────────── // ─── Efeitos em ficheiros ────────────────────────────────────────────────────
// O site público lista as fotos da galeria com glob() na pasta, não pela BD. // O site público lista as fotos da galeria com glob() na pasta, não pela BD.
// Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o // Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o
+20 -3
View File
@@ -75,6 +75,11 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
function auth_set_password(PDO $pdo, int $userId, string $password): void { function auth_set_password(PDO $pdo, int $userId, string $password): void {
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?") $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]); ->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
$self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
audit($self ? 'auth.password_change' : 'user.password_set', [
'target_type' => 'user', 'target_id' => $userId,
'summary' => $self ? 'O próprio alterou a palavra-passe' : 'Palavra-passe definida por outra pessoa ou por link de recuperação',
]);
// Best-effort: the password itself is already saved, so a missing // Best-effort: the password itself is already saved, so a missing
// `password_resets` table (migration not applied yet) must not turn a // `password_resets` table (migration not applied yet) must not turn a
@@ -116,6 +121,8 @@ function auth_register_client(PDO $pdo, array $data): array {
$pdo->prepare( $pdo->prepare(
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?" "UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
)->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]); )->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]);
audit('user.register', ['target_type' => 'user', 'target_id' => (int) $row['id'], 'target_label' => $name,
'summary' => 'Registo que reclamou uma conta de cliente sem palavra-passe', 'actor' => null]);
return ['id' => (int) $row['id']]; return ['id' => (int) $row['id']];
} }
@@ -136,7 +143,10 @@ function auth_register_client(PDO $pdo, array $data): array {
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken']; if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
throw $e; throw $e;
} }
return ['id' => (int) $pdo->lastInsertId()]; $newId = (int) $pdo->lastInsertId();
audit('user.register', ['target_type' => 'user', 'target_id' => $newId, 'target_label' => $name,
'summary' => 'Novo cliente registado no site', 'actor' => null]);
return ['id' => $newId];
} }
// ─── Backoffice user creation ──────────────────────────────────────────────── // ─── Backoffice user creation ────────────────────────────────────────────────
@@ -153,7 +163,10 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
strtolower(trim($email)), strtolower(trim($email)),
password_hash($password, PASSWORD_DEFAULT), password_hash($password, PASSWORD_DEFAULT),
]); ]);
return (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
'summary' => 'Sysadmin criado pelo setup inicial', 'actor' => null]);
return $id;
} }
// Returns ['id' => int] on success, or ['error' => 'email_taken']. // Returns ['id' => int] on success, or ['error' => 'email_taken'].
@@ -184,7 +197,10 @@ function _create_backoffice_user(PDO $pdo, string $role, string $name, string $e
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken']; if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
throw $e; throw $e;
} }
return ['id' => (int) $pdo->lastInsertId()]; $id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
'changes' => ['role' => ['antes' => null, 'depois' => $role]]]);
return ['id' => $id];
} }
// ─── Password reset ────────────────────────────────────────────────────────── // ─── Password reset ──────────────────────────────────────────────────────────
@@ -257,6 +273,7 @@ function send_password_reset_email(string $name, string $email, string $resetUrl
// same mechanism, different copy. // same mechanism, different copy.
function send_password_invite_email(PDO $pdo, array $user): bool { function send_password_invite_email(PDO $pdo, array $user): bool {
if (empty($user['email'])) return false; if (empty($user['email'])) return false;
audit('user.invite', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) ($user['name'] ?? '')]);
$token = auth_create_reset_token($pdo, (int) $user['id']); $token = auth_create_reset_token($pdo, (int) $user['id']);
$url = base_url('views/auth/reset_password.php?token=' . $token); $url = base_url('views/auth/reset_password.php?token=' . $token);
$name = (string) ($user['name'] ?? ''); $name = (string) ($user['name'] ?? '');
+4
View File
@@ -178,6 +178,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/messages.php' ? ' active' : '' ?>" <a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/messages.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/messages.php') ?>"><i class="bi bi-envelope-paper"></i>Mensagens</a> href="<?= base_url('views/sysadmin/messages.php') ?>"><i class="bi bi-envelope-paper"></i>Mensagens</a>
</li> </li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/audit.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/audit.php') ?>"><i class="bi bi-journal-text"></i>Auditoria</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/comms.php' ? ' active' : '' ?>" <a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/comms.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/comms.php') ?>"><i class="bi bi-send"></i>Comunicações</a> href="<?= base_url('views/sysadmin/comms.php') ?>"><i class="bi bi-send"></i>Comunicações</a>
+21 -2
View File
@@ -72,6 +72,22 @@ The corollary: **a site must not reimplement something this submodule provides.*
If the shared version is wrong for one site, fix it here with a parameter, or If the shared version is wrong for one site, fix it here with a parameter, or
say plainly in the site's own docs why it deviates. say plainly in the site's own docs why it deviates.
**The site declares; the shared code does not guess.** Each site's
`config/db.php` makes one `platform_connect()` call that states its PDO, base
URL, environment, column map, roles, session and sysadmin guard. Before it,
every tool probed for variants — a global `$pdo` or `App\Database`, a
`base_url()` or a `getBaseUrl()` — and each new tool had to remember them all.
The same call runs `platform_conformance()`: the database is compared with what
the shared code expects, and a missing column that would switch a defence off
without an error fails loudly in development and turns the hub card yellow in
production (§7).
**New tools reach every site without a file on the site.** The shared pages are
served by one front controller (`public/platform.php?tool=…`) and the shared
endpoints by another (`public/api/platform.php?endpoint=…`), both from closed
route tables here. A per-page stub on each of seven sites was seven chances to
forget one.
Submodules have one sharp edge worth stating: `git pull` in a site advances the Submodules have one sharp edge worth stating: `git pull` in a site advances the
submodule *pointer* but leaves its working tree on the old commit, silently and submodule *pointer* but leaves its working tree on the old commit, silently and
with no error. Every deploy script therefore runs `git submodule update with no error. Every deploy script therefore runs `git submodule update
@@ -127,7 +143,8 @@ Two things stay per-site, deliberately:
- **The users table.** The columns genuinely differ — fotografia's users carry - **The users table.** The columns genuinely differ — fotografia's users carry
CalDAV credentials, silver's carry a guardian. The shared code adapts through CalDAV credentials, silver's carry a guardian. The shared code adapts through
`auth_schema()` rather than demanding one shape, because renaming a column the `schema` given to `platform_connect()` (`auth_schema()` underneath),
declaring only the columns that differ, rather than demanding one shape, because renaming a column
with 147 references is a migration and adapting is a function call. with 147 references is a migration and adapting is a function call.
The same map covers *what you sign in with*. Six sites match on `email`; The same map covers *what you sign in with*. Six sites match on `email`;
@@ -189,7 +206,9 @@ sized to its workload rather than copied between sites.
Application code sits outside the document root: the web root is `public/`, and Application code sits outside the document root: the web root is `public/`, and
`shared/`, `config/` and `src/` are not reachable over HTTP. Anything that `shared/`, `config/` and `src/` are not reachable over HTTP. Anything that
genuinely must be callable is a thin, explicit stub in `public/api/`. genuinely must be callable goes through the two front controllers or a thin,
explicit stub in `public/api/` — and the front controllers only dispatch names
from a closed list; they never include a path taken from the request.
## 9. Migrations are append-only ## 9. Migrations are append-only
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -4,9 +4,13 @@ This directory used to be a git submodule pointing at
`git.andmore.pt/andre/platform-tools`. On the `main` branch it is a plain `git.andmore.pt/andre/platform-tools`. On the `main` branch it is a plain
directory of tracked files. directory of tracked files.
**Frozen at:** `ea3616abae30106df283e73a3e2f9e5bb0e6ae90` **Frozen at:** `12f26fd1cba8ce3229c598ac2f2e9a788cd7cf75` (`fix(client_ip): X-Real-IP / último X-Forwarded-For, nunca o primeiro`)
(`feat: o feedback passa a servir os três stacks da frota` — the same commit — refreshed on 2026-09-25 from `ea3616a`, 27 commits later. It is the fleet
`fotografia.andmore.pt` pins.) pin (`f761d99`, audit module) plus `a8c681b` (login form pieces, remember
options) and the client-IP fix found in this app's audit.
History: `ea3616a` (2026-09-13, same pin as fotografia at the time) →
`12f26fd` (2026-09-25).
## Why ## Why
+2 -3
View File
@@ -6,9 +6,8 @@
// dirname(__DIR__, 3) === <host> // dirname(__DIR__, 3) === <host>
// The per-platform stub at <host>/api/agent.php is a one-line require of this file. // The per-platform stub at <host>/api/agent.php is a one-line require of this file.
// //
// Hosts on a different stack (surf: src/ + App\Database) write their own thin // The action set lives in functions_agent.php, so a host that needs its own
// stub that bootstraps their way and calls agent_dispatch() directly — the // stub (surf sets MIGRATIONS_DIR first) still shares it.
// action set lives in functions_agent.php so both paths share it.
$repo_root = dirname(__DIR__, 3); $repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
+2 -3
View File
@@ -8,9 +8,8 @@
// Ao contrário dos outros endpoints desta pasta, este NÃO se autentica por // Ao contrário dos outros endpoints desta pasta, este NÃO se autentica por
// chave: quem chama é o browser de um administrador com sessão aberta neste // chave: quem chama é o browser de um administrador com sessão aberta neste
// site. Precisa por isso da sessão e dos helpers de autenticação do site — e // site. Precisa por isso da sessão e dos helpers de autenticação do site — e
// obtém-nos do mesmo hub_bootstrap.php que os outros, que é quem sabe distinguir // obtém-nos do mesmo hub_bootstrap.php que os outros, que abre o config/db.php
// os dois stacks da frota (config/db.php ou src/config.php + App\Database) e // do site e deixa em cima da mesa a sessão aberta, o $pdo e o functions_hub.
// deixa em cima da mesa a sessão aberta, o $pdo e o functions_hub.
// //
// Assim o stub de cada site é uma linha, igual à dos restantes endpoints. // Assim o stub de cada site é uma linha, igual à dos restantes endpoints.
// //
+21
View File
@@ -126,6 +126,22 @@ if ($git) $checks['git'] = $git;
$backup = $hub_has_db_layer ? $health_check(fn() => hub_backup_check()) : []; $backup = $hub_has_db_layer ? $health_check(fn() => hub_backup_check()) : [];
if ($backup) $checks['backup'] = $backup; if ($backup) $checks['backup'] = $backup;
// Envio de eventos de segurança ao hub. [] numa plataforma por emparelhar ou
// sem base de dados — não reportado, como os outros.
$security = !($pdo instanceof PDO) ? [] : $health_check(function () use ($pdo) {
require_once __DIR__ . '/../functions_security_events.php';
return hub_security_check($pdo);
});
if ($security) $checks['security'] = $security;
// A base de dados tem a forma que o código partilhado espera? Reporta as avarias
// silenciosas (uma coluna em falta que desliga o throttle sem erro nenhum) —
// ver functions_connector.php. Só leitura, uma consulta ao information_schema.
if ($pdo instanceof PDO) $checks['conformance'] = $health_check(function () use ($pdo) {
require_once __DIR__ . '/../functions_connector.php';
return platform_conformance_check($pdo);
});
hub_json([ hub_json([
// Só a base de dados: ver a nota no topo sobre o que significa `ok`. Numa // Só a base de dados: ver a nota no topo sobre o que significa `ok`. Numa
// plataforma estática não há base de dados que decida nada -- o que torna a // plataforma estática não há base de dados que decida nada -- o que torna a
@@ -156,4 +172,9 @@ hub_json([
'enabled' => true, 'enabled' => true,
'auth' => 'X-Hub-Key: <chave de operações>', 'auth' => 'X-Hub-Key: <chave de operações>',
], ],
// As páginas de sistema deste site, para o hub ligar para a página local
// (o recurso quando o hub não chega). Só com router: ver functions_routes.php.
'tools' => function_exists('platform_routes_announced') ? platform_routes_announced() : [],
// E os endpoints pelo router da API — o caminho para endpoints novos.
'api' => function_exists('platform_api_routes_announced') ? platform_api_routes_announced() : [],
]); ]);
+89 -4
View File
@@ -34,10 +34,20 @@ $root = $repo_root;
// vez de deixar as ações chamarem métodos sobre um $pdo a null — um erro de PHP // vez de deixar as ações chamarem métodos sobre um $pdo a null — um erro de PHP
// apareceria no hub como plataforma avariada, que é uma coisa diferente. // apareceria no hub como plataforma avariada, que é uma coisa diferente.
if (!$pdo instanceof PDO if (!$pdo instanceof PDO
&& in_array($action, ['migrations.list', 'migrations.apply_pending'], true)) { && in_array($action, ['migrations.list', 'migrations.apply_pending', 'update.run', 'update.status',
'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'], true)) {
hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400); hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400);
} }
// Auditoria: o que o hub mandou fazer aqui fica escrito, com o hub como autor.
// Só as ações que mudam alguma coisa; as leituras (list, status) não.
if ($pdo instanceof PDO && function_exists('platform_audit')
&& in_array($action, ['backup.trigger', 'update.run', 'git.pull', 'migrations.apply_pending', 'ip_bans.ban', 'ip_bans.unban'], true)) {
$params = is_array($req) ? $req : $_POST; unset($params['action']);
platform_audit($pdo, 'hub.' . $action, ['actor' => ['id' => null, 'name' => 'hub', 'role' => 'hub'],
'target_type' => 'platform', 'changes' => $params ?: null]);
}
switch ($action) { switch ($action) {
// ── Cópias de segurança ────────────────────────────────────────────────── // ── Cópias de segurança ──────────────────────────────────────────────────
@@ -91,9 +101,10 @@ switch ($action) {
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)'; $output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($pull['ok']) { if ($pull['ok']) {
// O ff-only recusa-se a fundir; um repositório divergente falha aqui // O ff-only recusa-se a fundir; um repositório divergente falha aqui
// em vez de inventar um merge que ninguém pediu. // em vez de inventar um merge que ninguém pediu. Os submódulos vão
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $root); // pelo git_sync_submodules(), que respeita os que seguem o branch.
if (trim($sub['output']) !== '') $output .= "\n\n[submodules]\n" . $sub['output']; $sub = git_sync_submodules($root);
$output .= "\n\n[submodules]\n" . implode("\n", $sub['log']);
if (function_exists('opcache_reset')) @opcache_reset(); if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear(); if (function_exists('settings_cache_clear')) settings_cache_clear();
if (function_exists('platform_settings_cache_clear')) platform_settings_cache_clear(); if (function_exists('platform_settings_cache_clear')) platform_settings_cache_clear();
@@ -101,6 +112,80 @@ switch ($action) {
} }
hub_json(['ok' => $pull['ok'], 'output' => $output]); hub_json(['ok' => $pull['ok'], 'output' => $output]);
// ── Atualização completa ─────────────────────────────────────────────────
// O mesmo caminho do webhook de deploy (auto_update_run): cópia antes de
// atualizar se estiver ligada, pull que pára em trabalho local, migrações
// pendentes e caches. É o que o botão do hub deve fazer — o git.pull acima
// fica para quem quer só o código.
//
// Responde já e trabalha depois de fechar a ligação: a cópia prévia pode
// demorar minutos e o hub só espera MONITOR_OPS_TIMEOUT_SECONDS. O
// resultado lê-se a seguir com update.status.
case 'update.run':
require_once __DIR__ . '/../functions_auto_update.php';
hub_load_backup_helpers();
if (!function_exists('git_run') || !is_dir($root . '/.git')) {
hub_json(['ok' => false, 'error' => 'esta instalação não é um repositório git (típico em cPanel) — as cópias continuam a funcionar'], 503);
}
@session_write_close();
platform_respond_and_continue(202, [
'ok' => true,
'status' => 'accepted',
'note' => 'Atualização iniciada. O resultado fica em update.status daqui a instantes.',
]);
auto_update_run($pdo, $root, null);
exit;
case 'update.status':
require_once __DIR__ . '/../functions_auto_update.php';
hub_json(['ok' => true, 'last' => auto_update_last($pdo)]);
// ── Bloqueio de IP ───────────────────────────────────────────────────────
// A mesma lista e as mesmas acções da página ip_bans.php deste site, para o
// hub mostrar numa aba. Um banimento feito daqui fica com source='hub' e,
// como qualquer outro, segue para a frota.
case 'ip_bans.list':
require_once __DIR__ . '/../functions_ip_ban.php';
hub_json(['ok' => true, 'bans' => platform_ip_ban_list($pdo, 200),
'offenders' => platform_ip_offenders($pdo, 7, 30)]);
case 'ip_bans.ban':
case 'ip_bans.unban':
require_once __DIR__ . '/../functions_ip_ban.php';
$ip = trim((string) (is_array($req) ? ($req['ip'] ?? '') : ''));
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
hub_json(['ok' => false, 'error' => 'endereço IP inválido'], 400);
}
if ($action === 'ip_bans.unban') {
hub_json(['ok' => platform_ip_unban($pdo, $ip), 'ip' => $ip]);
}
if (platform_ip_ban_exempt($ip)) {
hub_json(['ok' => false, 'error' => 'endereços internos não podem ser banidos'], 400);
}
// `minutes: null` é permanente; ausente é um dia.
$minutes = array_key_exists('minutes', $req) && $req['minutes'] === null
? null : max(1, (int) ($req['minutes'] ?? 1440));
$reason = trim((string) ($req['reason'] ?? '')) ?: 'banido a partir do hub';
$ok = platform_ip_ban($pdo, $ip, $reason, $minutes, 'hub', null);
hub_json(['ok' => $ok, 'ip' => $ip, 'minutes' => $minutes], $ok ? 200 : 500);
// ── Origem das visitas ───────────────────────────────────────────────────
// Só agregados — origens, formulários, países, dispositivos. Nenhuma visita
// individual sai daqui, nem o hash do IP.
case 'tracking.summary':
require_once __DIR__ . '/../functions_tracking.php';
$days = (int) (is_array($req) ? ($req['days'] ?? 30) : 30);
if (!in_array($days, [7, 30, 90, 365], true)) $days = 30;
platform_tracking_ensure_tables($pdo);
hub_json([
'ok' => true,
'days' => $days,
'sources' => array_slice(platform_tracking_report($pdo, $days), 0, 25),
'forms' => platform_tracking_conversions_by_form($pdo, $days),
'countries' => platform_tracking_breakdown($pdo, 'country', $days, 10),
'devices' => platform_tracking_breakdown($pdo, 'device_type', $days, 6),
]);
// ── Migrações ──────────────────────────────────────────────────────────── // ── Migrações ────────────────────────────────────────────────────────────
// Um pull sem as migrações aplicadas deixa a app a correr contra um esquema // Um pull sem as migrações aplicadas deixa a app a correr contra um esquema
// antigo, portanto o hub tem de conseguir fazer as duas coisas. // antigo, portanto o hub tem de conseguir fazer as duas coisas.
+36
View File
@@ -0,0 +1,36 @@
<?php
// ─── Front controller dos endpoints partilhados ─────────────────────────────
//
// Um só stub por site para toda a API partilhada:
//
// <?php require __DIR__ . '/../../shared/platform-tools/api/router.php'; // public/api/platform.php
//
// e cada endpoint responde em api/platform.php?endpoint=<nome> (ou
// api/platform?endpoint=… com 'clean_urls'). A lista é fechada
// (platform_api_routes()), e cada endpoint continua a autenticar-se a si
// próprio: este ficheiro só carrega o arranque do site e despacha.
//
// Um site com implementação própria de um endpoint declara-a no
// platform_connect(): 'api_files' => ['backup_trigger' => <caminho absoluto>]
// (a silver tem guardas com outros nomes). O 'migrations_dir' do site chega ao
// agente, como chega às páginas.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php'; // $repo_root, $pdo, o arranque do site
require_once __DIR__ . '/../functions_routes.php';
$endpoint = (string) ($_GET['endpoint'] ?? '');
$file = platform_api_routes()[$endpoint] ?? null;
if ($file === null) {
hub_json(['ok' => false, 'error' => 'endpoint desconhecido'], 404);
}
$cfg = function_exists('platform_config') ? platform_config() : [];
if (!empty($cfg['migrations_dir'])) {
$GLOBALS['MIGRATIONS_DIR'] = (string) $cfg['migrations_dir'];
}
$target = (string) ($cfg['api_files'][$endpoint] ?? (dirname(__DIR__) . '/' . $file));
unset($cfg, $file);
require $target;
+154
View File
@@ -0,0 +1,154 @@
<?php
// ─── Registo de auditoria (página partilhada) ───────────────────────────────
//
// /platform?tool=audit. Lê o `audit_log` do site pela forma canónica
// (platform_audit_query(), functions_audit.php), seja qual for a forma da
// tabela, e mostra quem fez o quê, a quê e quando, com o antes e o depois de
// cada alteração. Só leitura. Exporta o filtro em CSV.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('audit.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_audit.php';
$filters = [
'actor' => trim((string) ($_GET['actor'] ?? '')),
'action' => trim((string) ($_GET['action'] ?? '')),
'target_type' => trim((string) ($_GET['target_type'] ?? '')),
'target_id' => trim((string) ($_GET['target_id'] ?? '')),
'q' => trim((string) ($_GET['q'] ?? '')),
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['from'] ?? '')) ? (string) $_GET['from'] : '',
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['to'] ?? '')) ? (string) $_GET['to'] : '',
];
$perPage = 50;
$page = max(1, (int) ($_GET['p'] ?? 1));
// Exportação: o filtro inteiro (até 5000 linhas), em CSV que o Excel abre em pt.
if (($_GET['export'] ?? '') === 'csv') {
$rows = platform_audit_query($pdo, $filters, 5000);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="auditoria-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Quando', 'Quem', 'Papel', 'Ação', 'Tipo', 'Id', 'Alvo', 'Resumo', 'Alterações', 'IP'], ';');
foreach ($rows as $r) {
fputcsv($out, [$r['created_at'], $r['actor_name'], $r['actor_role'], $r['action'], $r['target_type'],
$r['target_id'], $r['target_label'], $r['summary'], $r['changes'], $r['ip']], ';');
}
fclose($out);
exit;
}
$rows = platform_audit_query($pdo, $filters, $perPage + 1, ($page - 1) * $perPage);
$hasNext = count($rows) > $perPage;
$rows = array_slice($rows, 0, $perPage);
$actions = platform_audit_distinct($pdo, 'action');
$types = platform_audit_distinct($pdo, 'target_type');
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$link = function (array $q) use ($selfUrl, $filters): string {
$base = isset($_GET['tool']) ? ['tool' => (string) $_GET['tool']] : [];
return $selfUrl . '?' . http_build_query($base + array_filter($q + $filters, fn($v) => $v !== '' && $v !== null));
};
/** Uma alteração em HTML: tabela campo / antes / depois, ou o texto tal como veio. */
$renderChanges = function (?string $json): string {
if ($json === null || $json === '') return '';
$data = json_decode($json, true);
if (!is_array($data)) return '<pre class="small mb-0">' . htmlspecialchars($json) . '</pre>';
$fmt = fn($v) => $v === null ? '<span class="text-muted">—</span>'
: htmlspecialchars(is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
$isDiff = $data && array_reduce($data, fn($ok, $v) => $ok && is_array($v) && (array_key_exists('from', $v) || array_key_exists('to', $v)), true);
if (!$isDiff) return '<pre class="small mb-0" style="white-space:pre-wrap">' . htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) . '</pre>';
$h = '<table class="table table-sm mb-0 small"><thead><tr><th>Campo</th><th>Antes</th><th>Depois</th></tr></thead><tbody>';
foreach ($data as $k => $v) {
$h .= '<tr><td>' . htmlspecialchars((string) $k) . '</td><td class="text-danger">' . $fmt($v['from'] ?? null) . '</td><td class="text-success">' . $fmt($v['to'] ?? null) . '</td></tr>';
}
return $h . '</tbody></table>';
};
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$dashboard_url = $platform_dashboard_url ?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$pageTitle = 'Registo de auditoria';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1200px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-journal-text me-2"></i>Registo de auditoria</h2>
<p class="text-muted small mb-0">Quem fez o quê, a quê e quando. Os segredos nunca entram no registo.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['export' => 'csv'])) ?>"><i class="bi bi-download me-1"></i>Exportar CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($dashboard_url) ?>">Voltar</a>
</div>
</div>
<form method="get" class="card card-body mb-3">
<?php if (isset($_GET['tool'])): ?><input type="hidden" name="tool" value="<?= htmlspecialchars((string) $_GET['tool']) ?>"><?php endif; ?>
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small mb-1">Quem</label>
<input class="form-control form-control-sm" name="actor" value="<?= htmlspecialchars($filters['actor']) ?>" placeholder="nome"></div>
<div class="col-md-2"><label class="form-label small mb-1">Ação</label>
<input class="form-control form-control-sm" name="action" list="audit-actions" value="<?= htmlspecialchars($filters['action']) ?>" placeholder="ex.: user.">
<datalist id="audit-actions"><?php foreach ($actions as $a): ?><option value="<?= htmlspecialchars((string) $a) ?>"><?php endforeach; ?></datalist></div>
<div class="col-md-2"><label class="form-label small mb-1">Tipo</label>
<select class="form-select form-select-sm" name="target_type"><option value="">Todos</option>
<?php foreach ($types as $t): ?><option value="<?= htmlspecialchars((string) $t) ?>" <?= $filters['target_type'] === (string) $t ? 'selected' : '' ?>><?= htmlspecialchars((string) $t) ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-2"><label class="form-label small mb-1">Texto</label>
<input class="form-control form-control-sm" name="q" value="<?= htmlspecialchars($filters['q']) ?>" placeholder="alvo, resumo…"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">De</label>
<input type="date" class="form-control form-control-sm" name="from" value="<?= htmlspecialchars($filters['from']) ?>"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">Até</label>
<input type="date" class="form-control form-control-sm" name="to" value="<?= htmlspecialchars($filters['to']) ?>"></div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-sm btn-primary flex-grow-1">Filtrar</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['actor' => '', 'action' => '', 'target_type' => '', 'target_id' => '', 'q' => '', 'from' => '', 'to' => ''])) ?>">Limpar</a>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead><tr><th>Quando</th><th>Quem</th><th>Ação</th><th>Alvo</th><th>Resumo</th><th>IP</th></tr></thead>
<tbody>
<?php if (!$rows): ?><tr><td colspan="6" class="text-muted p-4 text-center">Nada no registo com este filtro.</td></tr><?php endif; ?>
<?php foreach ($rows as $r): $ch = $renderChanges($r['changes']); ?>
<tr>
<td class="small text-nowrap"><?= htmlspecialchars(date('d/m/Y H:i:s', strtotime((string) $r['created_at']))) ?></td>
<td class="small"><?= $r['actor_name'] !== null && $r['actor_name'] !== '' ? htmlspecialchars((string) $r['actor_name']) : '<span class="text-muted">sistema</span>' ?>
<?php if (!empty($r['actor_role'])): ?><div class="text-muted"><?= htmlspecialchars((string) $r['actor_role']) ?></div><?php endif; ?></td>
<td class="small"><a href="<?= htmlspecialchars($link(['action' => (string) $r['action'], 'p' => null])) ?>"><code><?= htmlspecialchars((string) $r['action']) ?></code></a></td>
<td class="small">
<?php if (!empty($r['target_type'])): ?><a class="text-muted" href="<?= htmlspecialchars($link(['target_type' => (string) $r['target_type'], 'target_id' => (string) ($r['target_id'] ?? ''), 'p' => null])) ?>"><?= htmlspecialchars((string) $r['target_type']) ?><?= $r['target_id'] !== null && $r['target_id'] !== '' ? ' #' . htmlspecialchars((string) $r['target_id']) : '' ?></a><?php endif; ?>
<?php if (!empty($r['target_label'])): ?><div><?= htmlspecialchars((string) $r['target_label']) ?></div><?php endif; ?>
</td>
<td class="small">
<?= htmlspecialchars((string) ($r['summary'] ?? '')) ?>
<?php if ($ch !== ''): ?>
<details class="mt-1"><summary class="text-muted" style="cursor:pointer">alterações</summary><?= $ch ?></details>
<?php endif; ?>
</td>
<td class="small text-muted"><?= htmlspecialchars((string) ($r['ip'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($page > 1 || $hasNext): ?>
<div class="card-footer d-flex justify-content-between">
<?php if ($page > 1): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page - 1])) ?>">&larr; Mais recentes</a><?php else: ?><span></span><?php endif; ?>
<span class="small text-muted align-self-center">página <?= $page ?></span>
<?php if ($hasNext): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page + 1])) ?>">Mais antigas &rarr;</a><?php else: ?><span></span><?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
+4 -2
View File
@@ -37,6 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|| !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) { || !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => 'Ficheiro inválido.']; $_SESSION['backup_flash'] = ['type' => 'danger', 'message' => 'Ficheiro inválido.'];
} elseif (@unlink($real)) { } elseif (@unlink($real)) {
if (function_exists('platform_audit')) platform_audit($pdo ?? null, 'backup.delete', ['target_type' => 'backup', 'target_label' => $fn]);
$_SESSION['backup_flash'] = ['type' => 'success', 'message' => "Backup {$fn} apagado."]; $_SESSION['backup_flash'] = ['type' => 'success', 'message' => "Backup {$fn} apagado."];
} else { } else {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => "Não foi possível apagar {$fn} (permissões?)."]; $_SESSION['backup_flash'] = ['type' => 'danger', 'message' => "Não foi possível apagar {$fn} (permissões?)."];
@@ -80,7 +81,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'resto
} elseif ($info['type'] === 'db' && !isset($pdo)) { } elseif ($info['type'] === 'db' && !isset($pdo)) {
$_SESSION['backup_flash'] = [ $_SESSION['backup_flash'] = [
'type' => 'danger', 'type' => 'danger',
'message' => 'Restauro da base de dados indisponível: $pdo não foi carregado pelo stub.', 'message' => 'Restauro da base de dados indisponível: o arranque do site (config/db.php) não abriu a ligação à base de dados.',
]; ];
} else { } else {
$r = ($info['type'] === 'db') $r = ($info['type'] === 'db')
@@ -295,7 +296,8 @@ if (is_file($layout_navbar)) include $layout_navbar;
<div class="card-footer text-muted small"> <div class="card-footer text-muted small">
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
Os backups com mais de <?= (int) BACKUP_KEEP_DAYS ?> dias são eliminados automaticamente. Retenção automática, por tipo: <?= htmlspecialchars(backup_retention_summary()) ?>.
O resto é apagado no fim de cada backup.
Os ficheiros estão armazenados na pasta <code>backups/</code> no servidor. Os ficheiros estão armazenados na pasta <code>backups/</code> no servidor.
</div> </div>
</div> </div>
@@ -182,4 +182,4 @@ echo "Ficheiro : $outputPath\n";
echo "Tamanho : $size\n"; echo "Tamanho : $size\n";
echo "Ficheiros: $added adicionados, $skipped ignorados" . ($errors ? ", $errors erros" : '') . "\n"; echo "Ficheiros: $added adicionados, $skipped ignorados" . ($errors ? ", $errors erros" : '') . "\n";
cleanOldBackups($backupDir, 'backup_*.zip'); backup_prune_report();
+14 -27
View File
@@ -13,9 +13,9 @@ $host_root = dirname(__DIR__, 3);
// Three host patterns coexist: // Three host patterns coexist:
// - fotografia-style : config/env.php exposes env_load(), needs an explicit call // - fotografia-style : config/env.php exposes env_load(), needs an explicit call
// - dsa/moremio-style: config/env.php auto-loads .env via an IIFE; require is enough // - dsa/moremio-style: config/env.php auto-loads .env via an IIFE; require is enough
// - surf-style : no config/ directory at all -- src/config.php plus // - surf-style : no config/env.php -- config/db.php loads .env with
// vlucas/phpdotenv, which has already populated $_ENV and // vlucas/phpdotenv, and the workers run without it, so
// getenv() by the time a web request reaches here. // they read .env themselves (the fallback below).
// Requiring config/env.php unconditionally fataled on the third layout. // Requiring config/env.php unconditionally fataled on the third layout.
if (is_file($host_root . '/config/env.php')) { if (is_file($host_root . '/config/env.php')) {
require_once $host_root . '/config/env.php'; require_once $host_root . '/config/env.php';
@@ -51,7 +51,10 @@ if (!function_exists('env')) {
} }
define('BACKUP_TOKEN', env('BACKUP_TOKEN', 'replace_me')); define('BACKUP_TOKEN', env('BACKUP_TOKEN', 'replace_me'));
define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7)); // Retenção avô-pai-filho — ver backup_gfs_keep() em functions_backup.php.
define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7));
define('BACKUP_KEEP_WEEKS', (int) env('BACKUP_KEEP_WEEKS', 4));
define('BACKUP_KEEP_MONTHS', (int) env('BACKUP_KEEP_MONTHS', 1));
// Pasta (relativa à raiz do host) que o backup de ficheiros arquiva. Só os // Pasta (relativa à raiz do host) que o backup de ficheiros arquiva. Só os
// uploads têm informação externa — todo o resto é reconstituível a partir do // uploads têm informação externa — todo o resto é reconstituível a partir do
@@ -68,28 +71,12 @@ define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', '')); define('DB_PASS', env('DB_PASS', ''));
define('DB_CHARSET', 'utf8mb4'); define('DB_CHARSET', 'utf8mb4');
/** /** Poda avô-pai-filho no fim de um worker (ver backup_prune()), com o relatório no output. */
* Apaga backups mais antigos que BACKUP_KEEP_DAYS na pasta indicada. function backup_prune_report(): void
* $pattern — glob pattern para identificar os ficheiros (ex: 'backup_*.zip')
*/
function cleanOldBackups(string $dir, string $pattern): void
{ {
$cutoff = time() - (BACKUP_KEEP_DAYS * 86400); $r = backup_prune();
$files = glob($dir . '/' . $pattern) ?: []; foreach ($r['deleted'] as $f) echo "Apagado (retenção): $f\n";
$deleted = 0; foreach ($r['errors'] as $f) echo "ERRO a apagar: $f\n";
echo 'Rotação: ' . count($r['deleted']) . ' apagado(s), ' . count($r['kept']) . ' mantido(s) — '
foreach ($files as $file) { . backup_retention_summary($r['policy']) . ".\n";
if (filemtime($file) < $cutoff) {
if (@unlink($file)) {
$deleted++;
echo "Apagado (antigo): " . basename($file) . "\n";
}
}
}
if ($deleted === 0) {
echo "Rotação: nenhum backup antigo para apagar.\n";
} else {
echo "Rotação: $deleted ficheiro(s) apagado(s).\n";
}
} }
+3 -3
View File
@@ -182,6 +182,6 @@ echo "Backup da BD concluído!\n";
echo "Ficheiro : $zipPath\n"; echo "Ficheiro : $zipPath\n";
echo "Tamanho : $size\n"; echo "Tamanho : $size\n";
cleanOldBackups($backupDir, 'db_backup_*.zip'); // Só se chega aqui com a cópia nova no disco: a poda nunca corre depois de uma
// Varre também .sql órfãos de execuções antigas interrompidas a meio. // cópia falhada, que é quando as antigas são as únicas que há.
cleanOldBackups($backupDir, 'db_backup_*.sql'); backup_prune_report();
@@ -0,0 +1,30 @@
<?php
// Aplica a retenção avô-pai-filho à pasta backups/ sem fazer uma cópia nova.
//
// php shared/platform-tools/bin/backup-prune.php [--dry-run]
// 30 4 * * * php /var/www/html/shared/platform-tools/bin/backup-prune.php
//
// Os workers já podam no fim de cada cópia. Isto serve para um site cujas cópias
// pararam (a poda nunca corre depois de uma cópia falhada), para ver o que uma
// mudança de BACKUP_KEEP_* apagaria antes de a fazer (--dry-run), e para podar
// depois de a mudar.
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once dirname(__DIR__) . '/backups/config.php';
require_once dirname(__DIR__) . '/functions_backup.php';
$dryRun = in_array('--dry-run', $argv, true);
$r = backup_prune($dryRun);
echo 'Política: ' . backup_retention_summary($r['policy']) . "\n";
foreach ($r['kept'] as $k) printf(" mantém %s (%s)\n", $k['filename'], $k['reason']);
foreach ($r['deleted'] as $f) printf(" %s %s\n", $dryRun ? 'apagaria' : 'apagou ', $f);
foreach ($r['errors'] as $f) printf(" ERRO %s\n", $f);
exit($r['errors'] ? 1 : 0);
+5 -17
View File
@@ -8,9 +8,7 @@
// is safe to call from deploy.sh on every deploy. Exits non-zero if a row that // is safe to call from deploy.sh on every deploy. Exits non-zero if a row that
// should be encrypted could not be, so a deploy notices. // should be encrypted could not be, so a deploy notices.
// //
// Bootstrapping differs per site: the config/ stack (dance, fotografia, hub2) // Every site opens $pdo in its config/db.php; that is the whole bootstrap.
// opens $pdo in config/db.php; surf/silver builds it through App\Database. Try
// the common path first and fall back, rather than assuming either.
declare(strict_types=1); declare(strict_types=1);
@@ -23,16 +21,6 @@ if (is_file($repoRoot . '/config/db.php')) {
if (is_file($repoRoot . '/config/functions.php')) { if (is_file($repoRoot . '/config/functions.php')) {
require_once $repoRoot . '/config/functions.php'; require_once $repoRoot . '/config/functions.php';
} }
} elseif (is_file($repoRoot . '/src/db.php')) {
// surf/silver: config.php loads the environment that db.php then reads.
if (is_file($repoRoot . '/src/config.php')) require_once $repoRoot . '/src/config.php';
require_once $repoRoot . '/src/db.php';
}
if (!$pdo instanceof PDO) {
foreach (['App\\Database::getConnection', 'App\\Database::pdo', 'db', 'get_pdo'] as $factory) {
if (is_callable($factory)) { $pdo = $factory(); break; }
}
} }
if (!$pdo instanceof PDO) { if (!$pdo instanceof PDO) {
@@ -40,10 +28,10 @@ if (!$pdo instanceof PDO) {
exit(2); exit(2);
} }
// src/db.php carries hardcoded development fallbacks (a 192.168.x host, a // A bootstrap with a hardcoded fallback schema would connect somewhere
// *_dev schema). If the environment did not reach it we would connect // harmless-looking when the environment did not reach it, and "encrypt"
// somewhere harmless-looking and "encrypt" nothing, reporting success. Refuse // nothing, reporting success. Refuse unless the live connection is the schema
// unless the live connection is the schema DB_NAME actually names. // DB_NAME actually names.
$expected = (string) ($_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: ''); $expected = (string) ($_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: '');
$actual = (string) $pdo->query('SELECT DATABASE()')->fetchColumn(); $actual = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
if ($expected !== '' && $actual !== $expected) { if ($expected !== '' && $actual !== $expected) {
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# Refresh the iCloud Private Relay egress ranges that functions_geo.php reads.
#
# geo-relay-update.sh pt # one or more ISO country codes
# geo-relay-update.sh # falls back to $GEO_ALLOW_COUNTRIES
#
# Why this exists: Private Relay sends Safari traffic out through Apple's egress
# addresses, which are registered where Apple registered them and not where the
# person is. Against the ipdeny lists a subscriber in Aveiro looks foreign, gets
# refused, and never hears back. Apple publishes which country each egress range
# serves, so the gate can ask the right question about it.
#
# It writes relay-<cc>.v4 / relay-<cc>.v6 — same format as the ipdeny lists, read
# by the same code. **Only the countries you ask for**, and that is the point:
# the published list is 42k IPv4 ranges and 246k IPv6 (855 KB and 6.7 MB), while
# Portugal is 611 of them. The whole file would be a 6.7 MB commit every week and
# a 6.7 MB parse on every request, to answer a question about one country.
#
# Keeping it per-country is also what stops this becoming a Relay whitelist: a
# range serving France is simply never written, so a French relay user is
# refused by the normal path exactly like any other foreign address.
#
# Apple adds ranges as it adds capacity, so weekly is reasonable:
#
# 23 4 * * 1 /srv/sites/<site>/shared/platform-tools/bin/geo-relay-update.sh pt
#
# The files are committed, same as the ipdeny data and for the same reason: a
# deploy on a box that cannot reach Apple still has working data — as current as
# the last commit.
set -euo pipefail
DATA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/geo"
URL="${ICLOUD_RELAY_URL:-https://mask-api.icloud.com/egress-ip-ranges.csv}"
# Apple serves relay capacity for a country from at least a few dozen ranges.
# Fewer than this means the country code was wrong, the format changed, or the
# download was truncated — and overwriting a good file with it would quietly
# turn every relay visitor back into a foreigner.
MIN_ROWS=10
countries=("$@")
if [ ${#countries[@]} -eq 0 ]; then
read -r -a countries <<< "${GEO_ALLOW_COUNTRIES//[,;]/ }"
fi
if [ ${#countries[@]} -eq 0 ]; then
echo "uso: $(basename "$0") <cc> [cc...] (ou defina GEO_ALLOW_COUNTRIES)" >&2
exit 2
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "iCloud Private Relay: a descarregar $URL"
if ! curl -fsS --max-time 60 "$URL" -o "$tmp/raw.csv"; then
echo "FALHOU o download — os ficheiros anteriores mantêm-se." >&2
exit 1
fi
failed=0
for cc in "${countries[@]}"; do
cc="$(echo "$cc" | tr '[:upper:]' '[:lower:]')"
[ -n "$cc" ] || continue
up="$(echo "$cc" | tr '[:lower:]' '[:upper:]')"
# O formato da Apple é: cidr,countryCode,region,city, — só as duas
# primeiras colunas interessam, e uma linha que não seja exactamente isso é
# descartada em vez de adivinhada: uma mudança de formato tem de aparecer
# como "poucas gamas" e parar o script, não como um ficheiro meio lido.
awk -F',' -v want="$up" '
$1 ~ /\// && toupper($2) == want {
if ($1 ~ /:/) { print $1 > v6 } else { print $1 > v4 }
}
' v4="$tmp/$cc.v4" v6="$tmp/$cc.v6" "$tmp/raw.csv" || true
touch "$tmp/$cc.v4" "$tmp/$cc.v6"
rows4=$(wc -l < "$tmp/$cc.v4" | tr -d ' ')
rows6=$(wc -l < "$tmp/$cc.v6" | tr -d ' ')
total=$((rows4 + rows6))
if [ "$total" -lt "$MIN_ROWS" ]; then
echo "FALHOU $up: só $total gamas (mínimo $MIN_ROWS). Ficheiros anteriores mantidos." >&2
failed=1
continue
fi
# Ordenados no disco para um git diff entre duas actualizações se conseguir
# ler. O módulo volta a ordená-los ao carregar, porque esta ordem é textual
# e a procura precisa da ordem dos bytes empacotados.
sort -u "$tmp/$cc.v4" -o "$tmp/$cc.v4"
sort -u "$tmp/$cc.v6" -o "$tmp/$cc.v6"
mkdir -p "$DATA_DIR"
mv "$tmp/$cc.v4" "$DATA_DIR/relay-$cc.v4"
mv "$tmp/$cc.v6" "$DATA_DIR/relay-$cc.v6"
echo "OK $up: $rows4 gamas IPv4, $rows6 IPv6 → $DATA_DIR/relay-$cc.{v4,v6}"
done
exit "$failed"
@@ -0,0 +1,29 @@
<?php
// Envia ao hub os eventos de segurança em espera e aplica os banimentos da frota.
//
// */5 * * * * php /var/www/html/shared/platform-tools/bin/hub-security-sync.php
//
// Sem isto a sincronização acontece na mesma, no fim dos pedidos normais — mas
// um site sem visitas nunca chegaria a receber os banimentos dos outros, e uma
// caixa de saída cheia depois de o hub ter estado em baixo esvazia-se aqui de uma
// vez em vez de um lote por visita.
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
// O mesmo arranque dos endpoints do hub: resolve os dois stacks da frota.
require dirname(__DIR__) . '/hub_bootstrap.php';
require_once dirname(__DIR__) . '/functions_security_events.php';
if (!$pdo instanceof PDO) {
echo json_encode(['ok' => true, 'skipped' => 'plataforma sem base de dados']), "\n";
exit(0);
}
$res = hub_security_sync($pdo, 50);
echo json_encode($res, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
exit(!empty($res['ok']) ? 0 : 1);
+100
View File
@@ -0,0 +1,100 @@
<?php
// ─── Países para a página de bloqueio por país ──────────────────────────────
//
// Nomes em português europeu, agrupados pela ordem por que alguém os procura
// num site desta frota — e não por ordem alfabética do código, que é a ordem
// de quem escreve a lista e não a de quem a lê.
//
// **Não é a ISO 3166 inteira**, de propósito. São 249 códigos, e uma página com
// 249 caixas é a mesma coisa que um campo de texto: ninguém encontra lá nada.
// Estão aqui os que um negócio português tem alguma hipótese de querer — a
// Europa toda, os países de língua portuguesa, os destinos de emigração e as
// meia dúzia de economias grandes. Para o resto, a página tem um campo de
// escape onde se escreve o código à mão, e um código que já esteja gravado
// aparece sempre, mesmo que não conste desta lista.
//
// Um país que falte aqui não é um país bloqueado: isto é a lista do que se
// consegue escolher com o rato, não a lista do que o módulo sabe tratar.
return [
'Mais usados' => [
'PT' => 'Portugal',
'ES' => 'Espanha',
'FR' => 'França',
'GB' => 'Reino Unido',
'CH' => 'Suíça',
'LU' => 'Luxemburgo',
'DE' => 'Alemanha',
'BR' => 'Brasil',
],
'Resto da Europa' => [
'AD' => 'Andorra',
'AL' => 'Albânia',
'AT' => 'Áustria',
'BA' => 'Bósnia e Herzegovina',
'BE' => 'Bélgica',
'BG' => 'Bulgária',
'BY' => 'Bielorrússia',
'CY' => 'Chipre',
'CZ' => 'Chéquia',
'DK' => 'Dinamarca',
'EE' => 'Estónia',
'FI' => 'Finlândia',
'GI' => 'Gibraltar',
'GR' => 'Grécia',
'HR' => 'Croácia',
'HU' => 'Hungria',
'IE' => 'Irlanda',
'IS' => 'Islândia',
'IT' => 'Itália',
'LI' => 'Listenstaine',
'LT' => 'Lituânia',
'LV' => 'Letónia',
'MC' => 'Mónaco',
'MD' => 'Moldávia',
'ME' => 'Montenegro',
'MK' => 'Macedónia do Norte',
'MT' => 'Malta',
'NL' => 'Países Baixos',
'NO' => 'Noruega',
'PL' => 'Polónia',
'RO' => 'Roménia',
'RS' => 'Sérvia',
'RU' => 'Rússia',
'SE' => 'Suécia',
'SI' => 'Eslovénia',
'SK' => 'Eslováquia',
'SM' => 'São Marinho',
'UA' => 'Ucrânia',
'VA' => 'Vaticano',
],
'Língua portuguesa' => [
'AO' => 'Angola',
'CV' => 'Cabo Verde',
'GQ' => 'Guiné Equatorial',
'GW' => 'Guiné-Bissau',
'MO' => 'Macau',
'MZ' => 'Moçambique',
'ST' => 'São Tomé e Príncipe',
'TL' => 'Timor-Leste',
],
'Fora da Europa' => [
'AE' => 'Emirados Árabes Unidos',
'AR' => 'Argentina',
'AU' => 'Austrália',
'CA' => 'Canadá',
'CL' => 'Chile',
'CN' => 'China',
'CO' => 'Colômbia',
'IL' => 'Israel',
'IN' => 'Índia',
'JP' => 'Japão',
'MA' => 'Marrocos',
'MX' => 'México',
'NZ' => 'Nova Zelândia',
'TR' => 'Turquia',
'US' => 'Estados Unidos',
'VE' => 'Venezuela',
'ZA' => 'África do Sul',
],
];
@@ -0,0 +1,76 @@
104.28.136.119/32
104.28.136.120/32
104.28.138.54/32
104.28.138.55/32
104.28.139.148/32
104.28.139.149/32
104.28.143.15/32
104.28.146.45/32
104.28.146.46/32
104.28.146.47/32
104.28.146.48/32
104.28.146.49/32
104.28.34.141/32
104.28.34.142/32
104.28.34.143/32
104.28.34.144/32
104.28.42.78/32
104.28.42.79/32
104.28.53.18/32
104.28.53.19/32
104.28.86.43/32
104.28.86.44/32
104.28.87.85/32
104.28.87.86/32
104.28.87.87/32
104.28.87.88/32
104.28.88.103/32
104.28.88.104/32
104.28.88.105/32
104.28.88.106/32
104.28.96.131/32
104.28.96.132/32
140.248.40.172/31
140.248.40.174/31
140.248.40.176/31
140.248.40.178/31
146.75.168.156/31
146.75.168.158/31
146.75.168.160/31
146.75.168.162/31
146.75.182.52/31
146.75.182.54/31
146.75.182.56/31
146.75.182.58/31
146.75.185.156/31
146.75.185.158/31
146.75.185.160/31
146.75.185.162/31
146.75.210.52/31
146.75.210.54/31
146.75.210.56/31
146.75.210.58/31
172.225.158.178/31
172.225.158.180/30
172.225.158.216/31
172.225.159.64/28
172.225.161.144/28
172.225.162.192/28
172.225.163.62/31
172.225.163.80/30
172.225.163.84/31
172.225.178.176/28
172.225.178.192/28
172.225.179.50/31
172.225.179.52/30
172.225.179.64/31
172.225.192.208/28
172.225.193.58/31
172.225.193.60/30
172.225.193.64/31
172.225.212.0/28
172.225.212.16/30
172.225.212.20/31
172.225.212.22/31
172.225.212.240/28
172.225.243.64/28
+535
View File
@@ -0,0 +1,535 @@
2606:54c0:4fe8::/45
2606:54c0:4ff0::/45
2606:54c0:4ff8::/45
2606:54c0:5000::/45
2606:54c0:5008::/45
2606:54c0:5010::/45
2606:54c3:0:1152::/64
2606:54c3:0:132a::/64
2606:54c3:0:16a0::/64
2606:54c3:0:1992::/64
2606:54c3:0:857::/64
2606:54c3:0:ac1::/64
2a02:26f7:cb00:4000::/64
2a02:26f7:cb00:400::/56
2a02:26f7:cb00:500::/58
2a02:26f7:cb00:5280::/64
2a02:26f7:cb00:5281::/64
2a02:26f7:cb00:5282::/64
2a02:26f7:cb00:5283::/64
2a02:26f7:cb00:5284::/64
2a02:26f7:cb00:5285::/64
2a02:26f7:cb00:5286::/64
2a02:26f7:cb00:5287::/64
2a02:26f7:cb00:5288::/64
2a02:26f7:cb00:5289::/64
2a02:26f7:cb00:528a::/64
2a02:26f7:cb00:528b::/64
2a02:26f7:cb00:528c::/64
2a02:26f7:cb00:528d::/64
2a02:26f7:cb00:528e::/64
2a02:26f7:cb00:528f::/64
2a02:26f7:cb00:5290::/64
2a02:26f7:cb00:5291::/64
2a02:26f7:cb00:5292::/64
2a02:26f7:cb00:5293::/64
2a02:26f7:cb00:5294::/64
2a02:26f7:cb00:5295::/64
2a02:26f7:cb00:5296::/64
2a02:26f7:cb00:5297::/64
2a02:26f7:cb00:5298::/64
2a02:26f7:cb00:5299::/64
2a02:26f7:cb00:529a::/64
2a02:26f7:cb00:529b::/64
2a02:26f7:cb00:529c::/64
2a02:26f7:cb00:529d::/64
2a02:26f7:cb00:529e::/64
2a02:26f7:cb00:529f::/64
2a02:26f7:cb00:52a0::/64
2a02:26f7:cb00:52a1::/64
2a02:26f7:cb00:52a2::/64
2a02:26f7:cb00:52a3::/64
2a02:26f7:cb00:52a4::/64
2a02:26f7:cb00:52a5::/64
2a02:26f7:cb00:52a6::/64
2a02:26f7:cb00:52a7::/64
2a02:26f7:cb00:52a8::/64
2a02:26f7:cb00:52a9::/64
2a02:26f7:cb00:52aa::/64
2a02:26f7:cb00:52ab::/64
2a02:26f7:cb00:52ac::/64
2a02:26f7:cb00:52ad::/64
2a02:26f7:cb00:52ae::/64
2a02:26f7:cb00:52af::/64
2a02:26f7:cb00:52b0::/64
2a02:26f7:cb00:52b1::/64
2a02:26f7:cb00:52b2::/64
2a02:26f7:cb00:52b3::/64
2a02:26f7:cb00:52b4::/64
2a02:26f7:cb00:52b5::/64
2a02:26f7:cb00:52b6::/64
2a02:26f7:cb00:52b7::/64
2a02:26f7:cb00:52b8::/64
2a02:26f7:cb00:52b9::/64
2a02:26f7:cb00:52ba::/64
2a02:26f7:cb00:52bb::/64
2a02:26f7:cb00:52bc::/64
2a02:26f7:cb00:52bd::/64
2a02:26f7:cb00:52be::/64
2a02:26f7:cb00:52bf::/64
2a02:26f7:cb00:52c0::/64
2a02:26f7:cb00:52c1::/64
2a02:26f7:cb00:52c2::/64
2a02:26f7:cb00:52c3::/64
2a02:26f7:cb00:52c4::/64
2a02:26f7:cb00:52c5::/64
2a02:26f7:cb00:52c6::/64
2a02:26f7:cb00:52c7::/64
2a02:26f7:cb00:52c8::/64
2a02:26f7:cb00:52c9::/64
2a02:26f7:cb00:52ca::/64
2a02:26f7:cb00:52cb::/64
2a02:26f7:cb00:52cc::/64
2a02:26f7:cb00:52cd::/64
2a02:26f7:cb00:52ce::/64
2a02:26f7:cb00:52cf::/64
2a02:26f7:cb00:52d0::/64
2a02:26f7:cb00:52d1::/64
2a02:26f7:cb00:52d2::/64
2a02:26f7:cb00:52d3::/64
2a02:26f7:cb00:52d4::/64
2a02:26f7:cb00:52d5::/64
2a02:26f7:cb00:52d6::/64
2a02:26f7:cb00:52d7::/64
2a02:26f7:cb00:52d8::/64
2a02:26f7:cb00:52d9::/64
2a02:26f7:cb00::/54
2a02:26f7:cb01:4000::/64
2a02:26f7:cb02:4000::/64
2a02:26f7:cb02:400::/56
2a02:26f7:cb02:500::/58
2a02:26f7:cb02:5280::/64
2a02:26f7:cb02:5281::/64
2a02:26f7:cb02:5282::/64
2a02:26f7:cb02:5283::/64
2a02:26f7:cb02:5284::/64
2a02:26f7:cb02:5285::/64
2a02:26f7:cb02:5286::/64
2a02:26f7:cb02:5287::/64
2a02:26f7:cb02:5288::/64
2a02:26f7:cb02:5289::/64
2a02:26f7:cb02:528a::/64
2a02:26f7:cb02:528b::/64
2a02:26f7:cb02:528c::/64
2a02:26f7:cb02:528d::/64
2a02:26f7:cb02:528e::/64
2a02:26f7:cb02:528f::/64
2a02:26f7:cb02:5290::/64
2a02:26f7:cb02:5291::/64
2a02:26f7:cb02:5292::/64
2a02:26f7:cb02:5293::/64
2a02:26f7:cb02:5294::/64
2a02:26f7:cb02:5295::/64
2a02:26f7:cb02:5296::/64
2a02:26f7:cb02:5297::/64
2a02:26f7:cb02:5298::/64
2a02:26f7:cb02:5299::/64
2a02:26f7:cb02:529a::/64
2a02:26f7:cb02:529b::/64
2a02:26f7:cb02:529c::/64
2a02:26f7:cb02:529d::/64
2a02:26f7:cb02:529e::/64
2a02:26f7:cb02:529f::/64
2a02:26f7:cb02:52a0::/64
2a02:26f7:cb02:52a1::/64
2a02:26f7:cb02:52a2::/64
2a02:26f7:cb02:52a3::/64
2a02:26f7:cb02:52a4::/64
2a02:26f7:cb02:52a5::/64
2a02:26f7:cb02:52a6::/64
2a02:26f7:cb02:52a7::/64
2a02:26f7:cb02:52a8::/64
2a02:26f7:cb02:52a9::/64
2a02:26f7:cb02:52aa::/64
2a02:26f7:cb02:52ab::/64
2a02:26f7:cb02:52ac::/64
2a02:26f7:cb02:52ad::/64
2a02:26f7:cb02:52ae::/64
2a02:26f7:cb02:52af::/64
2a02:26f7:cb02:52b0::/64
2a02:26f7:cb02:52b1::/64
2a02:26f7:cb02:52b2::/64
2a02:26f7:cb02:52b3::/64
2a02:26f7:cb02:52b4::/64
2a02:26f7:cb02:52b5::/64
2a02:26f7:cb02:52b6::/64
2a02:26f7:cb02:52b7::/64
2a02:26f7:cb02:52b8::/64
2a02:26f7:cb02:52b9::/64
2a02:26f7:cb02:52ba::/64
2a02:26f7:cb02:52bb::/64
2a02:26f7:cb02:52bc::/64
2a02:26f7:cb02:52bd::/64
2a02:26f7:cb02:52be::/64
2a02:26f7:cb02:52bf::/64
2a02:26f7:cb02:52c0::/64
2a02:26f7:cb02:52c1::/64
2a02:26f7:cb02:52c2::/64
2a02:26f7:cb02:52c3::/64
2a02:26f7:cb02:52c4::/64
2a02:26f7:cb02:52c5::/64
2a02:26f7:cb02:52c6::/64
2a02:26f7:cb02:52c7::/64
2a02:26f7:cb02:52c8::/64
2a02:26f7:cb02:52c9::/64
2a02:26f7:cb02:52ca::/64
2a02:26f7:cb02:52cb::/64
2a02:26f7:cb02:52cc::/64
2a02:26f7:cb02:52cd::/64
2a02:26f7:cb02:52ce::/64
2a02:26f7:cb02:52cf::/64
2a02:26f7:cb02:52d0::/64
2a02:26f7:cb02:52d1::/64
2a02:26f7:cb02:52d2::/64
2a02:26f7:cb02:52d3::/64
2a02:26f7:cb02:52d4::/64
2a02:26f7:cb02:52d5::/64
2a02:26f7:cb02:52d6::/64
2a02:26f7:cb02:52d7::/64
2a02:26f7:cb02:52d8::/64
2a02:26f7:cb02:52d9::/64
2a02:26f7:cb02::/54
2a02:26f7:cb03:4000::/64
2a02:26f7:cb04:4000::/64
2a02:26f7:cb04:400::/56
2a02:26f7:cb04:500::/58
2a02:26f7:cb04:5280::/64
2a02:26f7:cb04:5281::/64
2a02:26f7:cb04:5282::/64
2a02:26f7:cb04:5283::/64
2a02:26f7:cb04:5284::/64
2a02:26f7:cb04:5285::/64
2a02:26f7:cb04:5286::/64
2a02:26f7:cb04:5287::/64
2a02:26f7:cb04:5288::/64
2a02:26f7:cb04:5289::/64
2a02:26f7:cb04:528a::/64
2a02:26f7:cb04:528b::/64
2a02:26f7:cb04:528c::/64
2a02:26f7:cb04:528d::/64
2a02:26f7:cb04:528e::/64
2a02:26f7:cb04:528f::/64
2a02:26f7:cb04:5290::/64
2a02:26f7:cb04:5291::/64
2a02:26f7:cb04:5292::/64
2a02:26f7:cb04:5293::/64
2a02:26f7:cb04:5294::/64
2a02:26f7:cb04:5295::/64
2a02:26f7:cb04:5296::/64
2a02:26f7:cb04:5297::/64
2a02:26f7:cb04:5298::/64
2a02:26f7:cb04:5299::/64
2a02:26f7:cb04:529a::/64
2a02:26f7:cb04:529b::/64
2a02:26f7:cb04:529c::/64
2a02:26f7:cb04:529d::/64
2a02:26f7:cb04:529e::/64
2a02:26f7:cb04:529f::/64
2a02:26f7:cb04:52a0::/64
2a02:26f7:cb04:52a1::/64
2a02:26f7:cb04:52a2::/64
2a02:26f7:cb04:52a3::/64
2a02:26f7:cb04:52a4::/64
2a02:26f7:cb04:52a5::/64
2a02:26f7:cb04:52a6::/64
2a02:26f7:cb04:52a7::/64
2a02:26f7:cb04:52a8::/64
2a02:26f7:cb04:52a9::/64
2a02:26f7:cb04:52aa::/64
2a02:26f7:cb04:52ab::/64
2a02:26f7:cb04:52ac::/64
2a02:26f7:cb04:52ad::/64
2a02:26f7:cb04:52ae::/64
2a02:26f7:cb04:52af::/64
2a02:26f7:cb04:52b0::/64
2a02:26f7:cb04:52b1::/64
2a02:26f7:cb04:52b2::/64
2a02:26f7:cb04:52b3::/64
2a02:26f7:cb04:52b4::/64
2a02:26f7:cb04:52b5::/64
2a02:26f7:cb04:52b6::/64
2a02:26f7:cb04:52b7::/64
2a02:26f7:cb04:52b8::/64
2a02:26f7:cb04:52b9::/64
2a02:26f7:cb04:52ba::/64
2a02:26f7:cb04:52bb::/64
2a02:26f7:cb04:52bc::/64
2a02:26f7:cb04:52bd::/64
2a02:26f7:cb04:52be::/64
2a02:26f7:cb04:52bf::/64
2a02:26f7:cb04:52c0::/64
2a02:26f7:cb04:52c1::/64
2a02:26f7:cb04:52c2::/64
2a02:26f7:cb04:52c3::/64
2a02:26f7:cb04:52c4::/64
2a02:26f7:cb04:52c5::/64
2a02:26f7:cb04:52c6::/64
2a02:26f7:cb04:52c7::/64
2a02:26f7:cb04:52c8::/64
2a02:26f7:cb04:52c9::/64
2a02:26f7:cb04:52ca::/64
2a02:26f7:cb04:52cb::/64
2a02:26f7:cb04:52cc::/64
2a02:26f7:cb04:52cd::/64
2a02:26f7:cb04:52ce::/64
2a02:26f7:cb04:52cf::/64
2a02:26f7:cb04:52d0::/64
2a02:26f7:cb04:52d1::/64
2a02:26f7:cb04:52d2::/64
2a02:26f7:cb04:52d3::/64
2a02:26f7:cb04:52d4::/64
2a02:26f7:cb04:52d5::/64
2a02:26f7:cb04:52d6::/64
2a02:26f7:cb04:52d7::/64
2a02:26f7:cb04:52d8::/64
2a02:26f7:cb04:52d9::/64
2a02:26f7:cb04::/54
2a02:26f7:cb05:4000::/64
2a02:26f7:cb0c:4000::/64
2a02:26f7:cb0c:400::/56
2a02:26f7:cb0c:500::/58
2a02:26f7:cb0c:5280::/64
2a02:26f7:cb0c:5281::/64
2a02:26f7:cb0c:5282::/64
2a02:26f7:cb0c:5283::/64
2a02:26f7:cb0c:5284::/64
2a02:26f7:cb0c:5285::/64
2a02:26f7:cb0c:5286::/64
2a02:26f7:cb0c:5287::/64
2a02:26f7:cb0c:5288::/64
2a02:26f7:cb0c:5289::/64
2a02:26f7:cb0c:528a::/64
2a02:26f7:cb0c:528b::/64
2a02:26f7:cb0c:528c::/64
2a02:26f7:cb0c:528d::/64
2a02:26f7:cb0c:528e::/64
2a02:26f7:cb0c:528f::/64
2a02:26f7:cb0c:5290::/64
2a02:26f7:cb0c:5291::/64
2a02:26f7:cb0c:5292::/64
2a02:26f7:cb0c:5293::/64
2a02:26f7:cb0c:5294::/64
2a02:26f7:cb0c:5295::/64
2a02:26f7:cb0c:5296::/64
2a02:26f7:cb0c:5297::/64
2a02:26f7:cb0c:5298::/64
2a02:26f7:cb0c:5299::/64
2a02:26f7:cb0c:529a::/64
2a02:26f7:cb0c:529b::/64
2a02:26f7:cb0c:529c::/64
2a02:26f7:cb0c:529d::/64
2a02:26f7:cb0c:529e::/64
2a02:26f7:cb0c:529f::/64
2a02:26f7:cb0c:52a0::/64
2a02:26f7:cb0c:52a1::/64
2a02:26f7:cb0c:52a2::/64
2a02:26f7:cb0c:52a3::/64
2a02:26f7:cb0c:52a4::/64
2a02:26f7:cb0c:52a5::/64
2a02:26f7:cb0c:52a6::/64
2a02:26f7:cb0c:52a7::/64
2a02:26f7:cb0c:52a8::/64
2a02:26f7:cb0c:52a9::/64
2a02:26f7:cb0c:52aa::/64
2a02:26f7:cb0c:52ab::/64
2a02:26f7:cb0c:52ac::/64
2a02:26f7:cb0c:52ad::/64
2a02:26f7:cb0c:52ae::/64
2a02:26f7:cb0c:52af::/64
2a02:26f7:cb0c:52b0::/64
2a02:26f7:cb0c:52b1::/64
2a02:26f7:cb0c:52b2::/64
2a02:26f7:cb0c:52b3::/64
2a02:26f7:cb0c:52b4::/64
2a02:26f7:cb0c:52b5::/64
2a02:26f7:cb0c:52b6::/64
2a02:26f7:cb0c:52b7::/64
2a02:26f7:cb0c:52b8::/64
2a02:26f7:cb0c:52b9::/64
2a02:26f7:cb0c:52ba::/64
2a02:26f7:cb0c:52bb::/64
2a02:26f7:cb0c:52bc::/64
2a02:26f7:cb0c:52bd::/64
2a02:26f7:cb0c:52be::/64
2a02:26f7:cb0c:52bf::/64
2a02:26f7:cb0c:52c0::/64
2a02:26f7:cb0c:52c1::/64
2a02:26f7:cb0c:52c2::/64
2a02:26f7:cb0c:52c3::/64
2a02:26f7:cb0c:52c4::/64
2a02:26f7:cb0c:52c5::/64
2a02:26f7:cb0c:52c6::/64
2a02:26f7:cb0c:52c7::/64
2a02:26f7:cb0c:52c8::/64
2a02:26f7:cb0c:52c9::/64
2a02:26f7:cb0c:52ca::/64
2a02:26f7:cb0c:52cb::/64
2a02:26f7:cb0c:52cc::/64
2a02:26f7:cb0c:52cd::/64
2a02:26f7:cb0c:52ce::/64
2a02:26f7:cb0c:52cf::/64
2a02:26f7:cb0c:52d0::/64
2a02:26f7:cb0c:52d1::/64
2a02:26f7:cb0c:52d2::/64
2a02:26f7:cb0c:52d3::/64
2a02:26f7:cb0c:52d4::/64
2a02:26f7:cb0c:52d5::/64
2a02:26f7:cb0c:52d6::/64
2a02:26f7:cb0c:52d7::/64
2a02:26f7:cb0c:52d8::/64
2a02:26f7:cb0c:52d9::/64
2a02:26f7:cb0c::/54
2a02:26f7:cb0d:4000::/64
2a02:26f7:cb10:4000::/64
2a02:26f7:cb10:400::/56
2a02:26f7:cb10:500::/58
2a02:26f7:cb10:5280::/64
2a02:26f7:cb10:5281::/64
2a02:26f7:cb10:5282::/64
2a02:26f7:cb10:5283::/64
2a02:26f7:cb10:5284::/64
2a02:26f7:cb10:5285::/64
2a02:26f7:cb10:5286::/64
2a02:26f7:cb10:5287::/64
2a02:26f7:cb10:5288::/64
2a02:26f7:cb10:5289::/64
2a02:26f7:cb10:528a::/64
2a02:26f7:cb10:528b::/64
2a02:26f7:cb10:528c::/64
2a02:26f7:cb10:528d::/64
2a02:26f7:cb10:528e::/64
2a02:26f7:cb10:528f::/64
2a02:26f7:cb10:5290::/64
2a02:26f7:cb10:5291::/64
2a02:26f7:cb10:5292::/64
2a02:26f7:cb10:5293::/64
2a02:26f7:cb10:5294::/64
2a02:26f7:cb10:5295::/64
2a02:26f7:cb10:5296::/64
2a02:26f7:cb10:5297::/64
2a02:26f7:cb10:5298::/64
2a02:26f7:cb10:5299::/64
2a02:26f7:cb10:529a::/64
2a02:26f7:cb10:529b::/64
2a02:26f7:cb10:529c::/64
2a02:26f7:cb10:529d::/64
2a02:26f7:cb10:529e::/64
2a02:26f7:cb10:529f::/64
2a02:26f7:cb10:52a0::/64
2a02:26f7:cb10:52a1::/64
2a02:26f7:cb10:52a2::/64
2a02:26f7:cb10:52a3::/64
2a02:26f7:cb10:52a4::/64
2a02:26f7:cb10:52a5::/64
2a02:26f7:cb10:52a6::/64
2a02:26f7:cb10:52a7::/64
2a02:26f7:cb10:52a8::/64
2a02:26f7:cb10:52a9::/64
2a02:26f7:cb10:52aa::/64
2a02:26f7:cb10:52ab::/64
2a02:26f7:cb10:52ac::/64
2a02:26f7:cb10:52ad::/64
2a02:26f7:cb10:52ae::/64
2a02:26f7:cb10:52af::/64
2a02:26f7:cb10:52b0::/64
2a02:26f7:cb10:52b1::/64
2a02:26f7:cb10:52b2::/64
2a02:26f7:cb10:52b3::/64
2a02:26f7:cb10:52b4::/64
2a02:26f7:cb10:52b5::/64
2a02:26f7:cb10:52b6::/64
2a02:26f7:cb10:52b7::/64
2a02:26f7:cb10:52b8::/64
2a02:26f7:cb10:52b9::/64
2a02:26f7:cb10:52ba::/64
2a02:26f7:cb10:52bb::/64
2a02:26f7:cb10:52bc::/64
2a02:26f7:cb10:52bd::/64
2a02:26f7:cb10:52be::/64
2a02:26f7:cb10:52bf::/64
2a02:26f7:cb10:52c0::/64
2a02:26f7:cb10:52c1::/64
2a02:26f7:cb10:52c2::/64
2a02:26f7:cb10:52c3::/64
2a02:26f7:cb10:52c4::/64
2a02:26f7:cb10:52c5::/64
2a02:26f7:cb10:52c6::/64
2a02:26f7:cb10:52c7::/64
2a02:26f7:cb10:52c8::/64
2a02:26f7:cb10:52c9::/64
2a02:26f7:cb10:52ca::/64
2a02:26f7:cb10:52cb::/64
2a02:26f7:cb10:52cc::/64
2a02:26f7:cb10:52cd::/64
2a02:26f7:cb10:52ce::/64
2a02:26f7:cb10:52cf::/64
2a02:26f7:cb10:52d0::/64
2a02:26f7:cb10:52d1::/64
2a02:26f7:cb10:52d2::/64
2a02:26f7:cb10:52d3::/64
2a02:26f7:cb10:52d4::/64
2a02:26f7:cb10:52d5::/64
2a02:26f7:cb10:52d6::/64
2a02:26f7:cb10:52d7::/64
2a02:26f7:cb10:52d8::/64
2a02:26f7:cb10:52d9::/64
2a02:26f7:cb10::/54
2a02:26f7:cb11:4000::/64
2a02:26f7:ed00:4000::/64
2a02:26f7:ed00:cbc0::/64
2a02:26f7:ed00:cbc1::/64
2a02:26f7:ed01:4000::/64
2a02:26f7:ed02:4000::/64
2a02:26f7:ed02:cbc0::/64
2a02:26f7:ed02:cbc1::/64
2a02:26f7:ed03:4000::/64
2a02:26f7:ed04:4000::/64
2a02:26f7:ed04:cbc0::/64
2a02:26f7:ed04:cbc1::/64
2a02:26f7:ed05:4000::/64
2a02:26f7:ed0c:4000::/64
2a02:26f7:ed0c:cbc0::/64
2a02:26f7:ed0c:cbc1::/64
2a02:26f7:ed0d:4000::/64
2a04:4e41:1303:5000::/52
2a04:4e41:1303:6000::/52
2a04:4e41:1303:7000::/52
2a04:4e41:1324::/48
2a04:4e41:1603:6000::/52
2a04:4e41:1603:7000::/52
2a04:4e41:1603:8000::/52
2a04:4e41:161a::/48
2a04:4e41:4100:8000::/52
2a04:4e41:4100:9000::/52
2a04:4e41:4100:a000::/52
2a04:4e41:4112::/48
2a04:4e41:7403:6000::/52
2a04:4e41:7403:7000::/52
2a04:4e41:7403:8000::/52
2a04:4e41:741a::/48
2a04:4e41:7800:8000::/52
2a04:4e41:7800:9000::/52
2a04:4e41:7800:a000::/52
2a04:4e41:7812::/48
2a09:bac2:4fe8::/45
2a09:bac2:4ff0::/45
2a09:bac2:4ff8::/45
2a09:bac2:5000::/45
2a09:bac2:5008::/45
2a09:bac2:5010::/45
2a09:bac3:4fe8::/45
2a09:bac3:4ff0::/45
2a09:bac3:4ff8::/45
2a09:bac3:5000::/45
2a09:bac3:5008::/45
2a09:bac3:5010::/45
+6 -1
View File
@@ -76,7 +76,12 @@ require_once __DIR__ . '/functions_csrf.php';
// A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas, // A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas,
// daí poder ser sobreposta pelo layout antes do include. // daí poder ser sobreposta pelo layout antes do include.
$hubPage = (string) ($GLOBALS['platform_feedback_hub_url'] ?? '/views/sysadmin/hub_integration.php'); // Num site com router (platform_connect(['router' => true])) é o endereço
// dele; o stub antigo só reencaminha.
$hubPage = (string) ($GLOBALS['platform_feedback_hub_url']
?? (function_exists('platform_router_enabled') && platform_router_enabled()
? platform_route_url('hub_integration')
: '/views/sysadmin/hub_integration.php'));
$inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer'; $inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer';
?> ?>
@@ -115,6 +115,13 @@ function platform_antispam_ident_ok(string $ident): bool
* tabela de settings. Não é um segredo tão bom como os outros, mas é * tabela de settings. Não é um segredo tão bom como os outros, mas é
* constante — e uma defesa que tranca todos os formulários é pior do que * constante — e uma defesa que tranca todos os formulários é pior do que
* uma assinatura mais fraca. * uma assinatura mais fraca.
*
* A mesma escada vive agora em platform_derived_key() (functions_settings.php),
* que é o que os módulos novos usam — o captcha, por exemplo. Esta cópia fica
* porque separa os domínios de outra maneira (ver o comentário lá dentro sobre
* a chave do ambiente) e porque trocá-la num site a correr invalida os
* formulários abertos nesse instante. Quem a converter faz uma janela que
* aceite as duas assinaturas antes de tirar a antiga.
*/ */
function platform_antispam_key(?PDO $pdo = null): string function platform_antispam_key(?PDO $pdo = null): string
{ {
@@ -195,8 +202,13 @@ function platform_antispam_trap_field(string $form = 'form', ?PDO $pdo = null):
if (!$scriptPrinted) { if (!$scriptPrinted) {
$scriptPrinted = true; $scriptPrinted = true;
$html .= '<script>document.querySelectorAll(\'input[data-js-check]\').forEach(' // Corre já (o formulário que o imprime está acima) e outra vez quando a
. 'function(i){i.value=i.dataset.jsCheck;});</script>'; // página acaba de carregar: o script sai uma vez só, junto ao PRIMEIRO
// formulário, e um segundo mais abaixo na mesma página ainda não existia
// quando ele correu — ficava sem a prova de JS (silver, 2026-09-23).
$html .= '<script>(function(){function f(){document.querySelectorAll(\'input[data-js-check]\').forEach('
. 'function(i){i.value=i.dataset.jsCheck;});}f();'
. 'document.addEventListener(\'DOMContentLoaded\',f);})();</script>';
} }
return $html; return $html;
+348
View File
@@ -0,0 +1,348 @@
<?php
// ─── Registo de auditoria (partilhado) ──────────────────────────────────────
//
// Quem fez o quê, a quê, quando e de onde — numa tabela `audit_log` de cada
// site, escrita por uma função só:
//
// platform_audit($pdo, 'user.delete', [
// 'target_type' => 'user',
// 'target_id' => 42,
// 'target_label' => 'Maria Silva',
// 'summary' => 'Enviada para a reciclagem',
// 'changes' => platform_audit_diff($antes, $depois),
// ]);
//
// Porque existe: a frota tinha TRÊS registos incompatíveis (hub, dance, quack,
// cada um com as suas colunas e a sua função) e cinco sites sem nenhum. O
// mesmo argumento do anti-spam e dos segredos — cópias não ficam iguais,
// ficam parecidas.
//
// ─── As tabelas que já existiam ─────────────────────────────────────────────
//
// Não se renomeia nada. O escritor LÊ as colunas da tabela do site
// (information_schema, uma vez por pedido) e escreve cada campo canónico na
// coluna que o site declarou para ele — `platform_connect(['audit' => ['map' =>
// ['actor_id' => 'user_id', ...]]])`, como o `schema` da autenticação. Um campo
// sem coluna é simplesmente omitido; uma coluna NOT NULL sem valor por omissão
// recebe um vazio (a do quack, `summary`, rebentava o INSERT); os textos são
// cortados ao tamanho da coluna. As migrações de cada site acrescentam as
// colunas que faltam, e daí em diante nada se perde.
//
// Um site sem tabela recebe-a no primeiro uso, já na forma canónica
// (platform_audit_ensure_table()).
//
// ─── As regras ──────────────────────────────────────────────────────────────
//
// - **Nunca parte a ação.** Uma falha a escrever regista-se no error_log e o
// pedido segue: perde-se a linha, não a operação.
// - **Segredos nunca entram.** Qualquer chave de `changes` com cara de segredo
// (password, token, secret, key, hash, cookie, csrf) é substituída por
// «(oculto)», a qualquer profundidade.
// - **O autor vem da sessão**, em qualquer das formas da frota
// (current_user(), $_SESSION['user'], ou as chaves soltas do quack), e o IP é
// o verdadeiro (platform_client_ip()). Quem chama pode passar 'actor' quando
// sabe melhor — o hub a agir por ops, o webhook do deploy, o PIN do dance.
require_once __DIR__ . '/functions_client_ip.php';
/** Os campos canónicos, e a definição de cada um numa tabela nova. */
function platform_audit_columns(): array
{
return [
'actor_id' => 'BIGINT UNSIGNED NULL',
'actor_name' => 'VARCHAR(150) NULL',
'actor_role' => 'VARCHAR(30) NULL',
'action' => "VARCHAR(80) NOT NULL DEFAULT ''",
'target_type' => "VARCHAR(60) NOT NULL DEFAULT ''",
'target_id' => 'VARCHAR(64) NULL',
'target_label' => 'VARCHAR(250) NULL',
'summary' => 'VARCHAR(1000) NULL',
'changes' => 'LONGTEXT NULL',
'ip' => 'VARCHAR(45) NULL',
'user_agent' => 'VARCHAR(255) NULL',
];
}
/** Nome da tabela e mapa de colunas deste site (platform_connect 'audit'). */
function platform_audit_config(): array
{
$cfg = function_exists('platform_config') ? (array) (platform_config()['audit'] ?? []) : [];
return [
'table' => (string) ($cfg['table'] ?? 'audit_log'),
'map' => (array) ($cfg['map'] ?? []),
// Dias que uma linha vive; 0 = para sempre.
'keep_days' => (int) ($cfg['keep_days'] ?? 730),
];
}
function platform_audit_ensure_table(PDO $pdo, string $table): void
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) return;
$cols = '';
foreach (platform_audit_columns() as $name => $def) $cols .= " `$name` $def,\n";
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `$table` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
$cols `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_audit_created` (`created_at`),
KEY `idx_audit_actor` (`actor_id`, `created_at`),
KEY `idx_audit_action` (`action`, `created_at`),
KEY `idx_audit_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* As colunas reais da tabela: [nome => ['type' => …, 'max' => int|null,
* 'required' => bool]]. Uma consulta por pedido. Cria a tabela quando não há.
*/
function platform_audit_table_columns(PDO $pdo): array
{
static $cache = [];
$table = platform_audit_config()['table'];
if (isset($cache[$table])) return $cache[$table];
$read = function () use ($pdo, $table): array {
$st = $pdo->prepare(
'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$st->execute([$table]);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$out[$c['COLUMN_NAME']] = [
'type' => strtolower((string) $c['DATA_TYPE']),
'max' => $c['CHARACTER_MAXIMUM_LENGTH'] !== null ? (int) $c['CHARACTER_MAXIMUM_LENGTH'] : null,
'required' => $c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
&& !str_contains((string) $c['EXTRA'], 'auto_increment'),
'not_null' => $c['IS_NULLABLE'] === 'NO',
];
}
return $out;
};
$cols = $read();
if (!$cols) {
platform_audit_ensure_table($pdo, $table);
$cols = $read();
}
return $cache[$table] = $cols;
}
/** A coluna do site para um campo canónico (o mapa, ou o próprio nome). */
function platform_audit_column(string $field): string
{
return (string) (platform_audit_config()['map'][$field] ?? $field);
}
/**
* Quem está a agir, a partir da sessão, em qualquer das formas da frota:
* current_user(), $_SESSION['user'], ou as chaves soltas user_id/username/role.
*/
function platform_audit_actor(): ?array
{
$u = null;
if (function_exists('current_user')) {
try { $u = current_user(); } catch (Throwable) { $u = null; }
}
if (!is_array($u) && session_status() === PHP_SESSION_ACTIVE && is_array($_SESSION['user'] ?? null)) {
$u = $_SESSION['user'];
}
if (is_array($u) && !empty($u['id'])) {
return [
'id' => (int) $u['id'],
'name' => (string) ($u['full_name'] ?? $u['name'] ?? $u['display_name'] ?? $u['username'] ?? $u['email'] ?? ''),
'role' => (string) ($u['role'] ?? ''),
];
}
if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION['user_id'])) {
return [
'id' => (int) $_SESSION['user_id'],
'name' => (string) ($_SESSION['full_name'] ?? $_SESSION['username'] ?? ''),
'role' => (string) ($_SESSION['role'] ?? ''),
];
}
return null;
}
/** Tira os segredos de uma estrutura antes de ela ir para o registo. */
function platform_audit_redact(mixed $v, string $key = ''): mixed
{
if ($key !== '' && preg_match('/pass|token|secret|api_?key|ops_?key|hash|cookie|csrf|validator|pairing_code/i', $key)) {
// Oculta os valores mas mantém a forma: um {from, to} continua a ser um
// {from, to}, e a página continua a mostrá-lo como alteração.
if (is_array($v)) return array_map(fn($x) => ($x === null || $x === '') ? $x : '(oculto)', $v);
return ($v === null || $v === '') ? $v : '(oculto)';
}
if (is_array($v)) {
$out = [];
foreach ($v as $k => $x) $out[$k] = platform_audit_redact($x, is_string($k) ? $k : $key);
return $out;
}
return $v;
}
/**
* O que mudou entre duas versões de um registo: [campo => ['from' => …, 'to' => …]].
* Compara como texto, para um '1' da base de dados e um 1 do formulário não
* contarem como mudança. $ignore: campos a não comparar (updated_at, …).
*/
function platform_audit_diff(array $before, array $after, array $ignore = ['updated_at', 'created_at']): array
{
$out = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $k) {
if (in_array($k, $ignore, true)) continue;
$a = $before[$k] ?? null;
$b = $after[$k] ?? null;
if ((string) (is_scalar($a) || $a === null ? $a : json_encode($a)) === (string) (is_scalar($b) || $b === null ? $b : json_encode($b))) continue;
$out[$k] = ['from' => $a, 'to' => $b];
}
return $out;
}
/** Os campos de um registo novo, na forma do diff (a partir de nada). Vazios ficam de fora. */
function platform_audit_created(array $fields): array
{
$out = [];
foreach ($fields as $k => $v) {
if ($v === null || $v === '') continue;
$out[$k] = ['from' => null, 'to' => $v];
}
return $out;
}
/**
* Regista uma ação. Nunca lança.
*
* $d: target_type, target_id, target_label, summary, changes (array), e
* opcionalmente actor (['id','name','role'], ou false para «sistema»).
*/
function platform_audit(?PDO $pdo, string $action, array $d = []): void
{
if (!$pdo instanceof PDO) {
$pdo = function_exists('platform_pdo') ? platform_pdo() : null;
if (!$pdo instanceof PDO) return;
}
try {
$actor = array_key_exists('actor', $d) ? ($d['actor'] ?: null) : platform_audit_actor();
$changes = $d['changes'] ?? null;
if (is_array($changes)) {
$changes = $changes ? json_encode(platform_audit_redact($changes), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
}
$values = [
'actor_id' => $actor['id'] ?? null,
'actor_name' => $actor['name'] ?? null,
'actor_role' => $actor['role'] ?? null,
'action' => $action,
'target_type' => (string) ($d['target_type'] ?? ''),
'target_id' => isset($d['target_id']) ? (string) $d['target_id'] : null,
'target_label' => $d['target_label'] ?? null,
'summary' => $d['summary'] ?? null,
'changes' => $changes,
'ip' => platform_client_ip(),
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : null,
];
$cols = platform_audit_table_columns($pdo);
if (!$cols) return;
$insert = [];
foreach ($values as $field => $v) {
$col = platform_audit_column($field);
if (!isset($cols[$col])) continue; // o site não tem coluna para isto
$c = $cols[$col];
if ($v !== null && in_array($c['type'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
$v = is_numeric($v) ? (int) $v : null; // um id que não é número não cabe numa coluna INT
}
if (is_string($v) && $c['max'] !== null && mb_strlen($v) > $c['max']) {
$v = mb_substr($v, 0, $c['max']);
}
if ($v === null && $c['not_null']) {
// NOT NULL: com valor por omissão, omite-se e vale o dela; sem ele, vazio.
if (!$c['required']) continue;
$v = '';
}
$insert[$col] = $v;
}
// Colunas obrigatórias do site que nada preencheu (NOT NULL sem valor por omissão).
foreach ($cols as $col => $c) {
if ($c['required'] && !array_key_exists($col, $insert) && $col !== 'id') $insert[$col] = '';
}
$table = platform_audit_config()['table'];
$names = implode(', ', array_map(fn($c) => "`$c`", array_keys($insert)));
$marks = implode(', ', array_fill(0, count($insert), '?'));
$pdo->prepare("INSERT INTO `$table` ($names) VALUES ($marks)")->execute(array_values($insert));
platform_audit_maybe_prune($pdo);
} catch (Throwable $e) {
error_log('[audit] não registado (' . $action . '): ' . $e->getMessage());
}
}
/** Apaga as linhas mais velhas do que keep_days — no máximo uma vez por dia e por pedido. */
function platform_audit_maybe_prune(PDO $pdo): void
{
static $done = false;
if ($done) return;
$done = true;
$keep = platform_audit_config()['keep_days'];
if ($keep <= 0 || random_int(1, 200) !== 1) return; // barato: ~1 em 200 escritas
try {
$table = platform_audit_config()['table'];
$col = 'created_at';
$pdo->prepare("DELETE FROM `$table` WHERE `$col` < DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT 5000")->execute([$keep]);
} catch (Throwable $e) {
error_log('[audit] poda falhou: ' . $e->getMessage());
}
}
// ─── Leitura (a página audit.php) ───────────────────────────────────────────
/**
* Linhas do registo com os nomes canónicos, seja qual for a forma da tabela do
* site. Filtros: actor (texto), action (prefixo), target_type, target_id,
* from/to (Y-m-d), q (texto livre).
*/
function platform_audit_query(PDO $pdo, array $f = [], int $limit = 100, int $offset = 0): array
{
$cols = platform_audit_table_columns($pdo);
$table = platform_audit_config()['table'];
$sel = ['`id`', '`created_at`'];
$has = [];
foreach (array_keys(platform_audit_columns()) as $field) {
$col = platform_audit_column($field);
if (isset($cols[$col])) { $sel[] = "`$col` AS `$field`"; $has[$field] = "`$col`"; }
else { $sel[] = "NULL AS `$field`"; }
}
$where = []; $params = [];
if (($f['actor'] ?? '') !== '' && isset($has['actor_name'])) { $where[] = "{$has['actor_name']} LIKE ?"; $params[] = '%' . $f['actor'] . '%'; }
if (($f['action'] ?? '') !== '') { $where[] = "{$has['action']} LIKE ?"; $params[] = $f['action'] . '%'; }
if (($f['target_type'] ?? '') !== '' && isset($has['target_type'])) { $where[] = "{$has['target_type']} = ?"; $params[] = $f['target_type']; }
if (($f['target_id'] ?? '') !== '' && isset($has['target_id'])) { $where[] = "{$has['target_id']} = ?"; $params[] = $f['target_id']; }
if (($f['from'] ?? '') !== '') { $where[] = '`created_at` >= ?'; $params[] = $f['from'] . ' 00:00:00'; }
if (($f['to'] ?? '') !== '') { $where[] = '`created_at` <= ?'; $params[] = $f['to'] . ' 23:59:59'; }
if (($f['q'] ?? '') !== '') {
$like = [];
foreach (['target_label', 'summary', 'changes'] as $field) if (isset($has[$field])) $like[] = "{$has[$field]} LIKE ?";
if ($like) { $where[] = '(' . implode(' OR ', $like) . ')'; foreach ($like as $_) $params[] = '%' . $f['q'] . '%'; }
}
$sql = 'SELECT ' . implode(', ', $sel) . " FROM `$table`"
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY `created_at` DESC, `id` DESC LIMIT ' . max(1, min(1000, $limit)) . ' OFFSET ' . max(0, $offset);
$st = $pdo->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Os valores distintos de um campo, para os filtros (ações, tipos). */
function platform_audit_distinct(PDO $pdo, string $field, int $limit = 200): array
{
$cols = platform_audit_table_columns($pdo);
$col = platform_audit_column($field);
if (!isset($cols[$col])) return [];
$table = platform_audit_config()['table'];
return $pdo->query("SELECT DISTINCT `$col` FROM `$table` WHERE `$col` IS NOT NULL AND `$col` <> '' ORDER BY `$col` LIMIT " . (int) $limit)
->fetchAll(PDO::FETCH_COLUMN);
}
+19 -17
View File
@@ -21,6 +21,8 @@
require_once __DIR__ . '/functions_settings.php'; require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php'; require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
require_once __DIR__ . '/functions_connector.php';
// ─── Schema map ───────────────────────────────────────────────────────────── // ─── Schema map ─────────────────────────────────────────────────────────────
// //
@@ -30,24 +32,11 @@ require_once __DIR__ . '/functions_client_ip.php';
// is a function call. Sites converging on the standard names can simply not // is a function call. Sites converging on the standard names can simply not
// call auth_schema() at all. // call auth_schema() at all.
// The defaults live in functions_connector.php, which the conformance check
// reads too — one list of standard names, not two that drift apart.
function auth_schema(?array $map = null): array { function auth_schema(?array $map = null): array {
static $schema = [ static $schema = null;
'table' => 'users', $schema ??= platform_auth_schema_defaults();
'id' => 'id',
'email' => 'email',
// The column the login form matches on. null => use `email`, which is
// what five of the six sites do. quack authenticates by `username`
// instead, and still needs `email` to stay the address password
// recovery mails to -- so the two cannot be the same key.
'login' => null,
'password' => 'password_hash',
'name' => 'name',
'role' => 'role',
'active' => 'is_active', // null => no active flag on this site
'deleted_at' => null, // set when the site soft-deletes instead
'last_login' => 'last_login_at',
'last_ip' => 'last_login_ip',
];
if ($map !== null) $schema = array_merge($schema, $map); if ($map !== null) $schema = array_merge($schema, $map);
return $schema; return $schema;
} }
@@ -243,6 +232,9 @@ function auth_throttle_check(PDO $pdo, string $identifier, int $max = 5, int $wi
$count = (int) $row['c']; $count = (int) $row['c'];
$blocked = $count >= $max; $blocked = $count >= $max;
if ($blocked) {
platform_security_event($pdo, 'login_throttled', hub_security_mask_identifier($identifier), $ip);
}
return [ return [
'blocked' => $blocked, 'blocked' => $blocked,
'remaining' => max(0, $max - $count), 'remaining' => max(0, $max - $count),
@@ -261,6 +253,7 @@ function auth_throttle_hit(PDO $pdo, string $identifier): void {
} catch (Throwable $e) { } catch (Throwable $e) {
error_log('[auth] throttle record failed: ' . $e->getMessage()); error_log('[auth] throttle record failed: ' . $e->getMessage());
} }
platform_security_event($pdo, 'login_failed', hub_security_mask_identifier($identifier));
} }
function auth_throttle_clear(PDO $pdo, string $identifier): void { function auth_throttle_clear(PDO $pdo, string $identifier): void {
@@ -327,6 +320,11 @@ function auth_login(PDO $pdo, array $user): void {
]; ];
$_SESSION['_auth_seen'] = time(); $_SESSION['_auth_seen'] = time();
if (function_exists('platform_audit')) {
platform_audit($pdo, 'auth.login', ['target_type' => 'user', 'target_id' => (int) $user[$s['id']],
'target_label' => (string) ($user[$s['email']] ?? '')]);
}
$sets = []; $params = []; $sets = []; $params = [];
if ($s['last_login']) { $sets[] = "`{$s['last_login']}` = NOW()"; } if ($s['last_login']) { $sets[] = "`{$s['last_login']}` = NOW()"; }
if ($s['last_ip']) { $sets[] = "`{$s['last_ip']}` = ?"; $params[] = auth_client_ip(); } if ($s['last_ip']) { $sets[] = "`{$s['last_ip']}` = ?"; $params[] = auth_client_ip(); }
@@ -342,6 +340,10 @@ function auth_login(PDO $pdo, array $user): void {
} }
function auth_logout(bool $destroy = true): void { function auth_logout(bool $destroy = true): void {
// Antes de limpar a sessão: depois já não se sabe quem saiu.
if (function_exists('platform_audit') && !empty($_SESSION['user']['id'])) {
platform_audit(null, 'auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user']['id']]);
}
$_SESSION = []; $_SESSION = [];
if (ini_get('session.use_cookies')) { if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params(); $p = session_get_cookie_params();
@@ -528,6 +528,15 @@ function auto_update_run(PDO $pdo, string $repo_root, ?string $ref = null): arra
/** Persist the outcome so the UI can show it without re-running anything. */ /** Persist the outcome so the UI can show it without re-running anything. */
function auto_update_record(PDO $pdo, array $result, ?string $ref): void { function auto_update_record(PDO $pdo, array $result, ?string $ref): void {
if (function_exists('platform_audit')) {
// Sem sessão (webhook do Gitea) o autor é a própria atualização automática.
platform_audit($pdo, 'platform.auto_update', [
'actor' => platform_audit_actor() ?? ['id' => null, 'name' => 'atualização automática', 'role' => 'webhook'],
'target_type' => 'platform',
'summary' => (string) ($result['summary'] ?? '') . (empty($result['ok']) ? ' — falhou' : ''),
'changes' => ['status' => $result['status'] ?? null, 'ref' => $ref],
]);
}
try { try {
platform_settings_set($pdo, 'auto_update_last_at', gmdate('Y-m-d H:i:s')); platform_settings_set($pdo, 'auto_update_last_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'auto_update_last_ok', !empty($result['ok']) ? '1' : '0'); platform_settings_set($pdo, 'auto_update_last_ok', !empty($result['ok']) ? '1' : '0');
+155 -9
View File
@@ -15,6 +15,11 @@
// backup_trigger(string $type): bool ($type in {files, db}) // backup_trigger(string $type): bool ($type in {files, db})
// backup_type_label(string): string // backup_type_label(string): string
// backup_type_badge(string): string // backup_type_badge(string): string
// backup_retention_policy(): array
// backup_retention_summary(?array $policy = null): string
// backup_created_at(string $filename, int $fallback): int
// backup_gfs_keep(array $items, array $policy, int $now): array
// backup_prune(bool $dryRun = false): array
function backup_dir(): string { function backup_dir(): string {
$dir = dirname(__DIR__, 2) . '/backups'; $dir = dirname(__DIR__, 2) . '/backups';
@@ -113,26 +118,21 @@ function backup_trigger(string $type): bool {
// Where the self-call lands. Default assumes shared/ is inside the document // Where the self-call lands. Default assumes shared/ is inside the document
// root. Under a public/ docroot it is not, so hosts point this at a thin // root. Under a public/ docroot it is not, so hosts point this at a thin
// wrapper instead. Constant wins (fotografia defines it in // wrapper instead. Constant wins (fotografia defines it in
// config/functions_backup.php); .env covers hosts with no such file (surf). // config/functions_backup.php); .env covers hosts with no such file.
$prefix = defined('BACKUP_TRIGGER_URL_PREFIX') $prefix = defined('BACKUP_TRIGGER_URL_PREFIX')
? BACKUP_TRIGGER_URL_PREFIX ? BACKUP_TRIGGER_URL_PREFIX
: (function_exists('env') : (function_exists('env')
? ((string) (env('BACKUP_TRIGGER_URL_PREFIX') ?: 'shared/platform-tools/backups/')) ? ((string) (env('BACKUP_TRIGGER_URL_PREFIX') ?: 'shared/platform-tools/backups/'))
: 'shared/platform-tools/backups/'); : 'shared/platform-tools/backups/');
// Host base-URL helpers differ: fotografia and the config/-stack sites // base_url() when the host has one; APP_BASE_URL from .env otherwise, so a
// expose base_url(); surf exposes getBaseUrl(). Calling base_url() // host without the helper still works.
// unconditionally fataled with "undefined function" on the latter, before
// any backup could be triggered. Fall back to APP_BASE_URL from .env so a
// host with neither helper still works.
$path = ltrim($prefix . $script, '/'); $path = ltrim($prefix . $script, '/');
if (function_exists('base_url')) { if (function_exists('base_url')) {
$url = base_url($path); $url = base_url($path);
} elseif (function_exists('getBaseUrl')) {
$url = rtrim(getBaseUrl(), '/') . '/' . $path;
} else { } else {
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : (string) getenv('APP_BASE_URL'); $base = function_exists('env') ? (string) env('APP_BASE_URL', '') : (string) getenv('APP_BASE_URL');
if ($base === '') { if ($base === '') {
error_log('backup_trigger: no base URL available (no base_url(), no getBaseUrl(), no APP_BASE_URL).'); error_log('backup_trigger: no base URL available (no base_url(), no APP_BASE_URL).');
return false; return false;
} }
$url = rtrim($base, '/') . '/' . $path; $url = rtrim($base, '/') . '/' . $path;
@@ -165,6 +165,152 @@ function backup_trigger(string $type): bool {
return $ok; return $ok;
} }
// ─── Retenção avô-pai-filho ─────────────────────────────────────────────────
//
// Antes: tudo o que tivesse mais de BACKUP_KEEP_DAYS dias era apagado, e tudo o
// que tivesse menos ficava — incluindo cinco cópias do mesmo dia (a da noite, a
// de antes de atualizar, as do hub, as de segurança de um restauro). Num site
// com galerias são gigabytes por cópia.
//
// Agora, por tipo (db e ficheiros contam em separado):
// • tudo das últimas 24 h — são os pontos de restauro de uma atualização;
// • a mais recente de cada um dos últimos BACKUP_KEEP_DAYS dias com cópia;
// • a mais recente de cada uma das últimas BACKUP_KEEP_WEEKS semanas;
// • a mais recente de cada um dos últimos BACKUP_KEEP_MONTHS meses;
// • e a mais recente de todas, sempre.
//
// Cada escalão começa onde o anterior acaba: as semanais são semanas mais
// antigas do que a diária mais antiga, e as mensais meses mais antigos do que a
// semanal mais antiga. Sem isto a «mensal» era a cópia mais recente do mês
// corrente — que já é a diária de hoje — e BACKUP_KEEP_MONTHS=1 não guardava
// nada. Com 7/4/1 cobre-se cerca de dois meses.
//
// «Dias com cópia», e não «dias de calendário»: se as cópias pararem durante um
// mês, a poda não pode ficar sem nada para guardar — é exactamente quando as
// antigas são as únicas que há.
function backup_retention_policy(): array {
$read = static function (string $key, int $default): int {
if (defined($key)) return max(0, (int) constant($key));
$v = function_exists('env') ? env($key, null) : (getenv($key) !== false ? getenv($key) : null);
return ($v === null || $v === '') ? $default : max(0, (int) $v);
};
return [
'recent_hours' => 24,
'days' => $read('BACKUP_KEEP_DAYS', 7),
'weeks' => $read('BACKUP_KEEP_WEEKS', 4),
'months' => $read('BACKUP_KEEP_MONTHS', 1),
];
}
/**
* A data da cópia pelo nome do ficheiro, que não muda quando o ficheiro é
* copiado ou restaurado de outro disco — ao contrário do mtime.
*/
function backup_created_at(string $filename, int $fallback): int {
if (preg_match('/_(\d{2})_(\d{2})_(\d{4})_(\d{2})_(\d{2})(?:_(\d{2}))?(?:_[0-9a-f]{6})?\.zip$/', $filename, $m)) {
$ts = mktime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), (int) $m[2], (int) $m[1], (int) $m[3]);
if ($ts !== false) return $ts;
}
return $fallback;
}
/**
* Que cópias ficam. Função pura: recebe a lista (filename, type, created_at) e
* devolve [filename => motivo]. O que não vier na resposta é para apagar.
*/
function backup_gfs_keep(array $items, array $policy, int $now): array {
$tiers = [
'days' => ['Y-m-d', 'diária'],
'weeks' => ['o-\WW', 'semanal'],
'months' => ['Y-m', 'mensal'],
];
$byType = [];
foreach ($items as $it) $byType[$it['type']][] = $it;
$keep = [];
foreach ($byType as $list) {
usort($list, fn($a, $b) => $b['created_at'] <=> $a['created_at']);
$keep[$list[0]['filename']] = 'mais recente';
foreach ($list as $it) {
if ($now - (int) $it['created_at'] < $policy['recent_hours'] * 3600) {
$keep[$it['filename']] ??= 'últimas ' . $policy['recent_hours'] . ' h';
}
}
// $floor: a cópia mais antiga guardada pelo escalão anterior. O escalão
// seguinte só olha para o que é mais antigo, e salta o balde dela — esse
// período já está representado.
$floor = null;
foreach ($tiers as $tier => [$fmt, $label]) {
$skip = $floor === null ? null : date($fmt, $floor);
$buckets = [];
$oldest = null;
// Da mais recente para a mais antiga: a primeira que cai num balde é
// a mais recente desse dia/semana/mês.
foreach ($list as $it) {
$t = (int) $it['created_at'];
if ($floor !== null && $t >= $floor) continue;
if (count($buckets) >= $policy[$tier]) break;
$key = date($fmt, $t);
if ($key === $skip || isset($buckets[$key])) continue;
$buckets[$key] = true;
$keep[$it['filename']] ??= $label;
$oldest = $t;
}
if ($oldest !== null) $floor = $oldest;
}
}
return $keep;
}
/**
* Aplica a retenção à pasta de cópias. Com $dryRun não apaga nada — diz o que
* apagaria. Devolve ['policy', 'kept' => [[filename, reason]], 'deleted', 'errors'].
*/
function backup_prune(bool $dryRun = false): array {
$dir = backup_dir();
$policy = backup_retention_policy();
$now = time();
$items = [];
foreach (backup_list() as $b) {
$b['created_at'] = backup_created_at($b['filename'], (int) $b['created_at']);
$items[] = $b;
}
$keep = backup_gfs_keep($items, $policy, $now);
$out = ['policy' => $policy, 'kept' => [], 'deleted' => [], 'errors' => []];
foreach ($items as $b) {
if (isset($keep[$b['filename']])) {
$out['kept'][] = ['filename' => $b['filename'], 'reason' => $keep[$b['filename']]];
} elseif ($dryRun || @unlink($dir . '/' . $b['filename'])) {
$out['deleted'][] = $b['filename'];
} else {
$out['errors'][] = $b['filename'];
}
}
// .sql de dumps interrompidos antes de irem para o ZIP: texto em claro que
// não aparece na lista. Um dia chega para não apanhar um dump a decorrer.
foreach (glob($dir . '/db_backup_*.sql') ?: [] as $f) {
if (filemtime($f) < $now - 86400) {
if ($dryRun || @unlink($f)) $out['deleted'][] = basename($f);
else $out['errors'][] = basename($f);
}
}
return $out;
}
/** Linha legível da política, para a página e para o log dos workers. */
function backup_retention_summary(?array $policy = null): string {
$p = $policy ?? backup_retention_policy();
return sprintf('tudo das últimas %d h, e a mais recente de cada um dos últimos %d dias, %d semanas e %d meses',
$p['recent_hours'], $p['days'], $p['weeks'], $p['months']);
}
function backup_type_label(string $type): string { function backup_type_label(string $type): string {
return match($type) { return match($type) {
'db' => 'Base de dados', 'db' => 'Base de dados',
+839
View File
@@ -0,0 +1,839 @@
<?php
// ─── Captcha partilhado: uma pergunta escrita, para qualquer formulário ─────
//
// Um desafio que a pessoa lê e responde, para os sítios onde as defesas
// invisíveis não chegam. Serve qualquer formulário de qualquer site da frota:
// registo, recuperação de password, contacto, comentários, inscrições.
//
// ─── Porque não é o reCAPTCHA (nem o hCaptcha, nem o Turnstile) ─────────────
//
// Três razões, e nenhuma é gosto por fazer à mão:
//
// 1. O ASSETS.md fixou a 2026-09-01 que nada de front-end sai para a rede em
// produção. Um captcha de terceiros é exactamente isso, e ainda por cima
// num formulário que não abre sem ele: o serviço em baixo é o formulário
// em baixo.
// 2. Corre JavaScript de terceiros na página — incluindo, quando o captcha
// está no back-office, na sessão de quem está autenticado como admin.
// 3. Manda para fora o IP e o comportamento de cada visitante, o que num site
// europeu é uma decisão de RGPD que ninguém tomou, e não uma dependência
// técnica.
//
// O preço é assumido: isto trava scripts, não trava um humano pago para
// resolver desafios. Era igualmente verdade com o reCAPTCHA.
//
// ─── A relação com os dois módulos de anti-spam ─────────────────────────────
//
// São três coisas diferentes e complementares:
//
// functions_antispam.php honeypot + blocklist + limite de 24 h
// functions_antispam_forms.php pontuação do conteúdo de um formulário
// este ficheiro uma pergunta visível, com resposta certa
//
// Os dois primeiros são invisíveis e recusam em silêncio — de propósito, porque
// um bot não deve ficar a saber o que o apanhou e uma lead recusada por engano
// tem de poder ser recuperada do registo. Isso só funciona quando há conteúdo
// para pontuar e quando a recusa pode ser silenciosa.
//
// O captcha é para o resto: um formulário de registo ou de recuperação de
// password não tem texto livre para pontuar, e recusar em silêncio um pedido de
// password nova deixa a pessoa a carregar no botão para sempre. Aqui a pessoa é
// informada, e pode tentar outra vez.
//
// Usar os dois juntos é o normal e não é redundante: as armadilhas apanham o
// script que nem olha para a página, o captcha apanha o que olha.
//
// ─── O desenho ──────────────────────────────────────────────────────────────
//
// **Sem estado no servidor.** O desafio viaja num token assinado por HMAC: o
// carimbo de tempo, um nonce aleatório e a assinatura. A pergunta *e a resposta*
// são derivadas do nonce com a chave do site, portanto a resposta nunca sai
// daqui e não há nada para guardar entre pedidos. Nenhuma tabela, nenhuma sessão
// obrigatória, e funciona atrás de qualquer cache.
//
// **Uma pergunta, uma tentativa.** Havendo sessão, o nonce fica marcado como
// usado assim que é verificado — certo ou errado. É o que impede que alguém peça
// um desafio e experimente as vinte respostas possíveis com o mesmo token. Sem
// sessão activa degrada para "reutilizável dentro da janela", e é por isso que o
// ttl é curto por omissão.
//
// **Funciona sem JavaScript**, porque a regra 10 do PHILOSOPHY.md diz que a
// página funciona sem ele, e porque um leitor de ecrã lê uma pergunta escrita e
// não lê uma imagem distorcida. Pela mesma razão não há imagens: a resposta é
// aceite em algarismos ou por extenso, com ou sem acentos.
//
// ─── Ligar num site ─────────────────────────────────────────────────────────
//
// require_once __DIR__ . '/../shared/platform-tools/functions_captcha.php';
//
// // no formulário, dentro do <form>:
// echo platform_captcha_field('registo', $pdo);
//
// // no handler, depois do CSRF:
// if (($erro = platform_captcha_verify('registo', $pdo)) !== null) {
// $error = $erro; // já em português, pronto a mostrar
// }
//
// O primeiro argumento é o âmbito: entra na assinatura, para que um desafio
// colhido no formulário de contacto não sirva no de registo. Dois formulários na
// mesma página com âmbitos diferentes são independentes.
//
// API pública:
// platform_captcha_config(?array) — configuração do módulo
// platform_captcha_field(string, ?PDO) — o bloco HTML pronto a imprimir
// platform_captcha_issue(string, ?PDO) — pergunta + token, para marcação própria
// platform_captcha_verify(string, ?PDO) — null passa, ou a mensagem de erro
// platform_captcha_ok(string, ?PDO) — o mesmo em booleano
//
// E o recurso — a razão pela qual este módulo existe. Ver a secção no fim:
// platform_captcha_appeal_offer(PDO, form, reason) — guarda a submissão recusada
// platform_captcha_appeal_field(token, form, ?PDO) — o bloco do segundo ecrã
// platform_captcha_appeal_claim(PDO, form, &$error) — devolve a submissão libertada
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Configuração. Com um array define; sem argumentos, lê.
*/
function platform_captcha_config(?array $set = null): array
{
static $cfg = [
// 'math' (contas), 'letter' (letra de uma palavra) ou 'mixed' (sorteia
// uma das duas por desafio). Misturar é de graça e obriga quem
// automatiza a resolver dois formatos em vez de um.
'type' => 'mixed',
// Validade do desafio. Curta de propósito: sem sessão activa é ela que
// limita quantas respostas cabem num token. 15 minutos chegam para
// preencher um formulário e não chegam para um ataque com paciência.
'ttl' => 900,
// Nomes dos campos. Configuráveis porque um site pode já ter um campo
// chamado 'captcha' vindo de outra coisa.
'field_answer' => 'captcha_answer',
'field_token' => 'captcha_token',
// Classes do Bootstrap 5, que é o que toda a frota carrega. Um sítio sem
// Bootstrap passa strings vazias e estiliza por fora.
'class_wrap' => 'mb-3',
'class_label' => 'form-label',
'class_input' => 'form-control',
'class_help' => 'form-text',
'help' => 'Uma pergunta simples, para travar envios automáticos.',
'autocomplete' => 'off',
// Uma tentativa por pergunta, quando há sessão. Ver o cabeçalho.
'single_use' => true,
// Um token forjado ou em falta é sinal de bot e conta para a escalada do
// functions_ip_ban.php. Uma resposta errada NÃO conta, e a diferença é
// deliberada: a escada bane aos 3 registos em 24 h, e quem se engana
// duas vezes a escrever "catorze" não é um bot — seria banir gente por
// errar uma pergunta que nós próprios lhe fizemos.
'offence_forged' => true,
'offence_wrong' => false,
// ─── O recurso ──────────────────────────────────────────────────────
// Tabela própria, criada à cabeça como as do functions_ip_ban.php: a
// `blocked_submissions` tem forma diferente em cada site e é um registo,
// não uma fila de trabalho.
'appeal_table' => 'captcha_appeals',
// Duas horas para responder. É uma pessoa a olhar para o ecrã, não um
// formulário deixado aberto — e quanto mais curto, menos tempo a
// submissão recusada fica guardada.
'appeal_ttl' => 7200,
// Tentativas por recurso, e recursos por IP à hora. O primeiro impede
// que se esgotem as respostas possíveis numa só submissão retida; o
// segundo impede que a fila de recursos vire um depósito de spam.
'appeal_tries' => 6,
'appeal_max_ip' => 5,
// Motivos que NÃO dão direito a recurso. A recusa silenciosa existe
// para o bot não ficar a saber o que o apanhou, e estes dois não têm
// leitura inocente: o honeypot é um campo que ninguém vê, e o carimbo só
// é produzido pelo nosso próprio formulário. Oferecer recurso a quem
// falha ali é ensiná-lo a contornar. Todos os outros — país, limite por
// IP, pontuação de conteúdo, formulário expirado — têm falsos positivos
// reais e é para eles que isto serve.
'appeal_never' => ['honeypot', 'carimbo inválido'],
// Palavras do desafio da letra. Sem acentos, de propósito: a resposta é
// uma letra e não vale a pena pôr ninguém a decidir se escreve "a" ou
// "á".
'words' => [
'CADEIRA', 'JANELA', 'SAPATO', 'CAMINHO', 'MERCADO', 'PALAVRA',
'LARANJA', 'MONTANHA', 'TELEFONE', 'BICICLETA', 'ESCADA',
'FLORESTA', 'CAMISOLA', 'GUITARRA', 'PADEIRO', 'CADERNO',
],
];
if ($set !== null) {
$cfg = array_merge($cfg, $set);
}
return $cfg;
}
/**
* A chave que assina os desafios.
*
* Domínio próprio: um carimbo do anti-spam não valida aqui, nem ao contrário. A
* derivação e os seus três degraus estão em functions_settings.php, que é onde
* vive a única implementação.
*/
function platform_captcha_key(?PDO $pdo = null): string
{
return platform_derived_key('captcha', $pdo);
}
/**
* A pergunta e a resposta, derivadas do nonce.
*
* Determinista: o mesmo nonce, com a mesma chave e o mesmo âmbito, dá sempre o
* mesmo par. É isso que dispensa guardar seja o que for — o servidor volta a
* calcular a resposta no pedido seguinte em vez de se lembrar dela.
*
* Note-se que a resposta nunca vai para o cliente, nem sequer como hash: um hash
* de "14" quebra-se com vinte tentativas numa consola.
*
* @return array{question:string, answer:string}
*/
function platform_captcha_challenge(string $form, string $nonce, ?PDO $pdo = null): array
{
$cfg = platform_captcha_config();
$b = array_values(unpack(
'C*',
hash_hmac('sha256', 'q|' . $form . '|' . $nonce, platform_captcha_key($pdo), true)
));
$type = $cfg['type'];
if ($type === 'mixed') {
$type = ($b[0] % 2 === 0) ? 'math' : 'letter';
}
if ($type === 'letter') {
$words = array_values($cfg['words']);
$word = $words ? $words[$b[1] % count($words)] : 'CADEIRA';
// Até à quinta letra, e nunca além do tamanho da palavra.
$max = min(5, mb_strlen($word, 'UTF-8'));
$pos = 1 + ($b[2] % $max);
$ord = [1 => 'primeira', 2 => 'segunda', 3 => 'terceira', 4 => 'quarta', 5 => 'quinta'][$pos];
return [
'question' => 'Escreva a ' . $ord . ' letra da palavra ' . $word . '.',
'answer' => mb_substr($word, $pos - 1, 1, 'UTF-8'),
];
}
// Contas. Os limites dos operandos mantêm o resultado entre 0 e 20, que é o
// intervalo que platform_captcha_number_words() sabe escrever por extenso.
switch ($b[1] % 3) {
case 0:
$x = 1 + ($b[2] % 9);
$y = 1 + ($b[3] % 9);
$op = 'mais';
$r = $x + $y;
break;
case 1:
// O maior primeiro: um resultado negativo é uma pergunta injusta.
$x = 2 + ($b[2] % 8);
$y = 1 + ($b[3] % ($x - 1));
$op = 'menos';
$r = $x - $y;
break;
default:
$x = 2 + ($b[2] % 4);
$y = 2 + ($b[3] % 3);
$op = 'vezes';
$r = $x * $y;
}
// Um operando por extenso e o outro em algarismos, sorteados. Custa nada e
// parte o regex /(\d+)\s*\+\s*(\d+)/ com que se resolve um captcha destes em
// três linhas.
$words = platform_captcha_number_words();
$left = ($b[4] % 2 === 0) ? (string) $x : $words[$x];
$right = ($b[4] % 2 === 0) ? $words[$y] : (string) $y;
// "duas vezes três", não "dois vezes três": quem conta ali são as vezes, e
// "vez" é feminino. Só acontece com o operando da esquerda de uma
// multiplicação, e só quando calha ser escrito por extenso. A resposta não
// muda com isto — platform_captcha_canonical_answer() já lê "duas" como 2.
if ($op === 'vezes' && $left === 'dois') {
$left = 'duas';
}
return [
'question' => 'Quanto é ' . $left . ' ' . $op . ' ' . $right . '?',
'answer' => (string) $r,
];
}
/** 0–20 por extenso, já na forma normalizada (minúsculas, sem acentos). */
function platform_captcha_number_words(): array
{
return [
0 => 'zero', 'um', 'dois', 'tres', 'quatro', 'cinco', 'seis', 'sete',
'oito', 'nove', 'dez', 'onze', 'doze', 'treze', 'catorze', 'quinze',
'dezasseis', 'dezassete', 'dezoito', 'dezanove', 'vinte',
];
}
/**
* Normaliza uma resposta: minúsculas, sem acentos, sem nada que não seja letra
* ou algarismo. "Catorze", "catorze " e "CATORZE" são a mesma resposta.
*/
function platform_captcha_normalize(string $s): string
{
$s = mb_strtolower(trim($s), 'UTF-8');
$s = strtr($s, [
'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'î' => 'i',
'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'ñ' => 'n',
]);
return (string) preg_replace('/[^a-z0-9]+/', '', $s);
}
/**
* A resposta de quem preencheu, posta na mesma forma que a nossa: por extenso
* vira algarismos, e as variantes do Brasil são aceites porque quem as escreve
* respondeu certo.
*/
function platform_captcha_canonical_answer(string $raw): string
{
$n = platform_captcha_normalize($raw);
$alias = [
'uma' => 'um', 'duas' => 'dois', 'quatorze' => 'catorze',
'dezesseis' => 'dezasseis', 'dezessete' => 'dezassete', 'dezenove' => 'dezanove',
];
$n = $alias[$n] ?? $n;
$words = array_flip(platform_captcha_number_words());
if (isset($words[$n])) {
return (string) $words[$n];
}
// "07" e "7" são a mesma resposta.
if ($n !== '' && ctype_digit($n)) {
return (string) (int) $n;
}
return $n;
}
/**
* Emite um desafio: a pergunta, o token e os nomes dos campos.
*
* Para quem quer a sua própria marcação — um formulário em grelha, um modal, um
* passo de um assistente. Quem só quer um campo normal usa
* platform_captcha_field(), que é isto com HTML à volta.
*
* @return array{question:string, token:string, field_answer:string, field_token:string, id:string}
*/
function platform_captcha_issue(string $form = 'form', ?PDO $pdo = null): array
{
$cfg = platform_captcha_config();
$ts = time();
$nonce = bin2hex(random_bytes(9));
$sig = platform_captcha_sign($form, $ts, $nonce, $pdo);
$ch = platform_captcha_challenge($form, $nonce, $pdo);
return [
'question' => $ch['question'],
'token' => $ts . '.' . $nonce . '.' . $sig,
'field_answer' => $cfg['field_answer'],
'field_token' => $cfg['field_token'],
// Único por instância: a mesma página pode ter dois captchas, e dois
// <label for="captcha"> mandam o clique sempre para o primeiro.
'id' => 'captcha-' . substr($nonce, 0, 8),
];
}
function platform_captcha_sign(string $form, int $ts, string $nonce, ?PDO $pdo = null): string
{
return hash_hmac('sha256', 't|' . $form . '|' . $ts . '|' . $nonce, platform_captcha_key($pdo));
}
/**
* O bloco pronto a imprimir dentro de um <form>.
*
* Sem JavaScript, sem imagens e sem dependências: um <label>, um <input> de
* texto e um campo escondido. `inputmode="numeric"` fica de fora de propósito —
* metade dos desafios pede uma letra, e um teclado numérico num campo onde se
* escreve "d" é pior do que nenhum.
*/
function platform_captcha_field(string $form = 'form', ?PDO $pdo = null): string
{
$cfg = platform_captcha_config();
$c = platform_captcha_issue($form, $pdo);
$esc = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$cls = static fn(string $k): string => $cfg[$k] !== '' ? ' class="' . $esc($cfg[$k]) . '"' : '';
$html = '<div' . $cls('class_wrap') . ' data-captcha="' . $esc($form) . '">'
. '<label for="' . $esc($c['id']) . '"' . $cls('class_label') . '>' . $esc($c['question']) . '</label>'
. '<input type="text" id="' . $esc($c['id']) . '" name="' . $esc($c['field_answer']) . '"'
. ' value="" required autocomplete="' . $esc($cfg['autocomplete']) . '"'
. ' autocapitalize="off" spellcheck="false"' . $cls('class_input') . '>'
. '<input type="hidden" name="' . $esc($c['field_token']) . '" value="' . $esc($c['token']) . '">';
if ($cfg['help'] !== '') {
$html .= '<div' . $cls('class_help') . '>' . $esc($cfg['help']) . '</div>';
}
return $html . '</div>';
}
/**
* Verifica a resposta. Devolve null quando passa, ou a mensagem a mostrar.
*
* **Falha fechada**, ao contrário do portão de país: um token que não chegou é
* um token inválido. O captcha só existe se a ausência dele for uma recusa —
* caso contrário bastava não enviar o campo. É por isso que um site que se
* esqueça de imprimir o campo vê o formulário recusado em vez de aberto: o erro
* aparece ao primeiro teste, em vez de ficar uma defesa desligada em silêncio.
*
* A mensagem devolvida é para mostrar a quem preencheu, já em português e já
* genérica: nunca diz qual das verificações falhou.
*/
function platform_captcha_verify(string $form = 'form', ?PDO $pdo = null): ?string
{
$cfg = platform_captcha_config();
$expired = 'A verificação expirou. Responda à nova pergunta e envie outra vez.';
$wrong = 'A resposta à pergunta de verificação não está certa. Tente de novo.';
$token = (string) ($_POST[$cfg['field_token']] ?? '');
$parts = explode('.', $token);
if (count($parts) !== 3) {
platform_captcha_offence($pdo, $form, 'token em falta ou malformado', (bool) $cfg['offence_forged']);
return $expired;
}
[$ts, $nonce, $sig] = $parts;
if (!ctype_digit($ts) || !ctype_xdigit($nonce) || !ctype_xdigit($sig)
|| !hash_equals(platform_captcha_sign($form, (int) $ts, $nonce, $pdo), $sig)) {
platform_captcha_offence($pdo, $form, 'token forjado', (bool) $cfg['offence_forged']);
return $expired;
}
// 60 s de folga para o futuro: o carimbo é feito no servidor, mas um relógio
// que ande atrasado depois de um restauro não há-de recusar toda a gente até
// alguém reparar.
$age = time() - (int) $ts;
if ($age > (int) $cfg['ttl'] || $age < -60) {
return $expired;
}
if (platform_captcha_used($nonce, false)) {
return $expired;
}
// Marcado antes de comparar, e não depois: é o que torna cada pergunta uma
// única tentativa. Marcar só em caso de sucesso deixava o token vivo para as
// vinte tentativas seguintes, que é precisamente o ataque.
platform_captcha_used($nonce, true, (int) $ts + (int) $cfg['ttl']);
$given = (string) ($_POST[$cfg['field_answer']] ?? '');
if (trim($given) === '') {
return 'Responda à pergunta de verificação.';
}
$ch = platform_captcha_challenge($form, $nonce, $pdo);
if (!hash_equals(
platform_captcha_canonical_answer($ch['answer']),
platform_captcha_canonical_answer($given)
)) {
platform_captcha_offence($pdo, $form, 'resposta errada', (bool) $cfg['offence_wrong']);
return $wrong;
}
return null;
}
/** O mesmo, em booleano, para quem já tem a sua própria mensagem. */
function platform_captcha_ok(string $form = 'form', ?PDO $pdo = null): bool
{
return platform_captcha_verify($form, $pdo) === null;
}
/**
* Nonces já verificados, guardados na sessão.
*
* Usa apenas a sessão que já estiver aberta — **não** chama session_start(). Numa
* página pública isso punha um cookie a toda a gente que passasse por um
* formulário, o que muda a política de cookies do site e estraga qualquer cache
* à frente. Sem sessão esta defesa não corre, e fica o ttl curto a limitar.
*/
function platform_captcha_used(string $nonce, bool $mark, int $expiresAt = 0): bool
{
$cfg = platform_captcha_config();
if (!$cfg['single_use'] || session_status() !== PHP_SESSION_ACTIVE) {
return false;
}
$now = time();
$used = $_SESSION['platform_captcha_used'] ?? [];
if (!is_array($used)) {
$used = [];
}
// Limpeza a cada passagem: sem isto a sessão cresce para sempre num site com
// um captcha no rodapé.
$used = array_filter($used, static fn($exp): bool => (int) $exp > $now);
$seen = isset($used[$nonce]);
if ($mark) {
$used[$nonce] = $expiresAt > 0 ? $expiresAt : $now + 900;
// Tecto: um bot com cookies pediria mil desafios e enchia a sessão.
if (count($used) > 60) {
$used = array_slice($used, -60, null, true);
}
}
$_SESSION['platform_captcha_used'] = $used;
return $seen;
}
/**
* Dá memória às falhas, quando há base de dados e o functions_ip_ban.php está
* carregado. Silencioso e sem consequências quando não há — um captcha não há-de
* exigir tabelas para poder fazer uma pergunta.
*/
function platform_captcha_offence(?PDO $pdo, string $form, string $detail, bool $enabled): void
{
if (!$enabled || !($pdo instanceof PDO) || !function_exists('platform_ip_record_offence')) {
return;
}
platform_ip_record_offence($pdo, 'captcha:' . mb_substr($form, 0, 28), $detail, platform_client_ip());
}
// ─── O recurso: uma segunda oportunidade a quem foi recusado ────────────────
//
// O problema concreto, e é o que fez nascer este módulo: o portão de país
// recusa quem chega de fora de Portugal, e isso apanha duas pessoas que não são
// spam nenhum — quem usa o iCloud Private Relay (a Apple faz sair o tráfego por
// endereços que as listas da ipdeny atribuem a outro país) e os emigrantes.
// Ambos recebiam a página de obrigado e ficavam à espera de uma resposta que
// nunca vinha, porque a submissão tinha sido deitada fora em silêncio.
//
// A recusa silenciosa continua a ser a coisa certa para um bot. O que estava
// errado era não haver caminho nenhum de volta para uma pessoa. Então:
//
// 1. a submissão recusada é guardada inteira, com a razão;
// 2. em vez da página de obrigado, a pessoa vê uma pergunta;
// 3. respondendo certo, a submissão original é libertada e o site trata-a
// como se tivesse passado à primeira.
//
// Três decisões que vale a pena não desfazer sem pensar:
//
// **Nem todos os motivos dão recurso.** O honeypot e o carimbo forjado ficam de
// fora (ver `appeal_never`): não têm falso positivo possível, e oferecer ali uma
// segunda tentativa é explicar a um bot o que o apanhou.
//
// **A submissão fica no servidor, não no browser.** O segundo ecrã só devolve a
// resposta ao desafio. Reenviar o conteúdo num campo escondido deixava editá-lo
// depois de ter sido pontuado — passava-se uma mensagem limpa na primeira volta
// e o texto verdadeiro na segunda.
//
// **O recurso não é preso ao IP.** Seria o reflexo natural e estragava
// exactamente o caso que isto veio resolver: o Private Relay muda o endereço de
// saída entre pedidos, e a pessoa voltaria com um IP diferente daquele com que
// foi recusada. O que prende o recurso é a assinatura do token.
/**
* A fila de recursos. Criada à cabeça, como as tabelas do functions_ip_ban.php:
* um site não há-de precisar de uma migração para poder desfazer um engano.
*/
function platform_captcha_appeal_ensure_table(PDO $pdo): bool
{
static $done = null;
if ($done !== null) {
return $done;
}
$cfg = platform_captcha_config();
$table = (string) $cfg['appeal_table'];
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
error_log('[captcha] nome de tabela inválido, recurso desligado: ' . $table);
return $done = false;
}
try {
$pdo->exec("
CREATE TABLE IF NOT EXISTS `{$table}` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`form` VARCHAR(40) NOT NULL,
`reason` VARCHAR(190) NOT NULL,
`payload` MEDIUMTEXT NOT NULL,
`ip` VARCHAR(45) NULL,
`attempts` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`claimed_at` DATETIME NULL,
KEY `idx_captcha_appeals_ip` (`ip`, `created_at`),
KEY `idx_captcha_appeals_age` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
return $done = true;
} catch (Throwable $e) {
// Sem tabela não há recurso, e o site comporta-se como antes: recusa
// em silêncio. Uma segunda oportunidade que falha não pode derrubar a
// primeira defesa.
error_log('[captcha] tabela de recursos indisponível: ' . $e->getMessage());
return $done = false;
}
}
/** Este motivo admite recurso? Ver `appeal_never` na configuração. */
function platform_captcha_appeal_allowed(string $reason): bool
{
$lower = mb_strtolower($reason, 'UTF-8');
foreach (platform_captcha_config()['appeal_never'] as $needle) {
if ($needle !== '' && str_contains($lower, mb_strtolower((string) $needle, 'UTF-8'))) {
return false;
}
}
return true;
}
/**
* Guarda a submissão recusada e devolve o token do recurso — ou null quando não
* há recurso a oferecer, e aí o site faz o que já fazia.
*
* Chamar logo a seguir a uma recusa, com a razão que a recusa devolveu:
*
* if (($reason = platform_antispam_check($pdo, 'contacto', ...)) !== null) {
* $appeal = platform_captcha_appeal_offer($pdo, 'contacto', $reason);
* // $appeal !== null -> mostrar o desafio (ver platform_captcha_appeal_field)
* // $appeal === null -> página de obrigado, como sempre
* }
*
* O payload por omissão é o $_POST inteiro menos os campos de mecanismo. Um site
* que prefira guardar só o que lhe interessa passa o array que quiser.
*/
function platform_captcha_appeal_offer(
PDO $pdo,
string $form,
string $reason,
?array $payload = null
): ?string {
$cfg = platform_captcha_config();
if (!platform_captcha_appeal_allowed($reason) || !platform_captcha_appeal_ensure_table($pdo)) {
return null;
}
$table = (string) $cfg['appeal_table'];
$ip = platform_client_ip();
$payload ??= platform_captcha_appeal_payload();
try {
// Limpeza oportunista. Os recursos são raros, portanto sai barato fazê-la
// aqui e não há mais um cron para alguém se esquecer de instalar.
$pdo->prepare("DELETE FROM `{$table}` WHERE created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)")
->execute([max(3600, (int) $cfg['appeal_ttl'] * 2)]);
if ($ip !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)");
$st->execute([$ip]);
if ((int) $st->fetchColumn() >= (int) $cfg['appeal_max_ip']) {
// Já teve as oportunidades que havia para dar. Sem recurso, e
// sem dizer porquê.
return null;
}
}
$pdo->prepare("INSERT INTO `{$table}` (form, reason, payload, ip) VALUES (?, ?, ?, ?)")
->execute([
mb_substr($form, 0, 40),
mb_substr($reason, 0, 190),
(string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$ip,
]);
$id = (int) $pdo->lastInsertId();
} catch (Throwable $e) {
error_log('[captcha] recurso não registado: ' . $e->getMessage());
return null;
}
return $id . '.' . platform_captcha_appeal_sign($id, $form, $pdo);
}
/**
* O que fica guardado de uma submissão retida: o $_POST sem os campos de
* mecanismo — o token CSRF, as armadilhas do anti-spam e o próprio captcha.
*
* Nenhum deles tem sentido depois de a submissão ser libertada (o carimbo vai
* estar velho, o honeypot vazio), e guardá-los era guardar um token válido
* numa tabela durante duas horas sem precisar.
*/
function platform_captcha_appeal_payload(?array $post = null): array
{
$cfg = platform_captcha_config();
$payload = $post ?? $_POST;
unset(
$payload['csrf_token'], $payload['_pt_csrf'], $payload['antispam_token'],
$payload['form_sig'], $payload['form_js'], $payload['form_ts'], $payload['website'],
$payload[$cfg['field_answer']], $payload[$cfg['field_token']], $payload['captcha_appeal']
);
return $payload;
}
function platform_captcha_appeal_sign(int $id, string $form, ?PDO $pdo = null): string
{
return hash_hmac('sha256', 'a|' . $form . '|' . $id, platform_derived_key('captcha-appeal', $pdo));
}
/**
* O bloco do segundo ecrã: o desafio mais o token do recurso.
*
* O token tem de sobreviver a uma resposta errada, por isso vai num campo
* escondido e é reimpresso a cada tentativa — com um desafio novo, porque o
* anterior ficou gasto.
*/
function platform_captcha_appeal_field(string $token, string $form = 'form', ?PDO $pdo = null): string
{
return '<input type="hidden" name="captcha_appeal" value="'
. htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">'
. platform_captcha_field($form, $pdo);
}
/** O token do recurso que veio no POST, para reimprimir o ecrã. */
function platform_captcha_appeal_token(): string
{
return (string) ($_POST['captcha_appeal'] ?? '');
}
/**
* Tenta libertar a submissão retida. Três respostas possíveis:
*
* null e $error null — não havia recurso em curso; seguir o fluxo normal
* null e $error com texto — havia, e falhou: reimprimir o ecrã com a mensagem
* array — libertada. É o $_POST original, para processar
* exactamente como se tivesse passado à primeira.
*
* O site continua a decidir o que fazer com ela — criar a lead, abrir o ticket,
* enviar o email. Este módulo não sabe o que aquele formulário significa, e não
* é aqui que isso se decide.
*/
function platform_captcha_appeal_claim(PDO $pdo, string $form = 'form', ?string &$error = null): ?array
{
$error = null;
$cfg = platform_captcha_config();
$token = platform_captcha_appeal_token();
if ($token === '') {
return null;
}
$expired = 'Este pedido já não está disponível. Volte a preencher o formulário, por favor.';
$parts = explode('.', $token);
if (count($parts) !== 2 || !ctype_digit($parts[0]) || !ctype_xdigit($parts[1])
|| !hash_equals(platform_captcha_appeal_sign((int) $parts[0], $form, $pdo), $parts[1])) {
platform_captcha_offence($pdo, $form, 'recurso forjado', (bool) $cfg['offence_forged']);
$error = $expired;
return null;
}
if (!platform_captcha_appeal_ensure_table($pdo)) {
$error = $expired;
return null;
}
$id = (int) $parts[0];
$table = (string) $cfg['appeal_table'];
try {
$st = $pdo->prepare(
"SELECT payload, attempts, ip, created_at FROM `{$table}`
WHERE id = ? AND form = ? AND claimed_at IS NULL
AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)"
);
$st->execute([$id, mb_substr($form, 0, 40), (int) $cfg['appeal_ttl']]);
$row = $st->fetch(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log('[captcha] recurso não lido: ' . $e->getMessage());
$error = $expired;
return null;
}
// Já libertado, já expirado, ou nunca existiu: a mesma resposta para os três.
if (!$row) {
$error = $expired;
return null;
}
if ((int) $row['attempts'] >= (int) $cfg['appeal_tries']) {
// Gastou as tentativas. Fecha-se o recurso em vez de o deixar aberto a
// ser martelado até uma resposta calhar certa.
try {
$pdo->prepare("UPDATE `{$table}` SET claimed_at = NOW() WHERE id = ?")->execute([$id]);
} catch (Throwable) { /* a janela fecha-a de qualquer maneira */ }
$error = $expired;
return null;
}
try {
$pdo->prepare("UPDATE `{$table}` SET attempts = attempts + 1 WHERE id = ?")->execute([$id]);
} catch (Throwable $e) {
error_log('[captcha] tentativa não contada: ' . $e->getMessage());
}
if (($why = platform_captcha_verify($form, $pdo)) !== null) {
$error = $why; // resposta errada: o recurso continua de pé
return null;
}
$payload = json_decode((string) $row['payload'], true);
if (!is_array($payload)) {
error_log('[captcha] payload do recurso ' . $id . ' ilegível');
$error = $expired;
return null;
}
try {
$pdo->prepare("UPDATE `{$table}` SET claimed_at = NOW() WHERE id = ?")->execute([$id]);
} catch (Throwable $e) {
error_log('[captcha] recurso ' . $id . ' libertado mas não marcado: ' . $e->getMessage());
}
platform_captcha_appeal_forgive($pdo, $form, (string) ($row['ip'] ?? ''), (string) $row['created_at']);
return $payload;
}
/**
* Apaga as infracções que a recusa desta pessoa gerou.
*
* Sem isto o recurso era uma armadilha: cada tentativa de contacto somava uma
* infracção, e a escada do functions_ip_ban.php bane aos três registos em 24 h —
* ou seja, a terceira vez que um utilizador do Private Relay tentasse falar
* connosco ficava sem acesso ao site, depois de nós próprios lhe termos
* confirmado que era humano.
*
* **Não levanta banimentos**, e é a única parte disto que é uma escolha e não
* uma consequência. Um captcha que desfizesse um banimento seria um oráculo:
* resolve-se um desafio e recupera-se o acesso, o que é um alvo bem mais
* apetecível do que um formulário de contacto. Quem já está banido fala com uma
* pessoa, pelo email do ecrã de 403.
*/
function platform_captcha_appeal_forgive(PDO $pdo, string $form, string $ip, string $since): void
{
if ($ip === '' || $since === '') {
return;
}
try {
// Só as infracções daquele formulário, daquele endereço, desde o momento
// em que a submissão foi retida. O historial anterior fica de pé.
$pdo->prepare(
"DELETE FROM ip_offences
WHERE ip = ? AND kind IN (?, ?) AND created_at >= DATE_SUB(?, INTERVAL 5 SECOND)"
)->execute([$ip, 'antispam:' . $form, 'captcha:' . $form, $since]);
} catch (Throwable $e) {
// Tabela por criar (o site não usa o ip_ban) ou apagada: não é motivo
// para não libertar a submissão, que é o que a pessoa veio buscar.
error_log('[captcha] infracções do recurso não limpas: ' . $e->getMessage());
}
}
+33 -12
View File
@@ -13,10 +13,21 @@
// //
// A regra: toda a quinta está atrás do Nginx Proxy Manager, por isso o // A regra: toda a quinta está atrás do Nginx Proxy Manager, por isso o
// REMOTE_ADDR é sempre o contentor do proxy (172.18.0.2) e nunca o visitante. // REMOTE_ADDR é sempre o contentor do proxy (172.18.0.2) e nunca o visitante.
// O X-Forwarded-For tem o valor certo, mas é um cabeçalho -- qualquer cliente o // Só se acredita nos cabeçalhos do proxy quando TRUSTED_PROXY=true E o pedido
// escreve. Só se acredita nele quando TRUSTED_PROXY=true diz que há mesmo um // chega mesmo de uma rede privada (o proxy); de fora, são forjáveis.
// proxy à frente a reescrevê-lo; caso contrário era uma identidade forjável, e //
// qualquer limite por IP passava a valer zero. // E acredita-se no cabeçalho CERTO. O NPM faz
// proxy_set_header X-Real-IP $remote_addr;
// proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
// ou seja, X-Real-IP é sempre reescrito com o IP que o proxy viu, e o
// X-Forwarded-For é ACRESCENTADO ao que o cliente mandou. Esta função usava o
// primeiro valor do X-Forwarded-For — o que o cliente escreve. Com
// "X-Forwarded-For: 1.2.3.4" cada pedido tinha o IP que o atacante quisesse, e
// o throttle, o IP ban e o registo de auditoria contavam identidades inventadas
// (encontrado na auditoria do fitme3070, 25/09/2026). Agora:
// 1. X-Real-IP, se for um IP válido;
// 2. senão o ÚLTIMO valor do X-Forwarded-For (o que o proxy acrescentou);
// 3. senão REMOTE_ADDR.
if (!function_exists('platform_client_ip')) { if (!function_exists('platform_client_ip')) {
/** /**
@@ -28,16 +39,26 @@ if (!function_exists('platform_client_ip')) {
? filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN) ? filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN)
: filter_var(getenv('TRUSTED_PROXY') ?: 'false', FILTER_VALIDATE_BOOLEAN); : filter_var(getenv('TRUSTED_PROXY') ?: 'false', FILTER_VALIDATE_BOOLEAN);
if ($trusted && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
// O primeiro da lista é o cliente; os seguintes são os proxies por $remoteOk = $remote !== '' && filter_var($remote, FILTER_VALIDATE_IP);
// onde passou, e o NPM acrescenta-se ao fim. // Proxy = endereço privado/reservado (rede Docker, LAN, loopback).
$first = trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR'])[0]); $fromProxy = $remoteOk
if (filter_var($first, FILTER_VALIDATE_IP)) { && !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
return $first;
if ($trusted && $fromProxy) {
$real = trim((string) ($_SERVER['HTTP_X_REAL_IP'] ?? ''));
if ($real !== '' && filter_var($real, FILTER_VALIDATE_IP)) {
return $real;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$hops = array_map('trim', explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']));
$last = end($hops);
if ($last !== false && filter_var($last, FILTER_VALIDATE_IP)) {
return $last;
}
} }
} }
$ip = $_SERVER['REMOTE_ADDR'] ?? null; return $remoteOk ? $remote : null;
return ($ip && filter_var($ip, FILTER_VALIDATE_IP)) ? $ip : null;
} }
} }
@@ -0,0 +1,427 @@
<?php
// ─── Conector: uma chamada para ligar uma app ao platform-tools ─────────────
//
// Até aqui o platform-tools adivinhava em que site estava: procurava um $pdo
// global, depois \App\Database (surf), depois um base_url(), um getBaseUrl(), um
// APP_BASE_URL. Cada ferramenta nova tinha de se lembrar das variantes. Com o
// conector é o site que DECLARA, uma vez, no seu arranque:
//
// require_once __DIR__ . '/../shared/platform-tools/functions_connector.php';
// platform_connect($pdo, [
// 'base_url' => APP_BASE_URL,
// 'schema' => ['name' => 'full_name'], // só o que difere
// 'roles' => ['staff' => 40, 'client' => 20],
// 'remember' => true,
// 'contact_email' => 'geral@exemplo.pt',
// ]);
//
// e o conector liga a autenticação partilhada, o «manter sessão», o bloqueio de
// IP e os eventos de segurança, e fica com o que as outras ferramentas precisam
// de saber (PDO, URL, ambiente, guarda, layout).
//
// CONFORMIDADE
//
// platform_conformance() compara a base de dados com o que o código partilhado
// espera. Só procura avarias SILENCIOSAS — as que não dão erro nenhum e deixam
// uma defesa desligada. O exemplo que o motivou: o auth_attempts da fotografia
// tem `ip, scope` e não `identifier`; o throttle partilhado falharia a consulta
// e, por desenho, deixaria entrar — o throttle desligava-se sem ninguém saber.
//
// Em desenvolvimento, platform_connect() recusa-se a arrancar com um erro de
// conformidade (excepto nas páginas de sistema e na API, onde está o runner de
// migrações que o corrige). Em produção nunca: vai para o health do hub, como o
// check `conformance`.
//
// Este ficheiro só declara funções — o functions_auth.php inclui-o para ler os
// nomes padrão das colunas, e não pode arrastar nada consigo.
require_once __DIR__ . '/functions_routes.php';
// platform_audit() fica disponível a todo o site ligado (e às páginas e à API
// do platform-tools). Só declara funções — nada corre ao incluir.
require_once __DIR__ . '/functions_audit.php';
const PLATFORM_DEV_ENVS = ['dev', 'development', 'local', 'staging', 'test'];
class PlatformConformanceError extends RuntimeException {}
/** Os nomes padrão da tabela de contas. auth_schema() parte daqui. */
function platform_auth_schema_defaults(): array {
return [
'table' => 'users',
'id' => 'id',
'email' => 'email',
// A coluna do formulário de login. null => `email`. A quack entra por
// `username` e continua a precisar de `email` para a recuperação.
'login' => null,
'password' => 'password_hash',
'name' => 'name',
'role' => 'role',
'active' => 'is_active', // null => o site não tem esta coluna
'deleted_at' => null, // o site apaga por soft-delete
'last_login' => 'last_login_at',
'last_ip' => 'last_login_ip',
];
}
// ─── Registo ─────────────────────────────────────────────────────────────────
function platform_config(?array $set = null): array {
static $cfg = [];
if ($set !== null) $cfg = $set;
return $cfg;
}
function platform_connected(): bool {
return platform_config() !== [];
}
function platform_pdo(): ?PDO {
$pdo = platform_config()['pdo'] ?? null;
return $pdo instanceof PDO ? $pdo : null;
}
function platform_env(): string {
$env = platform_config()['env'] ?? null;
if ($env === null || $env === '') {
$env = function_exists('env') ? (string) env('APP_ENV', '') : (string) ($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: '');
}
// Sem APP_ENV é produção: o lado seguro, em que nada rebenta de propósito.
return strtolower(trim((string) $env)) ?: 'prod';
}
function platform_is_dev(): bool {
return in_array(platform_env(), PLATFORM_DEV_ENVS, true);
}
// ─── Ligação ─────────────────────────────────────────────────────────────────
/**
* Liga o site ao platform-tools. Chamar uma vez, no arranque, depois de abrir o
* PDO. $pdo null é uma montra estática: não há auth, bloqueio nem eventos.
*
* Opções (todas opcionais):
* base_url URL absoluto da app. Por omissão APP_BASE_URL.
* env 'prod' | 'dev' | … Por omissão APP_ENV; sem ele, 'prod'.
* auth 'shared' (functions_auth.php) ou 'own' (o site tem a sua).
* schema o que difere de platform_auth_schema_defaults().
* roles papéis próprios => nível (os de base são sysadmin/admin/user).
* session opções de auth_session_start(); false = o site abre a sua.
* remember true para repor sessões do «manter sessão iniciada»; ou as
* opções de auth_remember_resume() ('login', 'allow').
* A caixa e o olho do formulário: functions_login_ui.php.
* ip_guard false desliga o bloqueio de IP à porta.
* contact_email o email mostrado a quem for bloqueado por engano.
* guard callable que exige sysadmin (as páginas partilhadas).
* layout ['header' => …, 'navbar' => …, 'footer' => …].
* conformance 'auto' (recusa em dev) ou 'report' (só reporta).
* conformance_exempt fragmentos de caminho onde dev não recusa.
* router true quando o site tem o public/platform.php (router.php):
* os links para as páginas partilhadas passam a ir por lá.
* (O public/api/platform.php — api/router.php — é o mesmo
* para a API.)
* clean_urls true quando o .htaccess do site serve `/platform` a partir de
* `platform.php`: os URLs do router saem sem `.php`.
* api_files implementação própria de um endpoint: [nome => ficheiro].
* audit o registo de auditoria (functions_audit.php): ['table' =>
* 'audit_log', 'map' => [campo canónico => coluna do site],
* 'keep_days' => 730]. Só o que difere; sem nada, a tabela
* canónica `audit_log`, criada no primeiro uso.
* dashboard_url, migrations_dir, sysadmin_tools, hide_tools, page_vars —
* o que o router passa às páginas; ver router.php.
*/
function platform_connect(?PDO $pdo, array $cfg = []): void {
if (platform_connected()) return;
$cfg += [
'base_url' => null,
'env' => null,
'auth' => 'shared',
'schema' => [],
'roles' => [],
'session' => [],
'remember' => false,
'ip_guard' => true,
'contact_email' => null,
'guard' => null,
'layout' => [],
'conformance' => 'auto',
// O runner de migrações vive nas páginas de sistema, e o health na API:
// recusar lá deixava o site sem a ferramenta que o conserta.
'conformance_exempt' => ['/api/', '/sysadmin/', '/setup.php', '/platform.php'],
'router' => false,
'clean_urls' => false,
];
if (!in_array($cfg['auth'], ['shared', 'own'], true)) {
throw new InvalidArgumentException("platform_connect: auth tem de ser 'shared' ou 'own'");
}
$cfg['pdo'] = $pdo;
platform_config($cfg);
if ($cfg['auth'] === 'shared') {
require_once __DIR__ . '/functions_auth.php';
if ($cfg['schema']) auth_schema($cfg['schema']);
if ($cfg['roles']) auth_roles_register($cfg['roles']);
if ($cfg['session'] !== false) auth_session_start((array) $cfg['session']);
}
if (!$pdo instanceof PDO) return;
if ($cfg['conformance'] === 'auto' && platform_is_dev()) {
platform_conformance_enforce($pdo);
}
if ($cfg['ip_guard']) {
require_once __DIR__ . '/functions_ip_ban.php';
platform_ip_guard($pdo, $cfg['contact_email']);
}
if ($cfg['auth'] === 'shared' && $cfg['remember']) {
require_once __DIR__ . '/functions_remember.php';
// Um array em vez de true são as opções de auth_remember_resume() — o
// gancho 'login' de um site que guarda mais na sessão (a silver:
// full_name e avatar), ou o 'allow' de quem tem regras próprias.
auth_remember_resume($pdo, is_array($cfg['remember']) ? $cfg['remember'] : []);
}
}
function platform_conformance_enforce(PDO $pdo): void {
if (PHP_SAPI === 'cli') return;
$r = platform_conformance($pdo);
if ($r['ok']) return;
$errors = array_values(array_filter($r['issues'], fn($i) => $i['level'] === 'error'));
$lines = [];
foreach ($errors as $i) {
$lines[] = '- ' . $i['message'] . ($i['fix'] !== null ? "\n " . $i['fix'] : '');
}
$msg = "platform-tools: a base de dados não está conforme (" . count($errors) . " erro(s)).\n"
. implode("\n", $lines)
. "\nAplique as correcções numa migração (ou mapeie a coluna em auth_schema()).";
$path = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?? '');
foreach ((array) (platform_config()['conformance_exempt'] ?? []) as $frag) {
if ($frag !== '' && str_contains($path, (string) $frag)) {
error_log('[conformance] ' . str_replace("\n", ' | ', $msg));
return;
}
}
throw new PlatformConformanceError($msg);
}
// ─── Conformidade ────────────────────────────────────────────────────────────
/**
* As tabelas que o código partilhado escreve. `required`: colunas que ele lê ou
* escreve, com a definição para as acrescentar. `optional`: detectadas em tempo
* de execução, a falta não parte nada. `insert`: o que ele põe num INSERT — uma
* coluna NOT NULL sem valor por omissão fora desta lista faz o INSERT falhar.
*
* Todas são criadas no primeiro uso quando não existem; o problema é só quando
* existem com outra forma.
*/
function platform_standard_tables(): array {
return [
'auth_attempts' => [
'required' => [
'identifier' => "VARCHAR(190) NOT NULL DEFAULT ''",
'ip' => 'VARCHAR(45) NULL',
'attempted_at' => 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
],
'optional' => ['success' => 'TINYINT(1) NOT NULL DEFAULT 0'],
'insert' => ['identifier', 'ip'],
'why' => 'o throttle de login partilhado deixa entrar quando a consulta falha',
],
'auth_remember_tokens' => [
'required' => [
'user_id' => 'BIGINT UNSIGNED NOT NULL',
'selector' => 'CHAR(32) NOT NULL',
'validator_hash' => 'CHAR(64) NOT NULL',
'expires_at' => 'DATETIME NOT NULL',
'last_used_at' => 'DATETIME NULL',
'last_ip' => 'VARCHAR(45) NULL',
'user_agent' => 'VARCHAR(255) NULL',
],
'optional' => [],
'insert' => ['user_id', 'selector', 'validator_hash', 'expires_at', 'last_ip', 'user_agent'],
'why' => '«manter sessão iniciada» não emite nem repõe sessões',
],
'ip_bans' => [
'required' => [
'ip' => 'VARCHAR(45) NOT NULL',
'reason' => 'VARCHAR(190) NOT NULL',
'source' => "VARCHAR(40) NOT NULL DEFAULT 'auto'",
'expires_at' => 'DATETIME NULL',
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
'created_by' => 'INT NULL',
'hits' => 'INT NOT NULL DEFAULT 0',
],
'optional' => [],
'insert' => ['ip', 'reason', 'source', 'expires_at', 'created_by'],
'why' => 'o bloqueio de IP falha aberto — ninguém é banido',
],
'ip_offences' => [
'required' => [
'ip' => 'VARCHAR(45) NOT NULL',
'kind' => 'VARCHAR(40) NOT NULL',
'detail' => 'VARCHAR(190) NULL',
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
],
'optional' => [],
'insert' => ['ip', 'kind', 'detail'],
'why' => 'as infracções não se registam e a escalada de banimento nunca começa',
],
'system_settings' => [
'required' => [
'setting_key' => 'VARCHAR(100) NOT NULL',
'setting_value' => 'TEXT NULL',
'updated_by' => 'INT NULL',
],
'optional' => [],
'insert' => ['setting_key', 'setting_value', 'updated_by'],
'why' => 'as definições partilhadas e o emparelhamento com o hub não gravam',
],
];
}
/**
* Lê o esquema e avalia. Só leitura — nunca cria nem altera nada. Uma consulta.
* Devolve ['ok' => bool, 'issues' => [['level','table','message','fix'], …]].
*/
function platform_conformance(PDO $pdo): array {
static $cache = null;
if ($cache !== null) return $cache;
$schema = function_exists('auth_schema') ? auth_schema() : platform_auth_schema_defaults();
$tables = array_merge(array_keys(platform_standard_tables()), [$schema['table']]);
$in = implode(',', array_fill(0, count($tables), '?'));
$st = $pdo->prepare(
"SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ($in)"
);
$st->execute($tables);
$columns = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$columns[$c['TABLE_NAME']][$c['COLUMN_NAME']] = $c;
}
$cfg = platform_config();
return $cache = platform_conformance_evaluate($columns, $schema, [
'connected' => $cfg !== [],
'auth' => $cfg['auth'] ?? null,
'app_env' => function_exists('env') ? (string) env('APP_ENV', '') : (string) ($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: ''),
'env' => platform_env(),
]);
}
/**
* A avaliação, sem base de dados. $columns: [tabela => [coluna => linha do
* information_schema]]. Separada para se poder testar com o esquema de cada site.
*/
function platform_conformance_evaluate(array $columns, array $schema, array $ctx): array {
$issues = [];
$add = static function (string $level, string $table, string $message, ?string $fix = null) use (&$issues): void {
$issues[] = ['level' => $level, 'table' => $table, 'message' => $message, 'fix' => $fix];
};
// ── Tabelas partilhadas ─────────────────────────────────────────────────
foreach (platform_standard_tables() as $table => $spec) {
if (!isset($columns[$table])) continue; // criada no primeiro uso, já com a forma certa
$have = $columns[$table];
foreach ($spec['required'] as $col => $def) {
if (!isset($have[$col])) {
$add('error', $table, "$table sem a coluna `$col` — {$spec['why']}",
"ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` $def;");
}
}
foreach ($spec['optional'] as $col => $def) {
if (!isset($have[$col])) {
$add('info', $table, "$table sem a coluna opcional `$col`",
"ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` $def;");
}
}
// Uma coluna antiga NOT NULL e sem valor por omissão faz falhar os INSERT
// do código partilhado — em modo estrito, um erro que ele engole.
foreach ($have as $col => $c) {
if (in_array($col, $spec['insert'], true)) continue;
$extra = strtolower((string) $c['EXTRA']);
if ($c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
&& !str_contains($extra, 'auto_increment') && !str_contains($extra, 'generated')) {
$add('error', $table, "$table.`$col` é NOT NULL sem valor por omissão — os INSERT partilhados falham ({$spec['why']})",
"ALTER TABLE `$table` MODIFY `$col` {$c['COLUMN_TYPE']} NULL;");
}
}
}
// ── Tabela de contas ────────────────────────────────────────────────────
$defaults = platform_auth_schema_defaults();
$table = (string) $schema['table'];
$users = $columns[$table] ?? null;
if ($users === null) {
$add('error', $table, "tabela de contas `$table` não existe", null);
} else {
// O que se pode acrescentar sem mexer em código; o resto mapeia-se.
$addable = [
'active' => 'TINYINT(1) NOT NULL DEFAULT 1',
'last_login' => 'DATETIME NULL',
'last_ip' => 'VARCHAR(45) NULL',
];
foreach (['id', 'email', 'login', 'password', 'name', 'role', 'active', 'deleted_at', 'last_login', 'last_ip'] as $key) {
$col = $schema[$key] ?? null;
if ($col === null || isset($users[$col])) continue;
$fix = isset($addable[$key]) && $col === $defaults[$key]
? "ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` {$addable[$key]}; -- ou auth_schema(['$key' => null])"
: "auth_schema(['$key' => '<coluna deste site>'])";
$add('error', $table, "$table sem a coluna `$col` (chave `$key`)", $fix);
}
}
// Os desvios mapeados não partem nada: ficam à vista para convergirem.
foreach ($defaults as $key => $std) {
$mine = $schema[$key] ?? null;
if ($mine === $std) continue;
if ($mine === null) {
$add('info', $table, "sem `$std` (desligado em auth_schema(['$key' => null]))");
} elseif ($std === null) {
$add('info', $table, "`$key` => `$mine` (extensão deste site)");
} else {
$add('info', $table, "`$mine` no lugar de `$std` (mapeado em auth_schema())");
}
}
// ── Ligação ─────────────────────────────────────────────────────────────
if (empty($ctx['connected'])) {
$add('info', '', 'ainda não chama platform_connect()');
} elseif (($ctx['auth'] ?? null) === 'own') {
$add('warn', '', 'autenticação própria — sem o endurecimento de sessão, o rehash e o throttle partilhados');
}
if (($ctx['app_env'] ?? '') === '') {
$add('warn', '', 'APP_ENV não definido — tratado como produção');
}
$ok = !array_filter($issues, fn($i) => $i['level'] === 'error');
return ['ok' => $ok, 'issues' => $issues];
}
/** `checks.conformance` para o health do hub. */
function platform_conformance_check(PDO $pdo): array {
$r = platform_conformance($pdo);
$count = ['error' => 0, 'warn' => 0, 'info' => 0];
foreach ($r['issues'] as $i) $count[$i['level']]++;
$first = null;
foreach ($r['issues'] as $i) {
if ($i['level'] === 'error') { $first = $i['message']; break; }
}
return [
'ok' => $r['ok'],
'detail' => $count['error'] . ' erro(s), ' . $count['warn'] . ' aviso(s), ' . $count['info'] . ' nota(s)'
. ($first !== null ? ' — ' . $first : ''),
'errors' => $count['error'],
'warnings' => $count['warn'],
'issues' => $r['issues'],
];
}
+9 -1
View File
@@ -34,5 +34,13 @@ function platform_csrf_ok(): bool {
} }
$sent = (string) ($_POST['_pt_csrf'] ?? ''); $sent = (string) ($_POST['_pt_csrf'] ?? '');
$have = (string) ($_SESSION['platform_tools_csrf'] ?? ''); $have = (string) ($_SESSION['platform_tools_csrf'] ?? '');
return $sent !== '' && $have !== '' && hash_equals($have, $sent); $ok = $sent !== '' && $have !== '' && hash_equals($have, $sent);
if (!$ok) {
// Uma sessão expirada também cai aqui; é no hub, com a frota inteira à
// vista, que um padrão se distingue de um formulário esquecido aberto.
require_once __DIR__ . '/functions_security_events.php';
platform_security_event(null, 'csrf_failed',
(string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''));
}
return $ok;
} }
+129
View File
@@ -249,6 +249,110 @@ function geo_ip_is_internal(string $ip): bool
); );
} }
// ─── Duas maneiras de não bloquear quem não devia ser bloqueado ─────────────
//
// O portão por país compara o endereço com as alocações da ipdeny, e isso
// pressupõe uma coisa que já não é verdade para toda a gente: que o endereço de
// saída fica no país de quem navega. Dois casos em que não fica, e ambos
// custaram leads reais:
//
// 1. **iCloud Private Relay.** O tráfego do Safari sai por endereços da Apple
// (e dos parceiros dela), registados onde a Apple os registou e não onde
// está a pessoa. Alguém em Aveiro aparece-nos como estrangeiro, é recusado
// em silêncio, e fica à espera de uma resposta que nunca vem.
// 2. **VPNs de empresa, o escritório do cliente, um serviço de monitorização.**
// Casos que ninguém adivinha de antemão e que aparecem um de cada vez.
//
// Daí duas listas, com propósitos diferentes:
//
// GEO_ALLOW_IPS à mão, para o caso concreto que apareceu hoje.
// data/geo/icloud-relay.* automática, e com o país lá dentro.
//
// A diferença da segunda é que **não é uma whitelist**: a Apple publica, para
// cada gama de saída, o país que ela serve. Um utilizador de Relay em Portugal
// passa a contar como PT; um em França continua a contar como FR e o portão
// recusa-o na mesma. Uma whitelist cega às gamas do Relay abria o portão a
// qualquer subscritor do iCloud+ no mundo inteiro, que é precisamente o que o
// portão existe para não fazer.
/** Gamas que passam sempre, escritas à mão. Vazio = não há lista. */
function geo_allow_ips(): array
{
$raw = (string) geo_env('GEO_ALLOW_IPS', '');
$out = [];
foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) {
$r = geo_cidr_to_range($entry);
if ($r !== null) {
$out[] = $r;
} else {
error_log('[geo] GEO_ALLOW_IPS: entrada inválida ignorada: ' . $entry);
}
}
return $out;
}
/**
* Está na lista escrita à mão?
*
* Percorre tudo em vez de procurar por bisseção, ao contrário das listas de
* país: isto tem meia dúzia de linhas escritas por uma pessoa, e ordenar um
* array de cinco elementos para poupar três comparações seria código a mais
* para quem vier a seguir.
*/
function geo_ip_allowlisted(?string $ip): bool
{
if ($ip === null || $ip === '') {
return false;
}
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
foreach (geo_allow_ips() as [$start, $end]) {
if (strlen($start) === strlen($packed)
&& strcmp($packed, $start) >= 0 && strcmp($packed, $end) <= 0) {
return true;
}
}
return false;
}
/** O Relay é para ter em conta? Desligar faz o portão voltar a vê-lo como estrangeiro. */
function geo_trust_relay(): bool
{
return filter_var(geo_env('GEO_TRUST_RELAY', 'true'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Este endereço é uma saída do Private Relay ao serviço deste país?
*
* As gamas vivem em `relay-<cc>.v4|.v6`, no mesmo formato e lidas pelo mesmo
* código que as listas da ipdeny — daí o `geo_ip_in_country()` com um prefixo
* em vez de um leitor novo. Foram filtradas por país à nascença: a lista que a
* Apple publica traz 42 mil gamas IPv4 e 246 mil IPv6 (855 KB e 6,7 MB), e
* carregar isso a cada pedido para responder a uma pergunta sobre Portugal —
* 611 gamas — não se justifica.
*
* Sem ficheiro devolve false, e o portão comporta-se exactamente como antes de
* isto existir. É de propósito: uma lista por descarregar não pode ser motivo
* para deixar entrar alguém que o operador quis barrar.
*/
function geo_relay_serves(string $ip, string $cc): bool
{
return geo_ip_in_country($ip, 'relay-' . strtolower($cc));
}
/** Há listas do Relay instaladas para estes países? A página usa isto para avisar. */
function geo_relay_available(array $countries): bool
{
foreach ($countries as $cc) {
if (geo_ranges('relay-' . strtolower($cc), false) || geo_ranges('relay-' . strtolower($cc), true)) {
return true;
}
}
return false;
}
/** /**
* Why this address should be refused, or null when it may pass. * Why this address should be refused, or null when it may pass.
* *
@@ -271,6 +375,31 @@ function geo_block_reason(?string $ip = null): ?string
return null; return null;
} }
// A lista escrita à mão manda em tudo o resto, incluindo no fail-closed
// abaixo: é a única defesa que continua a funcionar quando os dados de país
// faltam, e é para isso que serve — desbloquear alguém agora, sem esperar
// por um download.
if (geo_ip_allowlisted($ip)) {
return null;
}
// Saída do Private Relay ao serviço de um país permitido. Corre antes das
// listas da ipdeny porque para uma gama da Apple a resposta delas é onde a
// Apple registou o endereço, que não diz nada sobre quem está a navegar.
//
// Uma saída que sirva outro país não é tratada à parte: cai no ciclo abaixo
// e é recusada como qualquer endereço estrangeiro. Isto **não** é uma
// whitelist do Relay — abrir todas as gamas dele deixava entrar qualquer
// subscritor do iCloud+ no mundo inteiro, que é precisamente o que o portão
// existe para não fazer.
if (geo_trust_relay()) {
foreach ($allowed as $cc) {
if (geo_relay_serves($ip, $cc)) {
return null;
}
}
}
// No usable data for any allowed country is a deployment fault, not a // No usable data for any allowed country is a deployment fault, not a
// visitor's. Say so in the log and let them through. // visitor's. Say so in the log and let them through.
$haveData = false; $haveData = false;
+43 -5
View File
@@ -194,24 +194,62 @@ function git_harden_repo(string $repo): array {
* the only thing thrown away is the phantom mode diff. `--init` also clones any * the only thing thrown away is the phantom mode diff. `--init` also clones any
* submodule freshly added upstream. * submodule freshly added upstream.
* *
* A submodule declared `ignore = all` in .gitmodules follows its branch instead
* (`--remote`) — see git_submodule_follows_branch().
*
* Safe and quiet when there are no submodules. * Safe and quiet when there are no submodules.
* *
* @return array{ok:bool,log:array<string>} * @return array{ok:bool,log:array<string>}
*/ */
function git_sync_submodules(string $repo): array { function git_sync_submodules(string $repo): array {
if (git_submodule_paths($repo) === []) { $paths = git_submodule_paths($repo);
if ($paths === []) {
return ['ok' => true, 'log' => ['(sem submódulos)']]; return ['ok' => true, 'log' => ['(sem submódulos)']];
} }
$log = []; $log = [];
$sync = git_run(['submodule', 'sync', '--recursive'], $repo); $sync = git_run(['submodule', 'sync', '--recursive'], $repo);
$log[] = 'git submodule sync --recursive → ' . ($sync['ok'] ? 'OK' : 'FALHOU'); $log[] = 'git submodule sync --recursive → ' . ($sync['ok'] ? 'OK' : 'FALHOU');
if (trim($sync['output']) !== '') $log[] = $sync['output']; if (trim($sync['output']) !== '') $log[] = $sync['output'];
$ok = $sync['ok'];
$upd = git_run(['submodule', 'update', '--init', '--recursive', '--force'], $repo); $pinned = $tracking = [];
$log[] = 'git submodule update --init --recursive --force → ' . ($upd['ok'] ? 'OK' : 'FALHOU'); foreach ($paths as $path) {
if (trim($upd['output']) !== '') $log[] = $upd['output']; if (git_submodule_follows_branch($repo, $path)) $tracking[] = $path;
else $pinned[] = $path;
}
return ['ok' => $sync['ok'] && $upd['ok'], 'log' => $log]; // Um submódulo com `ignore = all` segue o seu branch, não o commit gravado
// (é o que o .gitmodules da quack declara). Pô-lo no commit gravado era
// fazer a plataforma andar para trás a cada atualização.
foreach ([[$pinned, []], [$tracking, ['--remote']]] as [$group, $extra]) {
if (!$group) continue;
$args = array_merge(['submodule', 'update', '--init', '--recursive', '--force'], $extra, ['--'], $group);
$upd = git_run($args, $repo);
$log[] = 'git ' . implode(' ', $args) . ' → ' . ($upd['ok'] ? 'OK' : 'FALHOU');
if (trim($upd['output']) !== '') $log[] = $upd['output'];
$ok = $ok && $upd['ok'];
}
return ['ok' => $ok, 'log' => $log];
}
/**
* O submódulo segue o branch (`update --remote`) em vez do commit gravado? Sim
* quando o .gitmodules lhe põe `ignore = all`: o commit gravado deixa de ser
* vigiado, portanto só pode ser um ponto de partida, não uma versão a cumprir.
*/
function git_submodule_follows_branch(string $repo, string $path): bool {
$res = git_run(['config', '-f', '.gitmodules', '--get-regexp', '^submodule\..*\.path$'], $repo);
if (!$res['ok']) return false;
foreach (preg_split('/\R/', trim($res['output'])) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && $parts[1] === $path) {
$name = substr($parts[0], strlen('submodule.'), -strlen('.path'));
$ignore = git_run(['config', '-f', '.gitmodules', '--get', "submodule.$name.ignore"], $repo);
return $ignore['ok'] && trim($ignore['output']) === 'all';
}
}
return false;
} }
/** /**
+26 -31
View File
@@ -33,10 +33,10 @@
// //
// ─── Portabilidade entre stacks ────────────────────────────────────────────── // ─── Portabilidade entre stacks ──────────────────────────────────────────────
// //
// A frota não tem um só bootstrap: dance/design/fotografia/mstudio/quack usam o // Um site ligado por platform_connect() declara o PDO e o URL; os outros ainda
// stack `config/` (env(), setting(), base_url(), APP_NAME), a silver usa `src/` // são lidos do stack `config/` (env(), setting(), base_url(), APP_NAME). Nada
// com $_ENV e getBaseUrl(). Nada aqui chama esses helpers directamente — passa // aqui chama esses helpers directamente — passa tudo pelos acessores
// tudo pelos acessores hub_env()/hub_setting()/hub_base_url() abaixo, que // hub_env()/hub_setting()/hub_base_url() abaixo, que
// resolvem em **tempo de chamada** (não de carregamento: este ficheiro é // resolvem em **tempo de chamada** (não de carregamento: este ficheiro é
// incluído do topo do functions.php, quando metade deles ainda não existe) e // incluído do topo do functions.php, quando metade deles ainda não existe) e
// caem para os primitivos do platform-tools quando o site não os tem. // caem para os primitivos do platform-tools quando o site não os tem.
@@ -65,27 +65,15 @@ function hub_root(): string {
} }
/** /**
* O $pdo do site. Os endpoints abrem-no no bootstrap e deixam-no global. * O $pdo do site: o que declarou em platform_connect(), ou o global que o
* * config/db.php abre. Não escreve em $GLOBALS: o site é que manda no seu escopo.
* A surf/silver não tem $pdo global — a ligação pede-se à App\Database. Os
* endpoints do hub passam pelo hub_bootstrap.php, que já resolve isso, mas o
* widget do feedback é incluído no meio de uma página qualquer e não passa por
* lá: sem esta saída, lê as definições todas vazias e conclui que o site não
* está emparelhado. Não escreve em $GLOBALS: o site é que manda no seu escopo.
*/ */
function hub_pdo(): ?PDO { function hub_pdo(): ?PDO {
$pdo = $GLOBALS['pdo'] ?? null; // Um site ligado por platform_connect() já disse qual é — não se adivinha.
if ($pdo instanceof PDO) return $pdo; if (function_exists('platform_pdo') && ($declared = platform_pdo()) !== null) return $declared;
if (is_callable(['\App\Database', 'getConnection'])) { $pdo = $GLOBALS['pdo'] ?? null;
try { return $pdo instanceof PDO ? $pdo : null;
$c = \App\Database::getConnection();
if ($c instanceof PDO) return $c;
} catch (Throwable $e) {
error_log('hub_pdo: ' . $e->getMessage());
}
}
return null;
} }
function hub_env(string $key, string $default = ''): string { function hub_env(string $key, string $default = ''): string {
@@ -152,12 +140,14 @@ function hub_app_name(): string {
} }
/** /**
* URL absoluto desta plataforma. A silver não tem base_url(); tem getBaseUrl(). * URL absoluto desta plataforma: o declarado em platform_connect(), o
* O APP_BASE_URL é o último recurso e está em todos os .env da frota. * base_url() do site, ou o APP_BASE_URL, que está em todos os .env da frota.
*/ */
function hub_base_url(string $path = ''): string { function hub_base_url(string $path = ''): string {
$declared = function_exists('platform_config') ? (string) (platform_config()['base_url'] ?? '') : '';
if ($declared !== '') return rtrim($declared, '/') . ($path === '' ? '' : '/' . ltrim($path, '/'));
if (function_exists('base_url')) return (string) base_url($path); if (function_exists('base_url')) return (string) base_url($path);
$base = function_exists('getBaseUrl') ? (string) getBaseUrl() : hub_env('APP_BASE_URL', ''); $base = hub_env('APP_BASE_URL', '');
return rtrim($base, '/') . ($path === '' ? '' : '/' . ltrim($path, '/')); return rtrim($base, '/') . ($path === '' ? '' : '/' . ltrim($path, '/'));
} }
@@ -257,7 +247,9 @@ function _hub_verify_or_die(string $expected, string $missingMessage): void {
* hub mostra isso em vez de um botão que não faz nada. * hub mostra isso em vez de um botão que não faz nada.
*/ */
function hub_ops_actions(): array { function hub_ops_actions(): array {
return ['backup.list', 'backup.trigger', 'git.status', 'git.pull', 'migrations.list', 'migrations.apply_pending']; return ['backup.list', 'backup.trigger', 'git.status', 'git.pull', 'update.run', 'update.status',
'migrations.list', 'migrations.apply_pending',
'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'];
} }
// ─── Carregamento tardio dos helpers do site ───────────────────────────────── // ─── Carregamento tardio dos helpers do site ─────────────────────────────────
@@ -270,8 +262,13 @@ function hub_ops_actions(): array {
*/ */
function hub_load_backup_helpers(): void { function hub_load_backup_helpers(): void {
if (function_exists('backup_list')) return; if (function_exists('backup_list')) return;
foreach ([__DIR__ . '/functions_backup.php', // O wrapper do site PRIMEIRO: é lá que se define o BACKUP_TRIGGER_URL_PREFIX
hub_root() . '/config/functions_backup.php'] as $candidate) { // antes de incluir o partilhado (a fotografia manda a cópia para api/, porque
// sob o docroot public/ o shared/ não se alcança por HTTP). Carregar o
// partilhado primeiro saltava essa definição, e a cópia disparada pelo hub
// batia num 404 que o curl dá como sucesso.
foreach ([hub_root() . '/config/functions_backup.php',
__DIR__ . '/functions_backup.php'] as $candidate) {
if (is_file($candidate)) { require_once $candidate; return; } if (is_file($candidate)) { require_once $candidate; return; }
} }
} }
@@ -407,9 +404,7 @@ function hub_git_check(string $root): array {
/** Versão mostrada no hub: o commit curto, ou a data do ficheiro mais recente. */ /** Versão mostrada no hub: o commit curto, ou a data do ficheiro mais recente. */
function hub_version(string $root, array $gitCheck): string { function hub_version(string $root, array $gitCheck): string {
if (!empty($gitCheck['commit'])) return (string) $gitCheck['commit']; if (!empty($gitCheck['commit'])) return (string) $gitCheck['commit'];
// A silver não tem config/functions.php; tem src/config.php. Sem isto a foreach (['/config/functions.php', '/public/index.php'] as $rel) {
// versão vinha vazia justamente na plataforma que mais precisa de a mostrar.
foreach (['/config/functions.php', '/src/config.php', '/public/index.php'] as $rel) {
if (is_file($root . $rel)) return date('Y.m.d', (int) filemtime($root . $rel)); if (is_file($root . $rel)) return date('Y.m.d', (int) filemtime($root . $rel));
} }
return ''; return '';
@@ -25,8 +25,13 @@
// Nada disto se aplica a endereços internos: atrás do NPM o contentor fala // Nada disto se aplica a endereços internos: atrás do NPM o contentor fala
// consigo próprio para cron e health checks, e auto-banir-se seria uma avaria // consigo próprio para cron e health checks, e auto-banir-se seria uma avaria
// difícil de diagnosticar. // difícil de diagnosticar.
//
// Com o site emparelhado com o hub, os banimentos e as infracções seguem para lá
// e os banimentos dos outros sites chegam cá com source='fleet' — ver
// functions_security_events.php.
require_once __DIR__ . '/functions_client_ip.php'; require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
/** [infracções em 24h => minutos de banimento]. null = permanente. */ /** [infracções em 24h => minutos de banimento]. null = permanente. */
const IP_BAN_LADDER = [3 => 60, 5 => 1440, 8 => 10080, 12 => null]; const IP_BAN_LADDER = [3 => 60, 5 => 1440, 8 => 10080, 12 => null];
@@ -123,6 +128,13 @@ function platform_ip_ban(PDO $pdo, string $ip, string $reason, ?int $minutes = 6
expires_at = VALUES(expires_at), created_at = NOW(), created_by = VALUES(created_by)' expires_at = VALUES(expires_at), created_at = NOW(), created_by = VALUES(created_by)'
)->execute([$ip, mb_substr($reason, 0, 190), $source, $expires, $userId]); )->execute([$ip, mb_substr($reason, 0, 190), $source, $expires, $userId]);
error_log("[ip-ban] $ip banido ($source): $reason" . ($expires ? " até $expires" : ' permanentemente')); error_log("[ip-ban] $ip banido ($source): $reason" . ($expires ? " até $expires" : ' permanentemente'));
// As cópias da frota não voltam ao hub: ecoavam em ciclo.
if ($source !== 'fleet') {
platform_security_event($pdo, 'ip_ban', $reason, $ip, [
'source' => $source,
'expires_ts' => $minutes === null ? null : time() + $minutes * 60,
]);
}
return true; return true;
} catch (Throwable $e) { } catch (Throwable $e) {
error_log('[ip-ban] não conseguiu banir: ' . $e->getMessage()); error_log('[ip-ban] não conseguiu banir: ' . $e->getMessage());
@@ -134,6 +146,9 @@ function platform_ip_unban(PDO $pdo, string $ip): bool
{ {
try { try {
platform_ip_ban_ensure_tables($pdo); platform_ip_ban_ensure_tables($pdo);
// Levanta também na frota — incluindo quando isto era uma cópia vinda de
// outro site. Sem isso a sincronização seguinte voltava a pô-la cá.
platform_security_event($pdo, 'ip_unban', null, $ip);
return $pdo->prepare('DELETE FROM ip_bans WHERE ip = ?')->execute([$ip]); return $pdo->prepare('DELETE FROM ip_bans WHERE ip = ?')->execute([$ip]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log('[ip-ban] não conseguiu levantar: ' . $e->getMessage()); error_log('[ip-ban] não conseguiu levantar: ' . $e->getMessage());
@@ -161,6 +176,7 @@ function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = nu
platform_ip_ban_ensure_tables($pdo); platform_ip_ban_ensure_tables($pdo);
$pdo->prepare('INSERT INTO ip_offences (ip, kind, detail) VALUES (?, ?, ?)') $pdo->prepare('INSERT INTO ip_offences (ip, kind, detail) VALUES (?, ?, ?)')
->execute([$ip, mb_substr($kind, 0, 40), $detail === null ? null : mb_substr($detail, 0, 190)]); ->execute([$ip, mb_substr($kind, 0, 40), $detail === null ? null : mb_substr($detail, 0, 190)]);
platform_security_event($pdo, 'offence', $kind . ($detail !== null && $detail !== '' ? ' · ' . $detail : ''), $ip);
$st = $pdo->prepare('SELECT COUNT(*) FROM ip_offences WHERE ip = ? AND created_at >= NOW() - INTERVAL 1 DAY'); $st = $pdo->prepare('SELECT COUNT(*) FROM ip_offences WHERE ip = ? AND created_at >= NOW() - INTERVAL 1 DAY');
$st->execute([$ip]); $st->execute([$ip]);
@@ -194,6 +210,8 @@ function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = nu
*/ */
function platform_ip_guard(PDO $pdo, ?string $contactEmail = null): void function platform_ip_guard(PDO $pdo, ?string $contactEmail = null): void
{ {
hub_security_maybe_sync($pdo);
$ban = platform_ip_banned($pdo); $ban = platform_ip_banned($pdo);
if ($ban === null) { if ($ban === null) {
return; return;
@@ -0,0 +1,86 @@
<?php
// ─── Peças do formulário de entrada (partilhadas) ───────────────────────────
//
// O platform-tools dá a mecânica da autenticação (functions_auth.php,
// functions_remember.php) e cada site desenha a sua página de entrada — a
// marca e o layout são do site. O que NÃO deve variar de site para site são as
// duas peças que qualquer entrada da frota tem de ter, e é o que vive aqui:
//
// platform_password_field() — a palavra-passe com o olho para a mostrar
// platform_remember_field() — a caixa «Manter sessão iniciada»
//
// Antes disto, cada site tinha as suas (ou não tinha: a silver não tinha
// nenhuma das duas). Markup Bootstrap (input-group, form-check), que é o que as
// páginas de entrada da frota usam; o olho é um SVG embutido, sem depender dos
// ícones, e o script sai uma vez por página.
//
// Do lado do servidor, «manter sessão» é o auth_remember_issue() depois de
// entrar, o 'remember' => true no platform_connect() para repor a sessão, e o
// auth_remember_clear() ao sair — ver o README, «Connecting a site».
/**
* O campo da palavra-passe com o botão de a mostrar.
*
* $o: name ('password'), id ('password'), label ('Palavra-passe'; '' = sem
* etiqueta), autocomplete ('current-password' | 'new-password'), required
* (true), minlength (null), class (classes extra do <input>).
*/
function platform_password_field(array $o = []): string
{
static $scriptPrinted = false;
$o += ['name' => 'password', 'id' => 'password', 'label' => 'Palavra-passe',
'autocomplete' => 'current-password', 'required' => true, 'minlength' => null, 'class' => ''];
$e = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
$eye = '<svg class="pt-eye-open" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">'
. '<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.17 8a13 13 0 0 1 1.66-2.04C4.12 4.67 5.88 3.5 8 3.5s3.88 1.17 5.17 2.46A13 13 0 0 1 14.83 8q-.09.13-.2.3c-.3.46-.73 1.06-1.29 1.66C12 11.33 10.12 12.5 8 12.5s-3.88-1.17-5.17-2.46A13 13 0 0 1 1.17 8"/>'
. '<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/></svg>';
$eyeOff = '<svg class="pt-eye-closed" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="display:none">'
. '<path d="M13.36 11.24C15.07 9.72 16 8 16 8s-3-5.5-8-5.5a7 7 0 0 0-2.8.6l.77.77A6 6 0 0 1 8 3.5c2.12 0 3.88 1.17 5.17 2.46A13 13 0 0 1 14.83 8q-.09.13-.2.3c-.3.46-.73 1.06-1.29 1.66q-.18.18-.37.35z"/>'
. '<path d="M11.3 9.18a3.5 3.5 0 0 0-4.47-4.47l.82.82a2.5 2.5 0 0 1 2.83 2.83zm-2.94 1.47.82.82a3.5 3.5 0 0 1-4.47-4.47l.82.82a2.5 2.5 0 0 0 2.83 2.83"/>'
. '<path d="M3.35 5.47q-.27.24-.52.49A13 13 0 0 0 1.17 8l.2.3c.3.46.73 1.06 1.29 1.66C4.12 11.33 5.88 12.5 8 12.5c.72 0 1.4-.13 2.03-.36l.77.77A7 7 0 0 1 8 13.5C3 13.5 0 8 0 8s.94-1.73 2.64-3.24zm10.3 8.88-12-12 .7-.7 12 12z"/></svg>';
$html = '';
if ($o['label'] !== '') $html .= '<label class="form-label" for="' . $e($o['id']) . '">' . $e($o['label']) . '</label>';
$html .= '<div class="input-group">'
. '<input type="password" class="form-control ' . $e($o['class']) . '" id="' . $e($o['id']) . '" name="' . $e($o['name']) . '"'
. ' autocomplete="' . $e($o['autocomplete']) . '"'
. ($o['required'] ? ' required' : '')
. ($o['minlength'] ? ' minlength="' . (int) $o['minlength'] . '"' : '') . '>'
. '<button type="button" class="btn btn-outline-secondary" data-pt-reveal="' . $e($o['id']) . '"'
. ' aria-label="Mostrar a palavra-passe" aria-pressed="false" title="Mostrar a palavra-passe">' . $eye . $eyeOff . '</button>'
. '</div>';
if (!$scriptPrinted) {
$scriptPrinted = true;
// Um clique mostra, outro esconde. Volta sempre a esconder ao submeter,
// para o browser não oferecer guardar a palavra-passe como texto.
$html .= '<script>(function(){'
. 'document.addEventListener("click",function(ev){var b=ev.target.closest("[data-pt-reveal]");if(!b)return;'
. 'var i=document.getElementById(b.getAttribute("data-pt-reveal"));if(!i)return;var show=i.type==="password";'
. 'i.type=show?"text":"password";b.setAttribute("aria-pressed",show?"true":"false");'
. 'b.setAttribute("aria-label",show?"Esconder a palavra-passe":"Mostrar a palavra-passe");b.title=b.getAttribute("aria-label");'
. 'b.querySelector(".pt-eye-open").style.display=show?"none":"";b.querySelector(".pt-eye-closed").style.display=show?"":"none";});'
. 'document.addEventListener("submit",function(ev){ev.target.querySelectorAll("[data-pt-reveal]").forEach(function(b){'
. 'var i=document.getElementById(b.getAttribute("data-pt-reveal"));if(i)i.type="password";});},true);'
. '})();</script>';
}
return $html;
}
/**
* A caixa «Manter sessão iniciada». Desmarcada por omissão (e remarcada quando
* o formulário volta com um erro). O número de dias vem do mesmo sítio que o
* mecanismo usa, para o texto não mentir.
*/
function platform_remember_field(string $name = 'remember'): string
{
$days = function_exists('auth_remember_days') ? auth_remember_days() : 30;
$checked = !empty($_POST[$name]) ? ' checked' : '';
$id = 'pt-remember-' . preg_replace('/[^a-z0-9_-]/i', '', $name);
return '<div class="form-check">'
. '<input class="form-check-input" type="checkbox" name="' . htmlspecialchars($name) . '" id="' . $id . '" value="1"' . $checked . '>'
. '<label class="form-check-label" for="' . $id . '">Manter sessão iniciada</label>'
. '<div class="form-text">Durante ' . (int) $days . ' dias neste dispositivo. Não marque num computador partilhado.</div>'
. '</div>';
}
+2
View File
@@ -181,6 +181,8 @@ function platform_mail_send(array $msg, string $repo_root = '', ?array $override
$capped = platform_mail_rate_blocked($pdo); $capped = platform_mail_rate_blocked($pdo);
if ($capped !== null) { if ($capped !== null) {
error_log('[mail-rate] envio adiado: ' . $capped); error_log('[mail-rate] envio adiado: ' . $capped);
require_once __DIR__ . '/functions_security_events.php';
platform_security_event($pdo, 'mail_rate_limited', $capped, '');
return ['ok' => false, 'skipped' => false, 'rate_limited' => true, 'error' => $capped]; return ['ok' => false, 'skipped' => false, 'rate_limited' => true, 'error' => $capped];
} }
} }
@@ -191,6 +191,13 @@ function password_reset_consume(PDO $pdo, string $token, string $newPassword, ar
auth_session_regenerate(); auth_session_regenerate();
auth_throttle_clear($pdo, (string) ($user[$s['email']] ?? '')); auth_throttle_clear($pdo, (string) ($user[$s['email']] ?? ''));
// Uma palavra-passe reposta termina as sessões lembradas em todos os
// dispositivos: repor é o que se faz quando se suspeita de que alguém a tem.
// (O dance fazia-o à mão; passou a ser aqui, para toda a frota.)
require_once __DIR__ . '/functions_remember.php';
try { auth_remember_forget_user($pdo, (int) $user[$s['id']]); }
catch (Throwable $e) { error_log('[password_reset] remember: ' . $e->getMessage()); }
return ['ok' => true, 'error' => null, 'user_id' => (int) $user[$s['id']]]; return ['ok' => true, 'error' => null, 'user_id' => (int) $user[$s['id']]];
} }
@@ -0,0 +1,379 @@
<?php
// ─── "Manter sessão iniciada" — login persistente ────────────────────────────
//
// A sessão do PHP morre quando o browser fecha, e o garbage collector apaga-a
// do servidor bem antes disso — `session.gc_maxlifetime` são 24 minutos por
// omissão. Esticar o cookie da sessão para trinta dias não resolve: o cookie
// sobrevive, os dados do lado do servidor não, e o utilizador é expulso na
// mesma, agora sem perceber porquê. Um "lembrar-me" honesto precisa de estado
// próprio, guardado numa tabela, com validade própria.
//
// MECANISMO — selector/validator
//
// O cookie leva dois valores separados por dois pontos: `<selector>:<validator>`.
//
// selector procura a linha. Está em claro na base de dados e é UNIQUE, por
// isso a consulta é um índice, não uma varredura.
// validator prova que o cookie é mesmo aquele. Na base de dados só existe o
// SHA-256 dele.
//
// A razão para os separar é a mesma que faz guardar palavras-passe com hash:
// quem leve um dump da tabela não consegue forjar um cookie a partir dele. Um
// esquema de token único obrigaria a procurar pelo próprio segredo — ou o
// segredo fica em claro na base de dados, ou não há forma de o indexar. A
// comparação é feita com hash_equals(), não com ===, para não dar o resultado
// por temporização.
//
// ROTAÇÃO E DETECÇÃO DE ROUBO
//
// Cada utilização gasta o validator e emite outro. Um cookie roubado vale, por
// isso, até à próxima visita do dono — e quando o dono volta, apresenta um
// validator que já não bate certo com um selector que existe. Isso não acontece
// por acidente: é a assinatura de uma cópia em circulação. Nesse caso apagam-se
// TODOS os tokens da conta, o que corta o atacante e obriga o dono a entrar de
// novo. Preferimos o incómodo à sessão silenciosamente partilhada.
//
// O QUE ISTO NÃO É
//
// Não é uma sessão privilegiada. `auth_remember_active()` diz se a sessão
// actual nasceu de um cookie em vez de uma palavra-passe escrita agora, para
// que uma operação sensível (mudar palavra-passe, apagar conta) possa exigir
// autenticação fresca. Cabe a cada página decidir — este ficheiro só regista
// a proveniência.
require_once __DIR__ . '/functions_auth.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Nome do cookie. Prefixado por instalação para que dois sites no mesmo domínio
* (fotografia e formação são ambos *.andmore.pt) não se pisem: o cookie de um
* chegaria ao outro, que procuraria o selector na SUA tabela e não o
* encontraria — um logout sem explicação a cada troca de separador.
*/
function auth_remember_cookie_name(): string {
// Uma constante do site ganha ao .env: é como um site que já tinha cookie
// próprio antes deste módulo (o dance, `dsa_remember`) passa a usá-lo sem
// deitar fora as sessões lembradas que já existiam.
if (defined('AUTH_REMEMBER_COOKIE') && (string) AUTH_REMEMBER_COOKIE !== '') {
return (string) AUTH_REMEMBER_COOKIE;
}
$name = function_exists('env') ? (string) env('AUTH_REMEMBER_COOKIE', '') : '';
return $name !== '' ? $name : 'am_remember';
}
/** Validade, em dias. Trinta é o costume; abaixo de 1 desliga o mecanismo. */
function auth_remember_days(): int {
$d = function_exists('env') ? (int) env('AUTH_REMEMBER_DAYS', 30) : 30;
return $d > 0 ? $d : 30;
}
function auth_remember_ensure_table(PDO $pdo): void {
// Sem chave estrangeira para users: o nome da tabela de contas varia de site
// para site (é o que auth_schema() existe para resolver) e o tipo da coluna
// id também — INT UNSIGNED nuns, BIGINT noutros. Uma FK aqui seria uma
// promessa que este ficheiro não pode cumprir em todo o lado.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `auth_remember_tokens` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`selector` CHAR(32) NOT NULL,
`validator_hash` CHAR(64) NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME NULL,
`last_ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_remember_selector` (`selector`),
KEY `idx_remember_user` (`user_id`),
KEY `idx_remember_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/** Escreve (ou apaga, com $value = null) o cookie com os parâmetros certos. */
function auth_remember_set_cookie(?string $value, int $expires = 0): void {
if (headers_sent()) return; // nada a fazer, e um warning não ajudaria
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
setcookie(auth_remember_cookie_name(), (string) $value, [
'expires' => $expires,
'path' => '/',
'secure' => $https,
'httponly' => true,
// Lax e não Strict: com Strict, quem chega ao site por um link de fora
// (um email com a ligação para a plataforma) não traz o cookie e vê o
// formulário de login — exactamente a situação que isto devia evitar.
'samesite' => 'Lax',
]);
}
/** Parte o cookie nos seus dois campos. null se estiver ausente ou deformado. */
function auth_remember_parse_cookie(): ?array {
$raw = (string) ($_COOKIE[auth_remember_cookie_name()] ?? '');
if ($raw === '' || substr_count($raw, ':') !== 1) return null;
[$selector, $validator] = explode(':', $raw, 2);
if (strlen($selector) !== 32 || !ctype_xdigit($selector)) return null;
if (strlen($validator) !== 64 || !ctype_xdigit($validator)) return null;
return ['selector' => $selector, 'validator' => $validator];
}
/**
* Emite um token novo para $userId e manda o cookie.
*
* Chamado depois de uma autenticação bem sucedida, e só quando o utilizador
* pediu para ser lembrado.
*/
function auth_remember_issue(PDO $pdo, int $userId): void {
if ($userId <= 0) return;
auth_remember_ensure_table($pdo);
$selector = bin2hex(random_bytes(16)); // 32 hex
$validator = bin2hex(random_bytes(32)); // 64 hex
$expires = time() + auth_remember_days() * 86400;
try {
$pdo->prepare(
"INSERT INTO `auth_remember_tokens`
(`user_id`, `selector`, `validator_hash`, `expires_at`, `last_ip`, `user_agent`)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$userId,
$selector,
hash('sha256', $validator),
date('Y-m-d H:i:s', $expires),
platform_client_ip(),
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
} catch (Throwable $e) {
// Um login válido nunca deve falhar porque o "lembrar-me" não coube.
error_log('[auth/remember] issue failed: ' . $e->getMessage());
return;
}
auth_remember_set_cookie($selector . ':' . $validator, $expires);
// Barato, e evita que a tabela cresça para sempre numa instalação sem cron.
auth_remember_prune($pdo);
}
/**
* Tenta repor a sessão a partir do cookie.
*
* Devolve a linha do utilizador (e deixa a sessão iniciada) ou null. Não
* redirecciona: quem chama decide o que fazer com o null.
*
* $opts — as duas coisas que um site pode ter de dizer, e que este ficheiro não
* pode adivinhar. Sem elas o comportamento é o de sempre.
*
* 'allow' => fn(PDO $pdo, array $user): bool
* Uma regra do site sobre quem pode entrar, a somar ao auth_active_sql()
* (ex.: o dance recusa uma conta com o email por confirmar fora do prazo).
* false trata-se como a conta ter deixado de existir: caem todos os
* tokens dela, porque a mesma recusa valeria para qualquer outro.
*
* 'login' => fn(PDO $pdo, array $user): void
* O que abre a sessão, em vez do auth_login() nu. Um site que guarda mais
* na sessão do que o contrato partilhado (o dance põe lá a linha da conta
* e os papéis) tem de ser ele a abri-la — senão a sessão reposta pelo
* cookie seria diferente da que o login abre, e o portal via-a vazia.
* Tem de chamar auth_login() (ou algo que o chame).
*/
function auth_remember_resume(PDO $pdo, array $opts = []): ?array {
if (auth_check()) return auth_user(); // já há sessão; nada a repor
$cookie = auth_remember_parse_cookie();
if ($cookie === null) return null;
try {
auth_remember_ensure_table($pdo);
$st = $pdo->prepare(
"SELECT * FROM `auth_remember_tokens` WHERE `selector` = ? LIMIT 1"
);
$st->execute([$cookie['selector']]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
error_log('[auth/remember] lookup failed: ' . $e->getMessage());
return null;
}
if ($row === null) {
// Selector desconhecido — token já gasto, expirado e podado, ou lixo.
auth_remember_forget_cookie();
return null;
}
if (!hash_equals((string) $row['validator_hash'], hash('sha256', $cookie['validator']))) {
// Selector certo, validator errado. Ver a nota sobre rotação no topo:
// isto é a assinatura de um cookie em circulação, não um engano.
error_log('[auth/remember] validator mismatch for user ' . (int) $row['user_id']
. ' — a revogar todos os tokens da conta');
auth_remember_forget_user($pdo, (int) $row['user_id']);
auth_remember_forget_cookie();
return null;
}
if (strtotime((string) $row['expires_at']) <= time()) {
auth_remember_delete($pdo, (string) $row['selector']);
auth_remember_forget_cookie();
return null;
}
// Token bom. Carregar a conta pelo mapa de colunas do site — e voltar a
// passar por auth_active_sql(), porque uma conta desactivada depois de o
// cookie ser emitido não pode entrar por esta porta.
$s = auth_schema();
try {
$u = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$u->execute([(int) $row['user_id']]);
$user = $u->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
error_log('[auth/remember] user load failed: ' . $e->getMessage());
return null;
}
if ($user !== null && isset($opts['allow']) && is_callable($opts['allow'])) {
try {
if (!($opts['allow'])($pdo, $user)) {
$user = null;
}
} catch (Throwable $e) {
// Falha aberta para o ecrã de login, não para dentro do portal: uma
// regra que não se conseguiu avaliar não deixa ninguém passar.
error_log('[auth/remember] allow hook failed: ' . $e->getMessage());
return null;
}
}
if ($user === null) {
auth_remember_forget_user($pdo, (int) $row['user_id']);
auth_remember_forget_cookie();
return null;
}
try {
if (isset($opts['login']) && is_callable($opts['login'])) {
($opts['login'])($pdo, $user);
} else {
auth_login($pdo, $user);
}
} catch (Throwable $e) {
// Um cookie nunca pode partir a página — nem o site público, que também
// passa por aqui. Quem vier a seguir vê o ecrã de login.
error_log('[auth/remember] login failed: ' . $e->getMessage());
return null;
}
$_SESSION['_auth_remembered'] = true;
// Rotação: o validator que acabou de ser usado deixa de valer.
auth_remember_rotate($pdo, (string) $row['selector']);
return $user;
}
/** Gasta o validator actual e emite outro para o mesmo selector. */
function auth_remember_rotate(PDO $pdo, string $selector): void {
$validator = bin2hex(random_bytes(32));
$expires = time() + auth_remember_days() * 86400;
try {
$pdo->prepare(
"UPDATE `auth_remember_tokens`
SET `validator_hash` = ?, `expires_at` = ?, `last_used_at` = NOW(), `last_ip` = ?
WHERE `selector` = ?"
)->execute([
hash('sha256', $validator),
date('Y-m-d H:i:s', $expires),
platform_client_ip(),
$selector,
]);
} catch (Throwable $e) {
error_log('[auth/remember] rotate failed: ' . $e->getMessage());
return;
}
auth_remember_set_cookie($selector . ':' . $validator, $expires);
}
/** Apaga o cookie do browser, sem tocar na base de dados. */
function auth_remember_forget_cookie(): void {
auth_remember_set_cookie('', time() - 42000);
unset($_COOKIE[auth_remember_cookie_name()]);
}
function auth_remember_delete(PDO $pdo, string $selector): void {
try {
$pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `selector` = ?")
->execute([$selector]);
} catch (Throwable $e) {
error_log('[auth/remember] delete failed: ' . $e->getMessage());
}
}
/**
* Esquece este dispositivo: apaga o token que o cookie actual identifica e o
* próprio cookie. É o que o logout deve chamar — sair numa máquina não pode
* terminar a sessão do telemóvel.
*/
function auth_remember_clear(PDO $pdo): void {
$cookie = auth_remember_parse_cookie();
if ($cookie !== null) auth_remember_delete($pdo, $cookie['selector']);
auth_remember_forget_cookie();
}
/**
* Esquece TODOS os dispositivos de uma conta. Para a mudança de palavra-passe,
* para a suspeita de roubo, e para o botão "terminar sessão em todo o lado".
*/
function auth_remember_forget_user(PDO $pdo, int $userId): int {
try {
$st = $pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `user_id` = ?");
$st->execute([$userId]);
return $st->rowCount();
} catch (Throwable $e) {
error_log('[auth/remember] forget_user failed: ' . $e->getMessage());
return 0;
}
}
/** Remove tokens expirados. Idempotente, barato, seguro de chamar sempre. */
function auth_remember_prune(PDO $pdo): void {
try {
$pdo->exec("DELETE FROM `auth_remember_tokens` WHERE `expires_at` <= NOW()");
} catch (Throwable $e) {
error_log('[auth/remember] prune failed: ' . $e->getMessage());
}
}
/**
* A sessão actual veio de um cookie em vez de uma palavra-passe escrita agora?
*
* Quem for pedir uma operação sensível deve exigir autenticação fresca quando
* isto for verdade.
*/
function auth_remember_active(): bool {
return !empty($_SESSION['_auth_remembered']);
}
/** Tokens activos de uma conta — para uma página "as minhas sessões". */
function auth_remember_list(PDO $pdo, int $userId): array {
try {
$st = $pdo->prepare(
"SELECT `id`, `selector`, `created_at`, `last_used_at`, `last_ip`, `user_agent`, `expires_at`
FROM `auth_remember_tokens`
WHERE `user_id` = ? AND `expires_at` > NOW()
ORDER BY `last_used_at` DESC, `created_at` DESC"
);
$st->execute([$userId]);
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {
error_log('[auth/remember] list failed: ' . $e->getMessage());
return [];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
// ─── Rotas do front controller (router.php) ─────────────────────────────────
//
// A lista fechada de páginas partilhadas que o platform.php de um site serve.
// Uma ferramenta nova entra aqui e aparece em todos os sites que usam o router
// no próximo update do submódulo.
//
// file a página, relativa a este directório
// needs_db sem base de dados (montra estática) responde 503 explicado
// backup_helpers carrega os helpers de cópias antes (o que os stubs faziam
// com require config/functions_backup.php)
function platform_routes(): array {
return [
'index' => ['file' => 'index.php'],
'platform_update' => ['file' => 'platform_update.php'],
'backups' => ['file' => 'backups.php', 'needs_db' => true, 'backup_helpers' => true],
'restore' => ['file' => 'restore.php', 'needs_db' => true, 'backup_helpers' => true],
'ip_bans' => ['file' => 'ip_bans.php', 'needs_db' => true],
'geo' => ['file' => 'geo.php'],
'tracking' => ['file' => 'tracking.php', 'needs_db' => true],
'hub_integration' => ['file' => 'hub_integration.php'],
'smtp_status' => ['file' => 'smtp_status.php', 'needs_db' => true],
'audit' => ['file' => 'audit.php', 'needs_db' => true],
];
}
/**
* Os endpoints partilhados que o public/api/platform.php de um site serve
* (api/router.php). Cada um continua a autenticar-se a si próprio — chave de
* saúde, chave de operações, HMAC, sessão + CSRF —; o router só despacha.
*
* Os URLs antigos (api/health.php, api/hub_ops.php, …) ficam onde estão: os
* webhooks do Gitea e os caminhos que o hub guarda por plataforma apontam para
* eles. Um endpoint NOVO entra só aqui e chega a todos os sites sem stub.
*/
function platform_api_routes(): array {
return [
'health' => 'api/hub_health.php',
'hub_ops' => 'api/hub_ops.php',
'hub_pair' => 'api/hub_pair.php',
'feedback' => 'api/feedback.php',
'agent' => 'api/agent.php',
'auto_update' => 'api/auto_update.php',
'backup_trigger' => 'api/backup_trigger.php',
'backup_download' => 'api/backup_download.php',
];
}
/** O site declarou `'router' => true` no platform_connect() (tem o public/platform.php). */
function platform_router_enabled(): bool {
return function_exists('platform_config') && !empty(platform_config()['router']);
}
/**
* URLs sem `.php`? Só quando o site o declara (`'clean_urls' => true`), porque
* precisa da regra do .htaccess que serve `/platform` a partir de
* `platform.php` — sem ela, o URL limpo dava 404.
*/
function platform_clean_urls(): bool {
return function_exists('platform_config') && !empty(platform_config()['clean_urls']);
}
/** URL de uma ferramenta pelo router; $query sem o `?` (p. ex. 'tab=db'). */
function platform_route_url(string $tool, string $query = ''): string {
$script = platform_clean_urls() ? 'platform' : 'platform.php';
$path = $script . '?tool=' . rawurlencode($tool) . ($query !== '' ? '&' . ltrim($query, '?&') : '');
return platform_route_absolute_url($path);
}
/** URL de um endpoint pelo router da API. */
function platform_api_url(string $endpoint, string $query = ''): string {
$script = platform_clean_urls() ? 'api/platform' : 'api/platform.php';
$path = $script . '?endpoint=' . rawurlencode($endpoint) . ($query !== '' ? '&' . ltrim($query, '?&') : '');
return platform_route_absolute_url($path);
}
/** Um caminho relativo ao base_url passa a absoluto; um URL ou caminho absoluto fica. */
function platform_route_absolute_url(string $url): string {
if ($url === '' || preg_match('#^([a-z][a-z0-9+.-]*:)?//#i', $url) || $url[0] === '/') return $url;
if (function_exists('base_url')) return (string) base_url($url);
if (function_exists('hub_base_url')) return hub_base_url($url);
return '/' . $url;
}
/**
* As ferramentas que este site serve, com o URL de cada uma — anunciadas no
* health para o hub poder ligar para a página local. [] sem router: os stubs
* antigos variam de site para site, e anunciar um URL que dá 404 é pior do que
* não anunciar.
*/
function platform_routes_announced(): array {
if (!platform_router_enabled()) return [];
$out = [];
foreach (array_keys(platform_routes()) as $tool) $out[$tool] = platform_route_url($tool);
return $out;
}
/** O mesmo para os endpoints, com a mesma condição. */
function platform_api_routes_announced(): array {
if (!platform_router_enabled()) return [];
$out = [];
foreach (array_keys(platform_api_routes()) as $endpoint) $out[$endpoint] = platform_api_url($endpoint);
return $out;
}
@@ -0,0 +1,398 @@
<?php
// ─── Eventos de segurança e banimentos da frota, via hub ────────────────────
//
// Cada plataforma regista localmente o que as defesas recusam (banimentos,
// infracções, logins falhados, CSRF, tecto de envio) numa caixa de saída, e
// envia-a em lote ao hub: POST <hub>/api/security_events.php, autenticado com a
// MESMA chave com que o hub sonda esta plataforma (X-Hub-Key). A resposta traz
// os banimentos activos da frota, que entram no ip_bans local com
// source='fleet' — um IP banido na dance passa a ser recusado na fotografia.
//
// Porquê uma caixa de saída e não um POST por evento: um ataque gera centenas
// de recusas por minuto. Um pedido HTTP por recusa punha cada pedido do
// atacante à espera da rede e transformava o ataque a um site num ataque ao
// hub. Em lote, mil recusas são um pedido.
//
// Quando é que o lote sai:
// • no fim de um pedido qualquer (platform_ip_guard() regista-o), no máximo a
// cada HUB_SECURITY_FLUSH_EVERY s se há eventos e HUB_SECURITY_SYNC_EVERY s
// se não há — sem eventos continua a sincronizar, é assim que os
// banimentos dos OUTROS sites chegam aqui;
// • pelo cron: bin/hub-security-sync.php, que esvazia a caixa toda.
//
// Nada disto bloqueia nem rebenta. Sem hub emparelhado não se regista nada
// (senão a caixa crescia para sempre); com o hub em baixo os eventos esperam,
// com tecto, e os banimentos locais funcionam exactamente como antes.
//
// Origem de cada decisão sobre banimentos da frota:
// • um banimento chega ao hub só a partir do site que o decidiu — as cópias
// (source='fleet') nunca são reenviadas, senão ecoavam em ciclo;
// • levantar um banimento em QUALQUER site levanta-o na frota: as cópias
// desaparecem de todos na sincronização seguinte. Sem isto, um unban local
// de uma cópia era desfeito cinco minutos depois. O site de origem mantém
// o seu próprio banimento até expirar ou ser levantado lá.
require_once __DIR__ . '/functions_client_ip.php';
const HUB_SECURITY_FLUSH_EVERY = 30;
const HUB_SECURITY_SYNC_EVERY = 300;
const HUB_SECURITY_BATCH = 200;
// Tecto da caixa com o hub em baixo. Os banimentos nunca são descartados; o
// resto sai do mais antigo para o mais recente.
const HUB_SECURITY_OUTBOX_MAX = 5000;
function hub_security_enabled(): bool {
static $on = null;
if ($on !== null) return $on;
try {
if (!function_exists('hub_url')) require_once __DIR__ . '/functions_hub.php';
$on = hub_url() !== '' && hub_api_key() !== '';
} catch (Throwable $e) {
$on = false;
}
return $on;
}
/**
* Carimbos em ficheiro, para o fim de cada pedido decidir «há sincronização a
* fazer?» com dois stat() e sem tocar na base de dados.
*/
function hub_security_stamp(string $name): string {
return rtrim(sys_get_temp_dir(), '/\\') . '/hub-security-'
. substr(md5(dirname(__DIR__, 2)), 0, 12) . '-' . $name;
}
function hub_security_ensure_table(PDO $pdo): void {
static $done = false;
if ($done) return;
$done = true;
// occurred_at em epoch e não DATETIME: o hub corre noutro contentor, e um
// DATETIME sem fuso lido do outro lado seria uma hora errada em metade do ano.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `hub_security_outbox` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`kind` VARCHAR(40) NOT NULL,
`ip` VARCHAR(45) NULL,
`detail` VARCHAR(190) NULL,
`data` TEXT NULL,
`occurred_at` INT UNSIGNED NOT NULL,
`attempts` INT NOT NULL DEFAULT 0,
KEY `idx_hso_kind` (`kind`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
/**
* Destino alternativo dos eventos: fn(?PDO, kind, ?detail, ?ip, array data).
* É o hub que se regista aqui — ele é o registo da frota, e mandar os seus
* próprios eventos a si mesmo por HTTP exigia emparelhá-lo consigo próprio.
*/
function platform_security_event_sink(?callable $sink = null): ?callable {
static $current = null;
if ($sink !== null) $current = $sink;
return $current;
}
/**
* Regista um evento para o hub. Nunca lança e nunca espera pela rede.
*
* $kind curto e estável (ip_ban, ip_unban, offence, login_failed,
* login_throttled, csrf_failed, mail_rate_limited): é por ele que o hub agrupa.
*/
function platform_security_event(?PDO $pdo, string $kind, ?string $detail = null,
?string $ip = null, array $data = []): void {
try {
$ip ??= platform_client_ip();
$ip = ($ip !== null && $ip !== '') ? $ip : null;
// Os detalhes das infracções trazem às vezes o email de quem submeteu
// (o antispam usa-o como assunto). Não tem de sair do site para o hub.
if ($detail !== null) {
$detail = preg_replace_callback('/[^\s@<>"\'(),;:]+@[^\s@<>"\'(),;:]+\.[a-z]{2,}/i',
static fn(array $m): string => hub_security_mask_identifier($m[0]), $detail);
}
$sink = platform_security_event_sink();
if ($sink !== null) {
$sink($pdo ?? (($GLOBALS['pdo'] ?? null) instanceof PDO ? $GLOBALS['pdo'] : null), $kind, $detail, $ip, $data);
return;
}
if (!hub_security_enabled()) return;
$pdo ??= hub_pdo();
if (!$pdo instanceof PDO) return;
hub_security_ensure_table($pdo);
$pdo->prepare(
'INSERT INTO hub_security_outbox (kind, ip, detail, data, occurred_at) VALUES (?, ?, ?, ?, ?)'
)->execute([
mb_substr($kind, 0, 40),
$ip,
$detail === null ? null : mb_substr($detail, 0, 190),
$data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
time(),
]);
@touch(hub_security_stamp('dirty'));
} catch (Throwable $e) {
error_log('[hub-security] evento não registado: ' . $e->getMessage());
}
}
/** «a***@exemplo.pt» — chega para ver um ataque a uma conta sem mandar o email ao hub. */
function hub_security_mask_identifier(string $id): string {
$id = mb_strtolower(trim($id));
$at = strrpos($id, '@');
if ($at === false) return mb_substr($id, 0, 1) . '***';
return mb_substr($id, 0, 1) . '***' . substr($id, $at);
}
/**
* Chamado cedo em cada pedido (pelo platform_ip_guard). Só agenda: o trabalho
* corre no fim, depois de a resposta sair quando o SAPI o permite.
*/
function hub_security_maybe_sync(PDO $pdo): void {
static $registered = false;
if ($registered) return;
$registered = true;
register_shutdown_function(static function () use ($pdo): void {
try {
if (!hub_security_due() || !hub_security_enabled()) return;
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
}
hub_security_sync($pdo, 1);
} catch (Throwable $e) {
error_log('[hub-security] sincronização falhou: ' . $e->getMessage());
}
});
}
function hub_security_due(): bool {
$attempt = hub_security_stamp('attempt');
$last = @filemtime($attempt) ?: 0;
$every = is_file(hub_security_stamp('dirty')) && !is_file(hub_security_stamp('failing'))
? HUB_SECURITY_FLUSH_EVERY
: HUB_SECURITY_SYNC_EVERY;
if (time() - $last < $every) return false;
// Marcar a tentativa ANTES de a fazer: os pedidos concorrentes desistem, e
// um hub em baixo não é tentado a cada pedido. Sem pasta temporária
// gravável não há como saber, e fica tudo para o cron.
return @touch($attempt);
}
/**
* Envia a caixa de saída e aplica a lista da frota que vem na resposta.
* $maxBatches: 1 no fim de um pedido, mais no cron.
*/
function hub_security_sync(PDO $pdo, int $maxBatches = 25): array {
if (!hub_security_enabled()) return ['ok' => false, 'error' => 'plataforma não emparelhada com o hub'];
if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível'];
hub_security_ensure_table($pdo);
@touch(hub_security_stamp('attempt'));
// Um só sincronizador de cada vez, entre todos os processos PHP do site. O
// nome leva a base de dados: os locks do MariaDB são do servidor inteiro, e a
// frota partilha o servidor — sem isto a sincronização de um site fazia os
// outros desistir.
if ((int) $pdo->query("SELECT GET_LOCK(CONCAT('hub_security_sync:', DATABASE()), 0)")->fetchColumn() !== 1) {
return ['ok' => true, 'skipped' => 'já a correr'];
}
$out = ['ok' => true, 'sent' => 0, 'fleet' => null, 'error' => null];
try {
hub_security_trim_outbox($pdo);
for ($i = 0; $i < max(1, $maxBatches); $i++) {
$rows = $pdo->query(
'SELECT * FROM hub_security_outbox ORDER BY id LIMIT ' . HUB_SECURITY_BATCH
)->fetchAll(PDO::FETCH_ASSOC);
$events = [];
foreach ($rows as $r) {
$events[] = [
'kind' => $r['kind'],
'ip' => $r['ip'],
'detail' => $r['detail'],
'data' => $r['data'] !== null ? json_decode((string) $r['data'], true) : null,
'ts' => (int) $r['occurred_at'],
];
}
$res = hub_security_post(['events' => $events]);
if (!$res['ok']) {
if ($rows) {
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
$pdo->exec("UPDATE hub_security_outbox SET attempts = attempts + 1 WHERE id IN ($ids)");
}
@touch(hub_security_stamp('failing'));
$out['ok'] = false;
$out['error'] = $res['error'];
error_log('[hub-security] envio falhou: ' . $res['error']);
break;
}
@unlink(hub_security_stamp('failing'));
if ($rows) {
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
$pdo->exec("DELETE FROM hub_security_outbox WHERE id IN ($ids)");
$out['sent'] += count($rows);
}
if (is_array($res['data']['fleet'] ?? null)) {
$out['fleet'] = hub_security_apply_fleet($pdo, $res['data']['fleet']);
}
@touch(hub_security_stamp('synced'));
if (count($rows) < HUB_SECURITY_BATCH) {
// Contar de novo: um evento que entrou durante o envio tem de
// manter a marca, senão esperava pela sincronização lenta.
if ((int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn() === 0) {
@unlink(hub_security_stamp('dirty'));
}
break;
}
}
} finally {
$pdo->query("SELECT RELEASE_LOCK(CONCAT('hub_security_sync:', DATABASE()))");
}
return $out;
}
function hub_security_trim_outbox(PDO $pdo): void {
$n = (int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn();
if ($n <= HUB_SECURITY_OUTBOX_MAX) return;
$excess = $n - HUB_SECURITY_OUTBOX_MAX;
$pdo->exec(
"DELETE FROM hub_security_outbox WHERE kind NOT IN ('ip_ban', 'ip_unban')
ORDER BY id LIMIT " . (int) $excess
);
error_log("[hub-security] caixa de saída acima do tecto — $excess eventos antigos descartados");
}
/** POST ao hub. ['ok'=>bool, 'data'=>array|null, 'error'=>string]. */
function hub_security_post(array $payload): array {
$ch = curl_init(hub_url() . '/api/security_events.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
// Curto: no pior caso isto corre no fim de um pedido de um visitante.
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 5,
// X-Hub-Key e não Authorization: o proxy come o Authorization (ver functions_hub.php).
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json',
'X-Hub-Key: ' . hub_api_key()],
CURLOPT_USERAGENT => 'andmore-platform-security/1.0',
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') return ['ok' => false, 'data' => null, 'error' => $err ?: 'sem resposta'];
$data = json_decode((string) $raw, true);
if (!is_array($data)) return ['ok' => false, 'data' => null, 'error' => "resposta não-JSON (HTTP $http)"];
if (empty($data['ok'])) return ['ok' => false, 'data' => $data, 'error' => (string) ($data['error'] ?? "HTTP $http")];
return ['ok' => true, 'data' => $data, 'error' => ''];
}
/**
* Aplica a lista da frota ao ip_bans local.
*
* Um banimento próprio activo ganha sempre à cópia — foi decidido aqui, com o
* motivo daqui. As cópias que o hub deixou de mandar (levantadas ou expiradas)
* saem, mas só quando a lista vem completa: uma lista truncada não diz nada
* sobre o que ficou de fora.
*/
function hub_security_apply_fleet(PDO $pdo, array $fleet): array {
require_once __DIR__ . '/functions_ip_ban.php';
platform_ip_ban_ensure_tables($pdo);
$local = [];
foreach ($pdo->query(
"SELECT ip, source, reason, expires_at,
(expires_at IS NULL OR expires_at > NOW()) AS active
FROM ip_bans
WHERE source = 'fleet' OR expires_at IS NULL OR expires_at > NOW()"
) as $r) {
$local[$r['ip']] = $r;
}
$upsert = $pdo->prepare(
"INSERT INTO ip_bans (ip, reason, source, expires_at, created_by) VALUES (?, ?, 'fleet', ?, NULL)
ON DUPLICATE KEY UPDATE reason = VALUES(reason), source = 'fleet',
expires_at = VALUES(expires_at), created_at = NOW(), created_by = NULL"
);
$seen = [];
$applied = 0;
foreach ((array) ($fleet['bans'] ?? []) as $b) {
$ip = trim((string) ($b['ip'] ?? ''));
if (!filter_var($ip, FILTER_VALIDATE_IP) || platform_ip_ban_exempt($ip)) continue;
$seen[$ip] = true;
$cur = $local[$ip] ?? null;
if ($cur !== null && $cur['source'] !== 'fleet' && (int) $cur['active'] === 1) continue;
$ts = isset($b['expires_ts']) ? (int) $b['expires_ts'] : null;
$expires = $ts ? date('Y-m-d H:i:s', $ts) : null;
$reason = mb_substr('frota · ' . (string) ($b['origin'] ?? '?') . ' · ' . (string) ($b['reason'] ?? ''), 0, 190);
if ($cur !== null && $cur['source'] === 'fleet'
&& $cur['reason'] === $reason && $cur['expires_at'] === $expires) {
continue;
}
try {
$upsert->execute([$ip, $reason, $expires]);
$applied++;
} catch (Throwable $e) {
error_log('[hub-security] cópia de banimento não aplicada: ' . $e->getMessage());
}
}
$removed = 0;
if (!empty($fleet['complete'])) {
$del = $pdo->prepare("DELETE FROM ip_bans WHERE ip = ? AND source = 'fleet'");
foreach ($local as $ip => $r) {
if ($r['source'] === 'fleet' && !isset($seen[$ip])) {
$del->execute([$ip]);
$removed++;
}
}
}
return ['received' => count($seen), 'applied' => $applied, 'removed' => $removed];
}
/**
* `checks.security` para o health do hub. A pergunta é «o envio está a
* funcionar?»: eventos presos há mais de uma hora querem dizer que não.
*/
function hub_security_check(PDO $pdo): array {
if (!hub_security_enabled()) return [];
hub_security_ensure_table($pdo);
$row = $pdo->query('SELECT COUNT(*) n, MIN(occurred_at) oldest FROM hub_security_outbox')
->fetch(PDO::FETCH_ASSOC);
$pending = (int) ($row['n'] ?? 0);
$oldest = $row['oldest'] !== null ? time() - (int) $row['oldest'] : 0;
$fleet = 0;
try {
$fleet = (int) $pdo->query(
"SELECT COUNT(*) FROM ip_bans WHERE source = 'fleet' AND (expires_at IS NULL OR expires_at > NOW())"
)->fetchColumn();
} catch (Throwable) { /* ip_bans ainda por criar */ }
$synced = @filemtime(hub_security_stamp('synced')) ?: null;
$detail = $pending . ' por enviar'
. ($synced ? ' · sincronizado há ' . max(0, (int) round((time() - $synced) / 60)) . ' min' : ' · nunca sincronizou')
. ' · ' . $fleet . ' banimento(s) da frota';
return [
'ok' => $oldest < 3600,
'detail' => $detail,
'pending' => $pending,
'oldest_seconds' => $oldest,
'fleet_bans' => $fleet,
'synced_at' => $synced ? date('c', $synced) : null,
];
}
@@ -316,8 +316,16 @@ function platform_smtp_config(PDO $pdo): array {
* *
* A site overrides the segment by defining PLATFORM_ADMIN_DIR (or setting it in * A site overrides the segment by defining PLATFORM_ADMIN_DIR (or setting it in
* .env) to e.g. 'views/admin'. Default keeps the existing behaviour. * .env) to e.g. 'views/admin'. Default keeps the existing behaviour.
*
* A site on the front controller (`'router' => true` in platform_connect())
* gets platform.php?tool=… for every shared page; its own pages keep the dir.
*/ */
function platform_admin_url(string $page = ''): string { function platform_admin_url(string $page = ''): string {
if (function_exists('platform_router_enabled') && platform_router_enabled()) {
[$file, $query] = array_pad(explode('?', $page, 2), 2, '');
$tool = $file === '' ? 'index' : preg_replace('/\.php$/', '', basename($file));
if (isset(platform_routes()[$tool])) return platform_route_url($tool, $query);
}
$dir = 'views/sysadmin'; $dir = 'views/sysadmin';
if (defined('PLATFORM_ADMIN_DIR') && constant('PLATFORM_ADMIN_DIR') !== '') { if (defined('PLATFORM_ADMIN_DIR') && constant('PLATFORM_ADMIN_DIR') !== '') {
$dir = (string) constant('PLATFORM_ADMIN_DIR'); $dir = (string) constant('PLATFORM_ADMIN_DIR');
@@ -329,3 +337,76 @@ function platform_admin_url(string $page = ''): string {
$path = trim($dir, '/') . ($page !== '' ? '/' . ltrim($page, '/') : ''); $path = trim($dir, '/') . ($page !== '' ? '/' . ltrim($page, '/') : '');
return function_exists('base_url') ? base_url($path) : '/' . $path; return function_exists('base_url') ? base_url($path) : '/' . $path;
} }
/**
* A stable HMAC key for a named purpose.
*
* Signed form tokens (anti-spam stamps, captcha challenges) are issued in one
* request and verified in another, so the key behind them must be **the same
* across requests** -- a per-request key rejects every visitor with "invalid
* token" and the failure looks like a bot problem rather than a bug.
*
* Three sources, in order, all stable:
*
* 1. platform_secret_key() -- the .env key, the normal case;
* 2. a random value stored in system_settings, when there is no .env key;
* 3. derived from the install's own configuration, for an install with
* neither. It is a weaker secret than the other two, but it is constant,
* and a defence that locks every form is worse than a weaker signature.
*
* Always domain-separated by $purpose, so a stamp minted for one feature can
* never validate in another. Note that functions_antispam_forms.php keeps its
* own older derivation on purpose: changing the key of a live site invalidates
* the forms that are open at that instant, and those submissions are refused
* silently -- leads lost for the sake of tidiness. New callers use this one.
*
* Pass the PDO wherever you have one. Source 3 is a last resort, and a site
* that reaches it from one call site with a PDO and another without would sign
* with two different keys.
*/
function platform_derived_key(string $purpose, ?PDO $pdo = null, string $settingKey = 'shared_hmac_key'): string {
static $bases = [];
if (!isset($bases[$settingKey])) {
$base = '';
$envKey = platform_secret_key();
if ($envKey !== null) {
$base = bin2hex($envKey);
} elseif ($pdo instanceof PDO) {
$base = (string) platform_settings_get($pdo, $settingKey, '');
if ($base === '') {
$fresh = bin2hex(random_bytes(32));
try {
// Stored in the clear on purpose: encrypting it would need
// the key we do not have (that is why we are here), and a
// value that decrypts differently on each read means every
// token minted before this request stops verifying.
platform_settings_set($pdo, $settingKey, $fresh, null, false);
$base = $fresh;
} catch (Throwable $e) {
error_log('[settings] ' . $settingKey . ' not persisted (' . $e->getMessage()
. '); deriving from install config');
}
}
}
if ($base === '') {
$read = static function (string $k): string {
if (defined($k)) return (string) constant($k);
if (function_exists('env')) return (string) env($k, '');
return (string) (getenv($k) ?: ($_ENV[$k] ?? ''));
};
// Not cached: this branch is the fallback, and a later call that
// does carry a PDO must be free to find the real key instead of
// inheriting this one.
return hash('sha256', 'am-key|' . $purpose . '|derived|'
. $read('DB_NAME') . '|' . $read('DB_USER') . '|' . $read('DB_PASS')
. '|' . $read('APP_BASE_URL'), true);
}
$bases[$settingKey] = $base;
}
return hash('sha256', 'am-key|' . $purpose . '|' . $bases[$settingKey], true);
}
+345 -40
View File
@@ -14,15 +14,22 @@
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500); http_response_code(500);
exit('geo.php: sem PDO — o stub tem de abrir a ligação primeiro.'); exit('geo.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
} }
require_once __DIR__ . '/functions_geo.php'; require_once __DIR__ . '/functions_geo.php';
// O token do platform-tools, carregado aqui e não presumido: numa plataforma que
// não o tivesse carregado por outro caminho (a silver), o formulário saía sem
// token e todas as gravações davam «Token inválido» (2026-09-23).
require_once __DIR__ . '/functions_csrf.php';
// O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design // O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design
// põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava // põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava
// os links desta página para um 404 nesse site. // os links desta página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: ''; $selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
// Pelo front controller a página é /platform?tool=geo: sem o `tool`, o redirect
// depois de gravar ia parar ao índice do Sistema em vez de voltar aqui.
if (isset($_GET['tool'])) $selfUrl .= '?tool=' . rawurlencode((string) $_GET['tool']);
$user = function_exists('current_user') ? current_user() : null; $user = function_exists('current_user') ? current_user() : null;
$uid = isset($user['id']) ? (int) $user['id'] : null; $uid = isset($user['id']) ? (int) $user['id'] : null;
@@ -30,6 +37,19 @@ $uid = isset($user['id']) ? (int) $user['id'] : null;
$probe = trim((string) ($_GET['probe'] ?? '')); $probe = trim((string) ($_GET['probe'] ?? ''));
$probeResult = null; $probeResult = null;
// Gravar uma definição. Pela função do site quando existe — a fotografia e o
// dance limpam a sua cache de definições nela —, senão pela do platform-tools,
// que é de onde o geo_env() lê. Até 2026-09-23 chamava sempre a do site, e numa
// plataforma sem ela (a silver) gravar dava um erro fatal.
$saveSetting = function (string $key, string $value) use ($pdo, $uid): void {
if (function_exists('db_update_setting')) {
db_update_setting($pdo, $key, $value, $uid);
} else {
require_once __DIR__ . '/functions_settings.php';
platform_settings_set($pdo, $key, $value, $uid);
}
};
// ─── POST ──────────────────────────────────────────────────────────────────── // ─── POST ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfOk = function_exists('csrf_verify') $csrfOk = function_exists('csrf_verify')
@@ -50,29 +70,65 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$countries) { if (!$countries) {
$_SESSION['flash'] = ['type' => 'warning', 'msg' => 'Defina primeiro os países permitidos.']; $_SESSION['flash'] = ['type' => 'warning', 'msg' => 'Defina primeiro os países permitidos.'];
} else { } else {
$script = __DIR__ . '/bin/geo-update.sh'; $args = ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1';
$out = []; $out = [];
$code = 1; $code = 1;
exec(escapeshellcmd($script) . ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1', $out, $code); exec(escapeshellcmd(__DIR__ . '/bin/geo-update.sh') . $args, $out, $code);
$_SESSION['flash'] = $code === 0
? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . implode(' · ', array_slice($out, 0, 6))] // O Relay corre a seguir e em separado: são duas fontes
: ['type' => 'danger', 'msg' => 'A actualização falhou (as listas anteriores mantêm-se). ' . implode(' · ', array_slice($out, 0, 6))]; // independentes, e a da Apple estar em baixo não pode impedir a
// actualização das listas de país (nem o contrário).
$relayOut = [];
$relayCode = 1;
exec(escapeshellcmd(__DIR__ . '/bin/geo-relay-update.sh') . $args, $relayOut, $relayCode);
$msg = implode(' · ', array_slice(array_merge($out, $relayOut), 0, 8));
$_SESSION['flash'] = ($code === 0 && $relayCode === 0)
? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . $msg]
: ['type' => ($code === 0 ? 'warning' : 'danger'), 'msg' =>
($code === 0
? 'Países actualizados; o iCloud Private Relay falhou (lista anterior mantida). '
: 'A actualização falhou (as listas anteriores mantêm-se). ') . $msg];
} }
header('Location: ' . $selfUrl); header('Location: ' . $selfUrl);
exit; exit;
} }
// O botão do "acrescentar o meu endereço" grava só a lista de endereços e
// devolve a página. Parte do texto que veio no formulário, e não do que está
// gravado, para não deitar fora o que a pessoa tenha escrito antes de
// carregar -- que é a maneira mais rápida de alguém perder confiança numa
// página de configuração.
if (($_POST['action'] ?? 'save') === 'add_my_ip') {
$ip = platform_client_ip() ?? '';
$lista = preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($ip !== '' && !in_array($ip, $lista, true)) {
$lista[] = $ip;
}
$saveSetting('geo_allow_ips', implode(',', $lista));
$_SESSION['flash'] = ['type' => 'success', 'msg' => 'O endereço ' . $ip . ' passa sempre a partir de agora.'];
header('Location: ' . $selfUrl);
exit;
}
$mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off'))); $mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off')));
if (!in_array($mode, ['off', 'forms', 'site'], true)) { if (!in_array($mode, ['off', 'forms', 'site'], true)) {
$mode = 'off'; $mode = 'off';
} }
// Aceita "PT, ES" ou "pt es"; guarda normalizado e sem repetições. Um // As caixas mais o campo de escape, tratados da mesma maneira. Um código que
// código que não seja ISO-3166 alfa-2 é recusado com aviso, e não engolido: // não seja ISO-3166 alfa-2 é recusado com aviso, e não engolido: um país mal
// um país mal escrito na lista é indistinguível de "ninguém passa". // escrito na lista é indistinguível de "ninguém passa".
//
// O campo de escape existe porque a lista de caixas tem 72 países e não 249:
// ver o cabeçalho de data/countries-pt.php.
$rejected = []; $rejected = [];
$valid = []; $valid = [];
foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_countries'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $cc) { $submetidos = array_merge(
array_map('strval', (array) ($_POST['geo_countries'] ?? [])),
preg_split('/[\s,;]+/', (string) ($_POST['geo_countries_extra'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []
);
foreach ($submetidos as $cc) {
$up = strtoupper($cc); $up = strtoupper($cc);
if (preg_match('/^[A-Z]{2}$/', $up)) { if (preg_match('/^[A-Z]{2}$/', $up)) {
$valid[$up] = true; $valid[$up] = true;
@@ -101,12 +157,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit; exit;
} }
db_update_setting($pdo, 'geo_mode', $mode, $uid); // Endereços que passam sempre. Validados aqui pela mesma função que o
db_update_setting($pdo, 'geo_allow_countries', $countries, $uid); // módulo usa para os ler, para que uma entrada inválida seja recusada com
db_update_setting($pdo, 'geo_fail_closed', $failClosed, $uid); // aviso em vez de ficar gravada a não fazer nada -- que é indistinguível de
// "guardei e continua bloqueado".
$badIps = [];
$validIps = [];
foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) {
if (geo_cidr_to_range($entry) !== null) {
$validIps[$entry] = true;
} else {
$badIps[] = $entry;
}
}
$allowIps = implode(',', array_keys($validIps));
$trustRelay = !empty($_POST['geo_trust_relay']) ? 'true' : 'false';
$_SESSION['flash'] = $rejected $saveSetting('geo_mode', $mode);
? ['type' => 'warning', 'msg' => 'Guardado, mas isto não são códigos de país válidos e foi ignorado: ' . implode(', ', $rejected)] $saveSetting('geo_allow_countries', $countries);
$saveSetting('geo_fail_closed', $failClosed);
$saveSetting('geo_allow_ips', $allowIps);
$saveSetting('geo_trust_relay', $trustRelay);
if (function_exists('platform_audit')) {
platform_audit($pdo, 'settings.geo', ['target_type' => 'settings', 'summary' => 'Bloqueio por país guardado',
'changes' => ['geo_mode' => $mode, 'geo_allow_countries' => $countries, 'geo_fail_closed' => $failClosed,
'geo_allow_ips' => $allowIps, 'geo_trust_relay' => $trustRelay]]);
}
$ignorado = array_merge(
$rejected ? ['códigos de país: ' . implode(', ', $rejected)] : [],
$badIps ? ['endereços: ' . implode(', ', $badIps)] : []
);
$_SESSION['flash'] = $ignorado
? ['type' => 'warning', 'msg' => 'Guardado, mas isto não é válido e foi ignorado — ' . implode(' · ', $ignorado)]
: ['type' => 'success', 'msg' => 'Configuração guardada.']; : ['type' => 'success', 'msg' => 'Configuração guardada.'];
header('Location: ' . $selfUrl); header('Location: ' . $selfUrl);
exit; exit;
@@ -116,6 +199,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$mode = geo_mode(); $mode = geo_mode();
$countries = geo_allowed_countries(); $countries = geo_allowed_countries();
$failClosed = geo_fail_closed(); $failClosed = geo_fail_closed();
$allowIps = preg_split('/[\s,;]+/', (string) geo_env('GEO_ALLOW_IPS', ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$trustRelay = geo_trust_relay();
// A lista de caixas, e os códigos já guardados que não constam dela -- esses
// aparecem à mesma, num grupo à parte: uma página que perde silenciosamente um
// valor gravado é pior do que uma página feia.
$grupos = require __DIR__ . '/data/countries-pt.php';
$conhecidos = array_merge(...array_map('array_keys', array_values($grupos)));
$foraDaLista = array_values(array_diff($countries, $conhecidos));
/** Há alguma lista de endereços para este país? (não abre os ficheiros) */
$temDados = function (string $cc): bool {
$base = GEO_DATA_DIR . '/' . strtolower($cc);
return is_file($base . '.v4') || is_file($base . '.v6');
};
$relayOk = geo_relay_available($countries);
// De onde veio cada valor — é o que explica um "guardei e não mudou nada". // De onde veio cada valor — é o que explica um "guardei e não mudou nada".
$origin = function (string $key) use ($pdo): string { $origin = function (string $key) use ($pdo): string {
@@ -126,15 +226,22 @@ $origin = function (string $key) use ($pdo): string {
$dataFiles = []; $dataFiles = [];
foreach ($countries as $cc) { foreach ($countries as $cc) {
foreach (['v4', 'v6'] as $fam) { // Duas fontes por país: a ipdeny (alocações nacionais) e a Apple (saídas do
$f = GEO_DATA_DIR . '/' . strtolower($cc) . '.' . $fam; // Private Relay ao serviço desse país). Lado a lado na mesma tabela porque
$dataFiles[] = [ // a pergunta do operador é uma só -- "tenho dados para PT?".
'cc' => $cc, foreach ([['', 'ipdeny'], ['relay-', 'iCloud Relay']] as [$prefix, $fonte]) {
'fam' => $fam, foreach (['v4', 'v6'] as $fam) {
'exists' => is_file($f), $key = $prefix . strtolower($cc);
'ranges' => is_file($f) ? count(geo_ranges($cc, $fam === 'v6')) : 0, $f = GEO_DATA_DIR . '/' . $key . '.' . $fam;
'mtime' => is_file($f) ? date('Y-m-d H:i', filemtime($f)) : '—', $dataFiles[] = [
]; 'cc' => $cc,
'fonte' => $fonte,
'fam' => $fam,
'exists' => is_file($f),
'ranges' => is_file($f) ? count(geo_ranges($key, $fam === 'v6')) : 0,
'mtime' => is_file($f) ? date('Y-m-d H:i', filemtime($f)) : '—',
];
}
} }
} }
@@ -154,8 +261,15 @@ $blockedUrl = $platform_blocked_url
?? (function_exists('base_url') ? base_url('views/admin/blocked_submissions.php') : null); ?? (function_exists('base_url') ? base_url('views/admin/blocked_submissions.php') : null);
$pageTitle = 'Bloqueio por país'; $pageTitle = 'Bloqueio por país';
$hdr = dirname(__DIR__, 2) . '/includes/header.php'; // O layout do site, como nas outras páginas partilhadas (o router passa-o a
if (is_file($hdr)) { include $hdr; } // partir do platform_connect()). Até 2026-09-23 esta página fixava
// includes/header.php e ignorava-o: num site com layout noutro sítio saía sem
// cabeçalho nenhum.
$layout_header = $platform_layout_header ?? (dirname(__DIR__, 2) . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? (dirname(__DIR__, 2) . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? (dirname(__DIR__, 2) . '/includes/footer.php');
if (is_file($layout_header)) { include $layout_header; }
if (is_file($layout_navbar)) { include $layout_navbar; }
$csrfField = function_exists('csrf_field') ? csrf_field() $csrfField = function_exists('csrf_field') ? csrf_field()
: (function_exists('platform_csrf_field') ? platform_csrf_field() : ''); : (function_exists('platform_csrf_field') ? platform_csrf_field() : '');
@@ -177,7 +291,38 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
<div class="alert alert-<?= $h($f['type']) ?> py-2"><?= $h($f['msg']) ?></div> <div class="alert alert-<?= $h($f['type']) ?> py-2"><?= $h($f['msg']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($mode !== 'off' && !$dataFiles): ?> <?php
// Uma frase que diz o que está a acontecer neste momento. É a pergunta com
// que se abre esta página, e antes só se respondia lendo três campos e
// sabendo de cor o que cada um faz.
$paisesTxt = $countries ? implode('/', $countries) : '(nenhum)';
[$cor, $frase] = match ($mode) {
'forms' => ['primary', 'Os formulários públicos só aceitam envios de <strong>' . $h($paisesTxt)
. '</strong>. Ler o site continua aberto a toda a gente.'],
'site' => ['danger', 'O site inteiro responde <strong>403</strong> a quem vier de fora de <strong>'
. $h($paisesTxt) . '</strong>.'],
default => ['secondary', 'O filtro está <strong>desligado</strong>. Ninguém é recusado por causa do país.'],
};
$extras = [];
if ($mode !== 'off' && $trustRelay && $relayOk) {
$extras[] = 'O iCloud Private Relay é atribuído ao país que serve.';
}
if ($allowIps) {
$extras[] = count($allowIps) . ' endereço' . (count($allowIps) === 1 ? '' : 's')
. ' na lista de excepções, que passam sempre.';
}
?>
<div class="alert alert-<?= $cor ?> py-2">
<?= $frase ?>
<?php foreach ($extras as $e): ?><span class="d-block small mt-1"><?= $h($e) ?></span><?php endforeach; ?>
</div>
<?php if ($mode !== 'off' && !$countries): ?>
<div class="alert alert-warning py-2">
<strong>Filtro ligado sem países.</strong> Sem lista, o módulo fica inerte de propósito —
escolha pelo menos um país abaixo.
</div>
<?php elseif ($mode !== 'off' && !$dataFiles): ?>
<div class="alert alert-warning py-2"> <div class="alert alert-warning py-2">
<strong>Filtro ligado sem listas.</strong> Nada é bloqueado — o módulo falha aberto de <strong>Filtro ligado sem listas.</strong> Nada é bloqueado — o módulo falha aberto de
propósito. Carregue em "Actualizar listas" abaixo. propósito. Carregue em "Actualizar listas" abaixo.
@@ -209,13 +354,143 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="geo_countries" class="form-label fw-semibold">Países permitidos</label> <div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
<input type="text" class="form-control" id="geo_countries" name="geo_countries" <label class="form-label fw-semibold mb-0">Países permitidos</label>
value="<?= $h(implode(', ', $countries)) ?>" placeholder="PT"> <span class="badge rounded-pill text-bg-light border">
<div class="form-text"> <?= count($countries) ?> seleccionado<?= count($countries) === 1 ? '' : 's' ?>
Códigos ISO de duas letras, separados por vírgula (<code>PT</code>, <code>PT, ES</code>). </span>
Valor activo vem de: <strong><?= $h($origin('GEO_ALLOW_COUNTRIES')) ?></strong>.
</div> </div>
<!-- Começa escondido e é o JavaScript que o mostra: um campo de
procura que não procura nada é pior do que não haver campo. -->
<input type="search" id="geo-filtro" class="form-control form-control-sm mb-3 d-none"
placeholder="Procurar país…" autocomplete="off" aria-label="Procurar país">
<?php foreach ($grupos as $grupo => $lista): ?>
<?php
// O grupo abre quando tem algum país escolhido lá dentro. Sem
// isto, a lista guardada podia estar toda dentro de secções
// fechadas e a página parecia vazia.
$temEscolha = (bool) array_intersect(array_keys($lista), $countries);
?>
<details class="mb-2 geo-grupo" <?= ($temEscolha || $grupo === 'Mais usados') ? 'open' : '' ?>>
<summary class="fw-semibold small text-uppercase text-muted py-1" style="cursor:pointer;">
<?= $h($grupo) ?>
<span class="text-body-tertiary fw-normal text-lowercase">(<?= count($lista) ?>)</span>
</summary>
<div class="row g-1 ps-1 pt-1">
<?php foreach ($lista as $cc => $nome): ?>
<div class="col-6 col-md-4 geo-pais" data-nome="<?= $h(mb_strtolower($nome . ' ' . $cc, 'UTF-8')) ?>">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="geo_countries[]"
value="<?= $h($cc) ?>" id="cc-<?= $h($cc) ?>"
<?= in_array($cc, $countries, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="cc-<?= $h($cc) ?>">
<?= $h($nome) ?>
<span class="text-body-tertiary small"><?= $h($cc) ?></span>
<?php if (in_array($cc, $countries, true) && !$temDados($cc)): ?>
<span class="badge text-bg-warning ms-1">sem listas</span>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
<?php if ($foraDaLista): ?>
<details class="mb-2" open>
<summary class="fw-semibold small text-uppercase text-muted py-1" style="cursor:pointer;">
Guardados, fora da lista acima
</summary>
<div class="row g-1 ps-1 pt-1">
<?php foreach ($foraDaLista as $cc): ?>
<div class="col-6 col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="geo_countries[]"
value="<?= $h($cc) ?>" id="cc-<?= $h($cc) ?>" checked>
<label class="form-check-label" for="cc-<?= $h($cc) ?>">
<code><?= $h($cc) ?></code>
<?php if (!$temDados($cc)): ?>
<span class="badge text-bg-warning ms-1">sem listas</span>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endif; ?>
<div class="row g-2 align-items-end mt-2">
<div class="col-sm-5">
<label for="geo_countries_extra" class="form-label small text-muted mb-1">
Outro país (código ISO)
</label>
<input type="text" class="form-control form-control-sm" id="geo_countries_extra"
name="geo_countries_extra" placeholder="IE, SN" value="">
</div>
<div class="col-sm-7">
<div class="form-text mt-0">
Para um país que não esteja nas caixas. Fica guardado e passa a aparecer aqui.
Valor activo vem de: <strong><?= $h($origin('GEO_ALLOW_COUNTRIES')) ?></strong>.
</div>
</div>
</div>
</div>
<div class="mb-4 p-3 rounded border bg-body-tertiary">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geo_trust_relay"
name="geo_trust_relay" value="1" <?= $trustRelay ? 'checked' : '' ?>>
<label class="form-check-label" for="geo_trust_relay">
<strong>Aceitar o iCloud Private Relay</strong>
<span class="d-block text-muted small">
O Safari com Private Relay sai por endereços da Apple, registados onde a Apple os
registou e não onde está a pessoa — quem está em Aveiro aparece-nos como
estrangeiro e é recusado. A Apple publica o país que cada gama serve, e com esta
opção é esse país que conta. <strong>Não é uma excepção ao filtro:</strong> uma
saída ao serviço de outro país continua a ser recusada como qualquer endereço
estrangeiro.
</span>
</label>
</div>
<div class="small mt-2 ps-4">
<?php if (!$countries): ?>
<span class="text-muted">Defina primeiro os países.</span>
<?php elseif ($relayOk): ?>
<span class="text-success"><i class="bi bi-check-circle me-1"></i>Listas do Relay instaladas.</span>
<?php else: ?>
<span class="text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1"></i>
Ainda sem listas do Relay — carregue em <strong>Actualizar listas</strong>, mais abaixo.
Até lá esta opção não muda nada.
</span>
<?php endif; ?>
</div>
</div>
<div class="mb-4">
<label for="geo_allow_ips" class="form-label fw-semibold">Endereços que passam sempre</label>
<textarea class="form-control font-monospace" id="geo_allow_ips" name="geo_allow_ips"
rows="3" spellcheck="false"
placeholder="85.240.0.0/16&#10;2001:8a0::/32"><?= $h(implode("\n", $allowIps)) ?></textarea>
<div class="form-text">
Um por linha, endereço solto ou gama em CIDR. Passam antes de qualquer outra
verificação — incluindo quando as listas de país estão em falta, que é o caso em que
isto é a única defesa que ainda funciona. Para o cliente que ficou de fora hoje, para
a VPN do escritório, para um serviço de monitorização.
<?php if ($allowIps): ?>
Activos: <strong><?= count($allowIps) ?></strong>, de
<strong><?= $h($origin('GEO_ALLOW_IPS')) ?></strong>.
<?php endif; ?>
</div>
<?php if ($myIp !== '' && !geo_ip_allowlisted($myIp)): ?>
<button type="submit" name="action" value="add_my_ip" class="btn btn-outline-secondary btn-sm mt-2">
<i class="bi bi-plus-lg me-1"></i>Acrescentar o meu endereço (<?= $h($myIp) ?>)
</button>
<?php endif; ?>
</div> </div>
<div class="mb-4"> <div class="mb-4">
@@ -258,11 +533,12 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
<?php else: ?> <?php else: ?>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle mb-0"> <table class="table table-sm align-middle mb-0">
<thead><tr><th>País</th><th>Família</th><th class="text-end">Ranges</th><th>Actualizado</th></tr></thead> <thead><tr><th>País</th><th>Origem</th><th>Família</th><th class="text-end">Gamas</th><th>Actualizado</th></tr></thead>
<tbody> <tbody>
<?php foreach ($dataFiles as $d): ?> <?php foreach ($dataFiles as $d): ?>
<tr> <tr>
<td><?= $h($d['cc']) ?></td> <td><?= $h($d['cc']) ?></td>
<td class="small"><?= $h($d['fonte']) ?></td>
<td><code><?= $h($d['fam']) ?></code></td> <td><code><?= $h($d['fam']) ?></code></td>
<td class="text-end"> <td class="text-end">
<?php if ($d['exists']): ?><?= (int) $d['ranges'] ?> <?php if ($d['exists']): ?><?= (int) $d['ranges'] ?>
@@ -275,8 +551,10 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
</table> </table>
</div> </div>
<p class="form-text mb-0 mt-2"> <p class="form-text mb-0 mt-2">
Origem: ipdeny.com. O cron refresca-as mensalmente; as alocações movem-se devagar. Alocações nacionais da ipdeny.com (mensal chega — movem-se devagar) e saídas do iCloud
Um download curto ou inválido é recusado e a lista anterior mantém-se. Private Relay publicadas pela Apple (semanal, que é o ritmo a que aparecem gamas novas).
As duas são independentes: uma em baixo não impede a outra de actualizar, e um download
curto ou inválido é recusado com a lista anterior a manter-se.
</p> </p>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -331,6 +609,33 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
</p> </p>
</div> </div>
<script>
// Procura entre as caixas de país. É enfeite: sem JavaScript o campo nem
// aparece (nasce com d-none) e a lista fica toda visível, que é o
// comportamento correcto -- a regra 10 do PHILOSOPHY.md diz que sem JS a
// página dá mais informação, não menos.
(function () {
var campo = document.getElementById('geo-filtro');
if (!campo) { return; }
campo.classList.remove('d-none');
campo.addEventListener('input', function () {
var termo = campo.value.trim().toLowerCase();
document.querySelectorAll('.geo-grupo').forEach(function (grupo) {
var visiveis = 0;
grupo.querySelectorAll('.geo-pais').forEach(function (pais) {
var bate = termo === '' || (pais.dataset.nome || '').indexOf(termo) !== -1;
pais.hidden = !bate;
if (bate) { visiveis++; }
});
// Abre o que tem resultados e esconde o grupo que ficou vazio, para
// não ficarem secções fechadas a esconder aquilo que se procurou.
grupo.hidden = visiveis === 0;
if (termo !== '' && visiveis > 0) { grupo.open = true; }
});
});
})();
</script>
<?php <?php
$ftr = dirname(__DIR__, 2) . '/includes/footer.php'; if (is_file($layout_footer)) { include $layout_footer; }
if (is_file($ftr)) { include $ftr; }
+6 -11
View File
@@ -2,9 +2,9 @@
// ─── Arranque partilhado dos endpoints do hub ──────────────────────────────── // ─── Arranque partilhado dos endpoints do hub ────────────────────────────────
// //
// Os três endpoints (health, ops, pair) são iguais em todas as plataformas, mas // Os três endpoints (health, ops, pair) são iguais em todas as plataformas, mas
// o arranque não é: a frota tem dois stacks. O `config/` (dance, design, // o arranque de cada site vive no seu config/db.php, que abre a ligação, e o
// fotografia, mstudio, quack, hub) abre a ligação em config/db.php e tem os // config/functions.php com os helpers. (A silver tinha um stack `src/` à parte
// helpers em config/functions.php; a silver usa `src/` com App\Database. // até 2026-09; passou para o mesmo contrato e o ramo dela saiu daqui.)
// //
// Em vez de dezoito stubs com o arranque copiado — três endpoints × seis sites, // Em vez de dezoito stubs com o arranque copiado — três endpoints × seis sites,
// e o dobro dos sítios onde uma correcção se esquece —, detecta-se aqui. Um // e o dobro dos sítios onde uma correcção se esquece —, detecta-se aqui. Um
@@ -21,9 +21,9 @@
// Não pode é ser tratada como as outras. Aqui distingue-se **não ter** base de // Não pode é ser tratada como as outras. Aqui distingue-se **não ter** base de
// dados de **não conseguir** abri-la: // dados de **não conseguir** abri-la:
// //
// nem config/db.php nem src/db.php existem → não há camada de dados nenhuma, // não há config/db.php → não há camada de dados nenhuma,
// é o desenho do site. Segue sem. // é o desenho do site. Segue sem.
// um deles existe mas o $pdo não apareceu → avaria. 503, como antes. // existe mas o $pdo não apareceu → avaria. 503, como antes.
// //
// A distinção sai da presença dos ficheiros e não de um flag por site, porque um // A distinção sai da presença dos ficheiros e não de um flag por site, porque um
// flag é uma coisa a mais para alguém pôr errada: um site com base de dados a // flag é uma coisa a mais para alguém pôr errada: um site com base de dados a
@@ -39,8 +39,7 @@
$repo_root = dirname(__DIR__, 2); $repo_root = dirname(__DIR__, 2);
/** Este site tem sequer uma camada de dados? Ver a nota acima. */ /** Este site tem sequer uma camada de dados? Ver a nota acima. */
$hub_has_db_layer = is_file($repo_root . '/config/db.php') $hub_has_db_layer = is_file($repo_root . '/config/db.php');
|| is_file($repo_root . '/src/db.php');
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
if (is_file($repo_root . '/config/db.php')) { if (is_file($repo_root . '/config/db.php')) {
@@ -53,10 +52,6 @@ if (!isset($pdo) || !$pdo instanceof PDO) {
if (!function_exists('base_url') && is_file($repo_root . '/config/functions.php')) { if (!function_exists('base_url') && is_file($repo_root . '/config/functions.php')) {
require_once $repo_root . '/config/functions.php'; require_once $repo_root . '/config/functions.php';
} }
} elseif (is_file($repo_root . '/src/db.php')) {
require_once $repo_root . '/src/config.php';
require_once $repo_root . '/src/db.php';
$pdo = \App\Database::getConnection();
} }
} }
+19 -3
View File
@@ -13,7 +13,7 @@
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500); http_response_code(500);
exit('hub_integration.php: sem PDO — o stub tem de abrir a ligação primeiro.'); exit('hub_integration.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
} }
require_once __DIR__ . '/functions_hub.php'; require_once __DIR__ . '/functions_hub.php';
@@ -47,8 +47,20 @@ $notice = null; // nem todos os sites da frota têm flash helpers
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$storageReady) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$storageReady) {
$notice = ['Falta a tabela system_settings nesta instalação — aplica as migrações primeiro.', 'danger']; $notice = ['Falta a tabela system_settings nesta instalação — aplica as migrações primeiro.', 'danger'];
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfOk = function_exists('csrf_verify') // $csrfField below picks csrf_field() (config/ stack: field `_csrf`,
? csrf_verify($_POST['csrf_token'] ?? '') // session key `csrf_token`) whenever it exists, falling back to this
// file's own platform_csrf_field() (field `_pt_csrf`, session key
// `platform_tools_csrf`) otherwise. Validation must check the SAME pair
// it rendered. It used to check for a `csrf_verify()` that no host's
// config/functions_csrf.php actually defines, so that branch never ran —
// every POST fell through to platform_csrf_ok(), which reads `_pt_csrf`
// and never matched the `_csrf` field the page actually renders on any
// config/-stack site. Result: every button here 403'd with "Pedido
// rejeitado" (found on beatriz.andmore.pt, 2026-09-13; likely affects
// every other config/-stack site's hub_integration.php too).
$csrfOk = function_exists('csrf_field')
? (($_POST['_csrf'] ?? '') !== '' && ($_SESSION['csrf_token'] ?? '') !== ''
&& hash_equals((string) $_SESSION['csrf_token'], (string) $_POST['_csrf']))
: platform_csrf_ok(); : platform_csrf_ok();
if (!$csrfOk) { if (!$csrfOk) {
http_response_code(403); http_response_code(403);
@@ -90,6 +102,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$storageReady) {
$notice = ['Chave de leitura removida — o hub deixa de conseguir ver esta plataforma.', 'warning']; $notice = ['Chave de leitura removida — o hub deixa de conseguir ver esta plataforma.', 'warning'];
} }
} }
// As chaves nunca entram no registo — só que foram mexidas, por quem e quando.
if ($act !== '' && isset($notice) && ($notice[1] ?? '') !== 'danger' && function_exists('platform_audit')) {
platform_audit($pdo instanceof PDO ? $pdo : null, 'hub.' . $act, ['target_type' => 'hub', 'summary' => (string) $notice[0]]);
}
} }
$key = hub_api_key(); $key = hub_api_key();
+58 -1
View File
@@ -56,11 +56,60 @@ $default_tools = [
'url' => platform_admin_url('hub_integration.php'), 'url' => platform_admin_url('hub_integration.php'),
'color' => 'secondary', 'color' => 'secondary',
// Um site que ainda não tenha o stub não deve mostrar um cartão para 404. // Um site que ainda não tenha o stub não deve mostrar um cartão para 404.
'available' => is_file($repo_root . '/public/views/sysadmin/hub_integration.php') 'available' => (function_exists('platform_router_enabled') && platform_router_enabled())
|| is_file($repo_root . '/public/views/sysadmin/hub_integration.php')
|| is_file($repo_root . '/public/views/admin/hub_integration.php'), || is_file($repo_root . '/public/views/admin/hub_integration.php'),
], ],
]; ];
// Com o router, todas as páginas partilhadas existem em todos os sites — por
// isso aparecem sozinhas, e uma ferramenta nova chega a toda a frota sem cartão
// nenhum a escrever site a site. Sem router ficam de fora: cada site tem só os
// stubs que tem, e um cartão para 404 é pior do que cartão nenhum.
$routed = function_exists('platform_router_enabled') && platform_router_enabled();
$default_tools = array_merge($default_tools, [
[
'title' => 'Bloqueio de IP',
'description' => 'Endereços recusados à porta por atividade automatizada repetida, e quem está a caminho disso.',
'icon' => 'bi-shield-slash',
'url' => platform_admin_url('ip_bans.php'),
'color' => 'warning',
'available' => $routed,
],
[
'title' => 'Bloqueio por país',
'description' => 'Recusar formulários — ou o site — a quem chega de fora do país.',
'icon' => 'bi-globe-americas',
'url' => platform_admin_url('geo.php'),
'color' => 'warning',
'available' => $routed,
],
[
'title' => 'Restaurar base de dados',
'description' => 'Aplicar um backup de base de dados, com inspecção prévia e cópia de segurança automática do estado actual.',
'icon' => 'bi-arrow-counterclockwise',
'url' => platform_admin_url('restore.php'),
'color' => 'danger',
'available' => $routed,
],
[
'title' => 'Origem das visitas',
'description' => 'De onde vêm as pessoas, com que dispositivo e de que país — e quais dessas origens produzem pedidos.',
'icon' => 'bi-graph-up-arrow',
'url' => platform_admin_url('tracking.php'),
'color' => 'success',
'available' => $routed,
],
[
'title' => 'Registo de auditoria',
'description' => 'Quem fez o quê, a quê e quando — com o antes e o depois de cada alteração.',
'icon' => 'bi-journal-text',
'url' => platform_admin_url('audit.php'),
'color' => 'secondary',
'available' => $routed,
],
]);
// Um site pode esconder um cartão por omissão declarando // Um site pode esconder um cartão por omissão declarando
// $platform_hide_tools = ['smtp_status.php'] no seu stub. Serve para quando a // $platform_hide_tools = ['smtp_status.php'] no seu stub. Serve para quando a
// plataforma tem uma página própria que já faz o mesmo -- o dance tem as // plataforma tem uma página própria que já faz o mesmo -- o dance tem as
@@ -80,6 +129,14 @@ $tools = array_filter(
array_merge($default_tools, $sysadmin_tools ?? []), array_merge($default_tools, $sysadmin_tools ?? []),
fn($t) => !isset($t['available']) || $t['available'] fn($t) => !isset($t['available']) || $t['available']
); );
// Um cartão próprio que aponta para o mesmo sítio que um por omissão não o
// repete (um site que ainda declara «Bloqueio de IP» à mão, por exemplo).
$seen = [];
$tools = array_filter($tools, function ($t) use (&$seen) {
$url = (string) ($t['url'] ?? '');
if ($url === '' || isset($seen[$url])) return $url === '';
return $seen[$url] = true;
});
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php'); $layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php'); $layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
+11 -2
View File
@@ -15,7 +15,7 @@
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500); http_response_code(500);
exit('ip_bans.php: sem PDO — o stub tem de abrir a ligação primeiro.'); exit('ip_bans.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
} }
require_once __DIR__ . '/functions_ip_ban.php'; require_once __DIR__ . '/functions_ip_ban.php';
@@ -35,6 +35,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'unban' && $ip !== '') { if ($action === 'unban' && $ip !== '') {
platform_ip_unban($pdo, $ip); platform_ip_unban($pdo, $ip);
if (function_exists('platform_audit')) platform_audit($pdo, 'ip.unban', ['target_type' => 'ip', 'target_label' => $ip]);
$_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "Banimento de $ip levantado. O histórico de infracções mantém-se."]; $_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "Banimento de $ip levantado. O histórico de infracções mantém-se."];
} elseif ($action === 'ban') { } elseif ($action === 'ban') {
if (!filter_var($ip, FILTER_VALIDATE_IP)) { if (!filter_var($ip, FILTER_VALIDATE_IP)) {
@@ -47,6 +48,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$mins = $dur === 'perm' ? null : max(1, (int) $dur); $mins = $dur === 'perm' ? null : max(1, (int) $dur);
$reason = trim((string) ($_POST['reason'] ?? '')) ?: 'banido manualmente'; $reason = trim((string) ($_POST['reason'] ?? '')) ?: 'banido manualmente';
platform_ip_ban($pdo, $ip, $reason, $mins, 'manual', $uid); platform_ip_ban($pdo, $ip, $reason, $mins, 'manual', $uid);
if (function_exists('platform_audit')) platform_audit($pdo, 'ip.ban', ['target_type' => 'ip', 'target_label' => $ip,
'summary' => $reason, 'changes' => ['minutes' => $mins]]);
$_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "$ip banido."]; $_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "$ip banido."];
} }
} }
@@ -94,6 +97,11 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
escritório, o CGNAT de um operador móvel — e banir para sempre à terceira escritório, o CGNAT de um operador móvel — e banir para sempre à terceira
tentativa apanha pessoas a mais. tentativa apanha pessoas a mais.
</span> </span>
<span class="d-block mt-1 text-muted">
Com o site emparelhado com o hub, os banimentos daqui passam aos outros sites da frota e os
deles chegam cá com a origem <strong>frota</strong>. Levantar um banimento retira as cópias de
todos os sites; o site onde ele nasceu mantém o seu até expirar ou ser levantado lá.
</span>
</div> </div>
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
@@ -113,7 +121,8 @@ $h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8
<tr class="<?= $activo ? '' : 'text-muted' ?>"> <tr class="<?= $activo ? '' : 'text-muted' ?>">
<td><code><?= $h($b['ip']) ?></code></td> <td><code><?= $h($b['ip']) ?></code></td>
<td class="small"><?= $h($b['reason']) ?></td> <td class="small"><?= $h($b['reason']) ?></td>
<td><span class="badge text-bg-<?= $b['source'] === 'manual' ? 'primary' : 'secondary' ?>"><?= $h($b['source']) ?></span></td> <td><span class="badge text-bg-<?= match ($b['source']) { 'manual' => 'primary', 'fleet' => 'info', default => 'secondary' } ?>"
<?= $b['source'] === 'fleet' ? 'title="Banido noutro site da frota. Levantar aqui retira as cópias de todos os sites."' : '' ?>><?= $h($b['source'] === 'fleet' ? 'frota' : $b['source']) ?></span></td>
<td class="small"> <td class="small">
<?php if ($b['expires_at'] === null): ?> <?php if ($b['expires_at'] === null): ?>
<span class="badge text-bg-danger">permanente</span> <span class="badge text-bg-danger">permanente</span>
+49 -3
View File
@@ -680,9 +680,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action_label = 'Atualizar submódulo (' . $sub_path . ')'; $action_label = 'Atualizar submódulo (' . $sub_path . ')';
$action_ok = $r3['ok']; $action_ok = $r3['ok'];
$action_output = ($r3['ok'] $action_output = ($r3['ok']
? "Submódulo {$sub_path} atualizado para {$short_sha}.\nCommit criado — usa o botão git push para publicar." ? "Submódulo {$sub_path} atualizado para {$short_sha}."
: 'Bump aplicado, mas o commit falhou:') : 'Bump aplicado, mas o commit falhou:')
. ($r3['output'] !== '' ? "\n\n" . $r3['output'] : ''); . ($r3['output'] !== '' ? "\n\n" . $r3['output'] : '');
// 5. Publish it in the same action, by default. A bump that stays
// local is exactly what makes the NEXT update (this sysadmin's or
// anyone else's) refuse to fast-forward: the parent repo now has a
// commit the remote doesn't, and platform_update's own divergence
// guard stops rather than guess how to reconcile it. Unticking
// "publicar" leaves the old two-step behaviour (bump, then the
// git push button below) for whoever wants to review first.
if ($r3['ok'] && !empty($_POST['also_push'])) {
$push = git_run(['push'], $repo_root);
$action_output .= "\n\n" . ($push['ok']
? "Publicado (git push)."
: "git push falhou — o bump ficou comitado localmente, publica-o com o botão git push abaixo:")
. ($push['output'] !== '' ? "\n\n" . $push['output'] : '');
$action_ok = $action_ok && $push['ok'];
} elseif ($r3['ok']) {
$action_output .= "\nCommit criado — usa o botão git push para publicar.";
}
if ($r3['ok'] && function_exists('opcache_reset')) @opcache_reset(); if ($r3['ok'] && function_exists('opcache_reset')) @opcache_reset();
if ($r3['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); if ($r3['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break; break;
@@ -703,6 +721,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
break; break;
} }
// Auditoria: cada ação desta página mexe no servidor. As que só leem
// (fetch, composer_check) ficam de fora — registá-las era só ruído.
if ($action !== '' && $action_label !== null && !in_array($action, ['fetch', 'composer_check'], true)
&& function_exists('platform_audit')) {
$post = $_POST; unset($post['action'], $post['_pt_csrf'], $post['csrf_token']);
platform_audit($pdo instanceof PDO ? $pdo : null, 'platform.' . $action, [
'target_type' => 'platform',
'summary' => $action_label . ' — ' . ($action_ok ? 'ok' : 'falhou'),
'changes' => $post ?: null,
]);
}
// ── Post/Redirect/Get ──────────────────────────────────────────────────── // ── Post/Redirect/Get ────────────────────────────────────────────────────
// Stash the result in the session and redirect to a GET of the same URL, so // Stash the result in the session and redirect to a GET of the same URL, so
// a browser refresh re-issues the GET instead of re-POSTing the action // a browser refresh re-issues the GET instead of re-POSTing the action
@@ -1398,10 +1428,26 @@ if (is_file($layout_navbar)) include $layout_navbar;
</button> </button>
</form> </form>
<?php if ($s['behind'] > 0): ?> <?php if ($s['behind'] > 0): ?>
<form method="POST" class="m-0" <form method="POST" class="m-0 d-flex align-items-center gap-2"
onsubmit="return confirm('Atualizar <?= htmlspecialchars(addslashes($s['path'])) ?> para o último commit de origin/<?= htmlspecialchars(addslashes($s['branch'])) ?> e fazer commit do bump no repo da plataforma?');"> onsubmit="return confirm('Atualizar <?= htmlspecialchars(addslashes($s['path'])) ?> para o último commit de origin/<?= htmlspecialchars(addslashes($s['branch'])) ?>, fazer commit do bump e publicá-lo (git push)?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="submodule_update"> <?= platform_csrf_field() ?><input type="hidden" name="action" value="submodule_update">
<input type="hidden" name="submodule_path" value="<?= htmlspecialchars($s['path']) ?>"> <input type="hidden" name="submodule_path" value="<?= htmlspecialchars($s['path']) ?>">
<?php
// Bump sem push é o que faz o repo divergir do remote: o bump fica
// comitado localmente, e a próxima atualização (a tua ou de outra
// pessoa) já não avança em fast-forward — "ALTERAÇÕES LOCAIS REAIS,
// atualização interrompida" (visto em produção, 2026-09-14). Publicar
// logo a seguir ao bump, na MESMA ação, é o que evita isso; por isso
// vem pré-marcada em vez de depender de alguém carregar depois num
// segundo botão "git push" que fica mais abaixo.
?>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" value="1" checked
name="also_push" id="alsoPush-<?= htmlspecialchars($s['path']) ?>">
<label class="form-check-label small text-muted" for="alsoPush-<?= htmlspecialchars($s['path']) ?>">
publicar (git push)
</label>
</div>
<button type="submit" class="btn btn-primary btn-sm"> <button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-arrow-up-circle me-1"></i>Atualizar <i class="bi bi-arrow-up-circle me-1"></i>Atualizar
</button> </button>
+8 -1
View File
@@ -25,7 +25,7 @@
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500); http_response_code(500);
exit('restore.php: sem PDO — o stub tem de abrir a ligação primeiro.'); exit('restore.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
} }
require_once __DIR__ . '/functions_restore.php'; require_once __DIR__ . '/functions_restore.php';
@@ -54,6 +54,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// A partir daqui a base de dados vai ser substituída. // A partir daqui a base de dados vai ser substituída.
$report = restore_db($pdo, $file, ['safety' => true, 'drop_extra_tables' => $dropExtra]); $report = restore_db($pdo, $file, ['safety' => true, 'drop_extra_tables' => $dropExtra]);
// Depois do restauro: a tabela do registo também foi reposta, e é na nova que
// tem de ficar escrito que isto aconteceu.
if (function_exists('platform_audit')) {
platform_audit($pdo, 'backup.restore', ['target_type' => 'backup', 'target_label' => $file,
'summary' => !empty($report['ok']) ? 'Base de dados reposta' : 'Restauro falhou',
'changes' => ['drop_extra_tables' => $dropExtra]]);
}
$_SESSION['restore_report'] = $report; $_SESSION['restore_report'] = $report;
header('Location: ' . $selfUrl, true, 303); header('Location: ' . $selfUrl, true, 303);
exit; exit;
+110
View File
@@ -0,0 +1,110 @@
<?php
// ─── Front controller das páginas de sistema ─────────────────────────────────
//
// Um só stub por site, em vez de um por página:
//
// <?php require __DIR__ . '/../shared/platform-tools/router.php'; // public/platform.php
//
// e cada página partilhada abre em platform.php?tool=<nome>. Uma ferramenta nova
// no platform-tools aparece em todos os sites no próximo update do submódulo,
// sem ficheiro nenhum a criar do lado de lá.
//
// O que os stubs faziam à mão vem agora do platform_connect() do site:
//
// guard quem pode entrar (obrigatório — sem ele isto recusa)
// guards guarda própria de uma ferramenta, p. ex. ['tracking' => 'require_admin']
// replaced_tools ferramenta que o site substitui por página sua:
// ['smtp_status' => 'views/sysadmin/comms.php']
// layout ['header' => …, 'navbar' => …, 'footer' => …]
// dashboard_url o «Voltar» das páginas (relativo ao base_url, ou absoluto);
// em array, por ferramenta — as outras voltam ao índice
// migrations_dir onde vivem as migrações, quando não é database/migrations/
// sysadmin_tools cartões próprios do índice (array, ou callable que o devolve)
// hide_tools cartões por omissão a esconder, p. ex. ['smtp_status']
// page_vars variáveis `platform_*` extra de uma página, p. ex.
// ['platform_error_log_file' => …]
//
// Query string (platform.php?tool=backups) e não /platform/backups: um caminho
// bonito precisa de regras de reescrita, e a frota tem Apache em Docker, cPanel
// e talvez nginx. Isto funciona em todos sem configurar nada.
//
// A tabela de rotas é uma lista fechada, e cada página continua a fazer as suas
// verificações (CSRF, confirmação escrita). O router só despacha e guarda a
// porta — nunca inclui um nome vindo do pedido.
declare(strict_types=1);
require_once __DIR__ . '/hub_bootstrap.php'; // $repo_root, $pdo, config/db.php + functions.php
require_once __DIR__ . '/functions_connector.php';
require_once __DIR__ . '/functions_routes.php';
$tool = (string) ($_GET['tool'] ?? 'index');
$route = platform_routes()[$tool] ?? null;
if ($route === null) {
http_response_code(404);
exit('Ferramenta desconhecida.');
}
$cfg = platform_config();
// Uma guarda por ferramenta quando o site a declara (a Origem das visitas é de
// admin na fotografia, não só de sysadmin); senão a geral.
$guard = $cfg['guards'][$tool] ?? ($cfg['guard'] ?? null);
if ($cfg === [] || !is_callable($guard)) {
// Falha fechada: sem guarda declarada não há forma de saber quem pode entrar,
// e uma página de sistema aberta a qualquer um é pior do que nenhuma.
http_response_code(500);
exit('Este site ainda não declara uma guarda de sysadmin no platform_connect() — '
. 'as páginas de sistema não abrem sem ela.');
}
$guard();
// Um site pode ter página própria no lugar de uma partilhada (as Comunicações
// da fotografia em vez do Estado SMTP: dois editores da mesma configuração
// acabavam por mostrar um o que o outro não gravou). O URL partilhado continua
// a chegar lá.
if (!empty($cfg['replaced_tools'][$tool])) {
header('Location: ' . platform_route_absolute_url((string) $cfg['replaced_tools'][$tool]), true, 302);
exit;
}
if (!$pdo instanceof PDO && !empty($route['needs_db'])) {
http_response_code(503);
exit('Esta ferramenta precisa de base de dados, e este site não tem.');
}
// O que os stubs definiam antes de incluir a página.
foreach (['header', 'navbar', 'footer'] as $part) {
if (!empty($cfg['layout'][$part])) ${'platform_layout_' . $part} = $cfg['layout'][$part];
}
// Em array é por ferramenta, e as que lá não estão voltam ao índice do Sistema
// (é de lá que se chega a elas): ['index' => 'views/admin/users.php'].
if (is_array($cfg['dashboard_url'] ?? null)) {
$platform_dashboard_url = isset($cfg['dashboard_url'][$tool])
? platform_route_absolute_url((string) $cfg['dashboard_url'][$tool])
: platform_route_url('index');
} elseif (!empty($cfg['dashboard_url'])) {
$platform_dashboard_url = platform_route_absolute_url((string) $cfg['dashboard_url']);
}
if (!empty($cfg['migrations_dir'])) {
$GLOBALS['MIGRATIONS_DIR'] = (string) $cfg['migrations_dir'];
}
foreach ((array) ($cfg['page_vars'] ?? []) as $name => $value) {
if (is_string($name) && str_starts_with($name, 'platform_')) ${$name} = $value;
}
if ($tool === 'index') {
$tools = $cfg['sysadmin_tools'] ?? [];
$sysadmin_tools = is_callable($tools) ? (array) $tools() : (array) $tools;
foreach ($sysadmin_tools as &$card) {
if (isset($card['url'])) $card['url'] = platform_route_absolute_url((string) $card['url']);
}
unset($card);
// Sem o .php: o índice compara com str_contains() no URL do cartão, e o do
// router é …?tool=smtp_status, não …/smtp_status.php.
$platform_hide_tools = array_map(fn($h) => preg_replace('/\.php$/', '', (string) $h), (array) ($cfg['hide_tools'] ?? []));
}
if (!empty($route['backup_helpers'])) {
hub_load_backup_helpers();
}
unset($cfg, $tools, $part, $name, $value);
require __DIR__ . '/' . $route['file'];
+1 -1
View File
@@ -16,7 +16,7 @@ require_once __DIR__ . '/functions_csrf.php';
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500); http_response_code(500);
exit('setup.php: no PDO — the stub must open the connection first.'); exit('setup.php: no PDO — the site bootstrap (config/db.php) must open the connection first.');
} }
$repo_root = $repo_root ?? dirname(__DIR__, 2); $repo_root = $repo_root ?? dirname(__DIR__, 2);
$setup_title = $setup_title ?? 'Configuração inicial'; $setup_title = $setup_title ?? 'Configuração inicial';
+6
View File
@@ -85,6 +85,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
platform_settings_set($pdo, 'smtp_password', trim((string) $_POST['smtp_password']), $uid); platform_settings_set($pdo, 'smtp_password', trim((string) $_POST['smtp_password']), $uid);
} }
if (function_exists('platform_audit')) {
platform_audit($pdo, 'settings.smtp', ['target_type' => 'settings', 'summary' => 'Configuração SMTP guardada',
'changes' => ['smtp_host' => $_POST['smtp_host'] ?? null, 'smtp_port' => $_POST['smtp_port'] ?? null,
'smtp_username' => $_POST['smtp_username'] ?? null, 'email_from' => $_POST['email_from'] ?? null,
'smtp_password' => ($_POST['smtp_password'] ?? '') !== '' ? 'alterada' : null]]);
}
$_SESSION['smtp_flash'] = ['type' => 'success', 'message' => 'Configuração guardada.']; $_SESSION['smtp_flash'] = ['type' => 'success', 'message' => 'Configuração guardada.'];
} elseif ($action === 'send_test') { } elseif ($action === 'send_test') {
$to = trim((string) ($_POST['to'] ?? '')); $to = trim((string) ($_POST['to'] ?? ''));
+9 -1
View File
@@ -10,11 +10,19 @@
if (!isset($pdo) || !$pdo instanceof PDO) { if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500); http_response_code(500);
exit('tracking.php: sem PDO — o stub tem de abrir a ligação primeiro.'); exit('tracking.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
} }
require_once __DIR__ . '/functions_tracking.php'; require_once __DIR__ . '/functions_tracking.php';
// As tabelas nascem com a primeira visita registada, porque só as funções de
// escrita as criavam. Numa plataforma acabada de ligar isso punha esta página
// a rebentar com "Table 'visit_sources' doesn't exist" -- um 500 exactamente no
// sítio onde se ia ver se a analítica estava a funcionar, e que se lê como
// avaria e não como "ainda não houve visitas". A chamada é idempotente e esta
// página é de sysadmin, por isso o custo do DDL não conta para nada.
platform_tracking_ensure_tables($pdo);
$days = (int) ($_GET['days'] ?? 30); $days = (int) ($_GET['days'] ?? 30);
if (!in_array($days, [7, 30, 90, 365], true)) { if (!in_array($days, [7, 30, 90, 365], true)) {
$days = 30; $days = 30;
+3
View File
@@ -34,6 +34,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = 'Endereço de email inválido.'; $error = 'Endereço de email inválido.';
} else { } else {
$user = user_find_by_email($pdo, $email); $user = user_find_by_email($pdo, $email);
// Registado exista ou não a conta (sem dizer qual) — pedidos em série
// ao mesmo endereço ficam visíveis.
audit('auth.password_reset_requested', ['target_type' => 'user', 'target_label' => audit_mask_email($email), 'actor' => null]);
if ($user) { if ($user) {
$token = auth_create_reset_token($pdo, (int) $user['id']); $token = auth_create_reset_token($pdo, (int) $user['id']);
send_password_reset_email( send_password_reset_email(
+3
View File
@@ -43,6 +43,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$acct = throttle_account_key((string) ($_POST['email'] ?? '')); $acct = throttle_account_key((string) ($_POST['email'] ?? ''));
if (!throttle_check($pdo, 'login') || !throttle_check($pdo, 'login_acct', 10, 50, $acct)) { if (!throttle_check($pdo, 'login') || !throttle_check($pdo, 'login_acct', 10, 50, $acct)) {
$report_login('login_throttled'); $report_login('login_throttled');
audit('auth.login_throttled', ['target_type' => 'user', 'target_label' => audit_mask_email((string) ($_POST['email'] ?? '')), 'actor' => null]);
throttle_block('login form'); throttle_block('login form');
} }
throttle_record($pdo, 'login'); throttle_record($pdo, 'login');
@@ -51,10 +52,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? ''); $user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
if ($user) { if ($user) {
auth_set_session($user); auth_set_session($user);
audit('auth.login', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) $user['name']]);
header('Location: ' . base_url($destination($user['role']))); header('Location: ' . base_url($destination($user['role'])));
exit; exit;
} }
$report_login('login_failed'); $report_login('login_failed');
audit('auth.login_failed', ['target_type' => 'user', 'target_label' => audit_mask_email((string) ($_POST['email'] ?? '')), 'actor' => null]);
// Deliberately vague — doesn't reveal whether the address exists. // Deliberately vague — doesn't reveal whether the address exists.
$error = 'Email ou palavra-passe incorretos.'; $error = 'Email ou palavra-passe incorretos.';
} }
+3
View File
@@ -5,6 +5,9 @@
require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php'; require __DIR__ . '/../../config/functions.php';
if (!empty($_SESSION['user_id'])) {
audit('auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user_id']]);
}
$_SESSION = []; $_SESSION = [];
// Drop the cookie too, so the browser doesn't keep presenting a dead id. // Drop the cookie too, so the browser doesn't keep presenting a dead id.
if (ini_get('session.use_cookies')) { if (ini_get('session.use_cookies')) {
+9
View File
@@ -0,0 +1,9 @@
<?php
// Sistema → Auditoria: a página partilhada do platform-tools (só leitura, com
// filtros e exportação CSV) sobre o audit_log deste site.
$repo_root = dirname(__DIR__, 2);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
$user = require_sysadmin();
$page_title = 'Auditoria';
require $repo_root . '/shared/platform-tools/audit.php';
+3
View File
@@ -17,6 +17,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check(); csrf_check();
$id = (int) ($_POST['id'] ?? 0); $id = (int) ($_POST['id'] ?? 0);
$action = $_POST['_action'] ?? ''; $action = $_POST['_action'] ?? '';
if (in_array($action, ['retry', 'cancel', 'delete', 'restore'], true) && $id > 0) {
audit('message.' . $action, ['target_type' => 'message', 'target_id' => $id]);
}
if ($id > 0) { if ($id > 0) {
switch ($action) { switch ($action) {
+9
View File
@@ -41,11 +41,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email_opt = isset($_POST['email_opt_out']) ? 1 : 0; $email_opt = isset($_POST['email_opt_out']) ? 1 : 0;
$sms_opt = isset($_POST['sms_opt_out']) ? 1 : 0; $sms_opt = isset($_POST['sms_opt_out']) ? 1 : 0;
$marketing = isset($_POST['marketing_consent']) ? 1 : 0; $marketing = isset($_POST['marketing_consent']) ? 1 : 0;
$before = db_get_user($pdo, (int) $user['id']) ?? [];
$pdo->prepare( $pdo->prepare(
"UPDATE users SET phone=?, address=?, city=?, postal_code=?, "UPDATE users SET phone=?, address=?, city=?, postal_code=?,
email_opt_out=?, sms_opt_out=?, marketing_consent=? email_opt_out=?, sms_opt_out=?, marketing_consent=?
WHERE id=?" WHERE id=?"
)->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]); )->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]);
audit('user.profile_update', [
'target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) $user['name'],
'changes' => platform_audit_diff(
array_intersect_key($before, array_flip(['phone', 'address', 'city', 'postal_code', 'email_opt_out', 'sms_opt_out', 'marketing_consent'])),
['phone' => $phone, 'address' => $address, 'city' => $city, 'postal_code' => $postal_code,
'email_opt_out' => (string) $email_opt, 'sms_opt_out' => (string) $sms_opt, 'marketing_consent' => (string) $marketing]
),
]);
$saved = true; $saved = true;
} }
} }