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

The submodule caused every deployment failure this site has had:

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

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

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

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

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;
}
}