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:
1 parent
5e300fc341
commit
0e8b53f620
65 files changed
+5852
-455
No files matched your search
+9
-24
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'] ?? '');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+731
-220
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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() : [],
|
||||||
]);
|
]);
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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])) ?>">← 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 →</a><?php else: ?><span></span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if (is_file($layout_footer)) include $layout_footer; ?>
|
||||||
@@ -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();
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 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; }
|
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'];
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'] ?? ''));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user