shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.
The submodule caused every deployment failure this site has had:
- .gitmodules named no `branch`, so the update fell back to
refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
"Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
revision in submodule path".
- Its URL was http://, which the origin answers with 523.
- This install is deployed by uploading files, and a submodule is invisible
to that. The tree moved forward while shared/platform-tools stayed on the
old pin, config/functions_secrets.php died on a missing compat_secrets.php,
and the whole site — public pages included — went down.
Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.
The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.8 KiB
PHP
154 lines
5.8 KiB
PHP
<?php
|
|
// ─── Tecto de envio de email ────────────────────────────────────────────────
|
|
//
|
|
// A fila de notificações tinha backoff de retentativas mas nenhum tecto: nada
|
|
// impedia que um evento em cadeia despachasse centenas de mensagens seguidas.
|
|
// Foi o que os 14 registos de bot do dance quase produziram em 25/08 -- cada um
|
|
// disparou um email de confirmação para a caixa de um terceiro, e só não foram
|
|
// mais porque os bots pararam.
|
|
//
|
|
// O que isto protege não é o servidor, é a reputação do domínio. Um pico de
|
|
// envios para endereços que nunca pediram nada é exactamente o padrão que os
|
|
// filtros de spam procuram, e recuperar uma reputação queimada leva semanas.
|
|
//
|
|
// Duas janelas, porque protegem de coisas diferentes:
|
|
//
|
|
// MAIL_RATE_PER_MINUTE trava o disparo em cadeia -- o ciclo que envia sem
|
|
// parar por causa de um erro de lógica
|
|
// MAIL_RATE_PER_HOUR trava a campanha lenta -- o bot que manda um por
|
|
// minuto durante a noite e não dispara o primeiro
|
|
//
|
|
// Falha FECHADO, ao contrário do bloqueio por país. Aqui a dúvida é entre não
|
|
// enviar uma mensagem legítima e queimar o domínio: a primeira recupera-se
|
|
// sozinha na retentativa seguinte, a segunda não. Uma mensagem recusada pelo
|
|
// tecto fica na fila, não se perde.
|
|
|
|
require_once __DIR__ . '/functions_settings.php';
|
|
|
|
/**
|
|
* Cria a tabela de contagem se faltar.
|
|
*
|
|
* Tabela própria e não uma contagem sobre notification_queue: nem todos os
|
|
* sites têm fila, e os que têm apagam linhas antigas -- a contagem passaria a
|
|
* depender da política de limpeza de outra coisa.
|
|
*/
|
|
function platform_mail_rate_ensure_table(PDO $pdo): void
|
|
{
|
|
static $done = false;
|
|
if ($done) {
|
|
return;
|
|
}
|
|
$done = true;
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS `mail_sends` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
KEY `idx_mail_sends_at` (`sent_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
");
|
|
}
|
|
|
|
/** Tectos em vigor, da base de dados, do .env, ou os do módulo. */
|
|
function platform_mail_rate_limits(?PDO $pdo = null): array
|
|
{
|
|
$get = function (string $key, int $default) use ($pdo): int {
|
|
if ($pdo instanceof PDO && function_exists('platform_settings_get')) {
|
|
$v = trim((string) platform_settings_get($pdo, strtolower($key), ''));
|
|
if ($v !== '' && ctype_digit($v)) {
|
|
return (int) $v;
|
|
}
|
|
}
|
|
if (function_exists('env')) {
|
|
$v = trim((string) env($key, ''));
|
|
if ($v !== '' && ctype_digit($v)) {
|
|
return (int) $v;
|
|
}
|
|
}
|
|
return $default;
|
|
};
|
|
|
|
return [
|
|
// Generosos de propósito. Isto é uma rede contra disparos em cadeia,
|
|
// não uma quota: um tecto apertado de mais transforma-se em avarias
|
|
// silenciosas no dia em que a academia mandar mesmo 60 confirmações.
|
|
'per_minute' => max(1, $get('MAIL_RATE_PER_MINUTE', 20)),
|
|
'per_hour' => max(1, $get('MAIL_RATE_PER_HOUR', 200)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Pode sair mais uma mensagem agora?
|
|
*
|
|
* Devolve null quando sim, ou o motivo quando não -- para quem chama poder
|
|
* registá-lo em vez de falhar em silêncio.
|
|
*/
|
|
function platform_mail_rate_blocked(PDO $pdo): ?string
|
|
{
|
|
try {
|
|
platform_mail_rate_ensure_table($pdo);
|
|
$lim = platform_mail_rate_limits($pdo);
|
|
|
|
$st = $pdo->query("
|
|
SELECT
|
|
SUM(sent_at >= NOW() - INTERVAL 1 MINUTE) AS m,
|
|
SUM(sent_at >= NOW() - INTERVAL 1 HOUR) AS h
|
|
FROM mail_sends
|
|
WHERE sent_at >= NOW() - INTERVAL 1 HOUR
|
|
");
|
|
$r = $st->fetch(PDO::FETCH_ASSOC) ?: ['m' => 0, 'h' => 0];
|
|
|
|
if ((int) $r['m'] >= $lim['per_minute']) {
|
|
return "tecto de {$lim['per_minute']} envios/minuto atingido";
|
|
}
|
|
if ((int) $r['h'] >= $lim['per_hour']) {
|
|
return "tecto de {$lim['per_hour']} envios/hora atingido";
|
|
}
|
|
return null;
|
|
} catch (Throwable $e) {
|
|
// Falha fechado: sem forma de contar, não se dispara às cegas.
|
|
error_log('[mail-rate] contagem indisponível, a travar por precaução: ' . $e->getMessage());
|
|
return 'contador de envios indisponível';
|
|
}
|
|
}
|
|
|
|
/** Regista um envio. Chamar DEPOIS de a mensagem sair, nunca antes. */
|
|
function platform_mail_rate_record(PDO $pdo): void
|
|
{
|
|
try {
|
|
platform_mail_rate_ensure_table($pdo);
|
|
$pdo->exec("INSERT INTO mail_sends () VALUES ()");
|
|
|
|
// Limpeza oportunista: a tabela só serve para as últimas 24h, e sem
|
|
// isto cresceria para sempre. Uma vez em cada cem inserções chega.
|
|
if (random_int(1, 100) === 1) {
|
|
$pdo->exec("DELETE FROM mail_sends WHERE sent_at < NOW() - INTERVAL 1 DAY");
|
|
}
|
|
} catch (Throwable $e) {
|
|
error_log('[mail-rate] não registou o envio: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/** Quantos saíram no último minuto e na última hora — para a página de estado. */
|
|
function platform_mail_rate_usage(PDO $pdo): array
|
|
{
|
|
$lim = platform_mail_rate_limits($pdo);
|
|
try {
|
|
platform_mail_rate_ensure_table($pdo);
|
|
$r = $pdo->query("
|
|
SELECT
|
|
SUM(sent_at >= NOW() - INTERVAL 1 MINUTE) AS m,
|
|
SUM(sent_at >= NOW() - INTERVAL 1 HOUR) AS h
|
|
FROM mail_sends
|
|
WHERE sent_at >= NOW() - INTERVAL 1 HOUR
|
|
")->fetch(PDO::FETCH_ASSOC) ?: [];
|
|
return [
|
|
'minute' => (int) ($r['m'] ?? 0),
|
|
'hour' => (int) ($r['h'] ?? 0),
|
|
'per_minute' => $lim['per_minute'],
|
|
'per_hour' => $lim['per_hour'],
|
|
];
|
|
} catch (Throwable) {
|
|
return ['minute' => 0, 'hour' => 0] + $lim;
|
|
}
|
|
}
|