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>
This commit is contained in:
andreandClaude Opus 5 committed 2026-09-13 23:06:23 +01:00
1 parent 0e034232e9
commit 4d40adbf64
67 files changed
+17478 -5

No files matched your search

+334
View File
@@ -0,0 +1,334 @@
<?php
// ─── Shared SMTP transport ───────────────────────────────────────────────────
//
// The TRANSPORT half of outbound email only: build a configured PHPMailer,
// apply the dev safety nets, hand the message to SMTP. Deliberately NOT the
// queue: fotografia's message_queue (21 cols, multi-channel, soft-delete),
// hub2's message_queue (19 cols, template-driven) and surf's
// notification_queue (12 cols, keyed on lesson_id + a JSON payload) encode app
// domain, not infrastructure, and have no useful common shape. Each app keeps
// its own queue and calls platform_mail_send() at the point of egress.
//
// Public API:
// platform_mail_config(): array
// platform_mail_available(string $repo_root = ''): bool
// platform_mail_make(string $repo_root = ''): ?PHPMailer
// platform_mail_send(array $msg, string $repo_root = ''): array
// platform_mail_test(string $repo_root = ''): array
/**
* Reads SMTP settings from whichever mechanism the host uses.
*
* Precedence: a constant already defined by the app wins (fotografia and hub2
* define SMTP_HOST et al. during bootstrap), then env()/getenv()/$_ENV for
* hosts that do not. Without this the toolkit would only work on one of them.
*/
/**
* $override lets a host supply settings the toolkit cannot discover.
*
* Not every install exposes SMTP through constants or the environment: hub2
* keeps them in system_settings with the password encrypted, reachable only
* through its own smtp_config(). Without this the transport would silently
* fall back to an unconfigured mailer on those hosts.
*/
function platform_mail_config(?array $override = null): array {
$get = function (string $key, string $default = '') {
if (defined($key)) {
$v = constant($key);
return is_bool($v) ? ($v ? '1' : '') : (string) $v;
}
if (function_exists('env')) {
$v = env($key, null);
if ($v !== null && $v !== '') return (string) $v;
}
$v = getenv($key);
if ($v === false || $v === '') $v = $_ENV[$key] ?? '';
return (string) ($v === false ? $default : ($v === '' ? $default : $v));
};
// Primeiro nome que resolver ganha. Os sites da quinta declaram
// SMTP_USER/SMTP_PASS/SMTP_FROM_EMAIL, este módulo nasceu a ler
// SMTP_USERNAME/SMTP_PASSWORD/EMAIL_FROM, e ninguém dava pela diferença: o
// envio real passa por platform_smtp_config($pdo), que lê a base. Só o
// diagnóstico é que lia daqui -- e mostrava utilizador e password vazios
// enquanto o email saía bem, que é precisamente a altura em que uma página
// de diagnóstico não pode mentir. Pior: platform_mail_send() sem override
// autenticava-se com credenciais vazias.
$first = function (array $keys, string $default = '') use ($get): string {
foreach ($keys as $k) {
$v = $get($k);
if ($v !== '') return $v;
}
return $default;
};
$cfg = [
'host' => $first(['SMTP_HOST']),
'port' => (int) ($get('SMTP_PORT', '587') ?: 587),
'username' => $first(['SMTP_USERNAME', 'SMTP_USER']),
'password' => $first(['SMTP_PASSWORD', 'SMTP_PASS']),
'secure' => $first(['SMTP_SECURE'], 'tls'),
'from' => $first(['EMAIL_FROM', 'SMTP_FROM_EMAIL', 'SMTP_FROM']),
'from_name' => $first(['EMAIL_FROM_NAME', 'NOTIFY_FROM_NAME']),
// Safety nets. Only consulted when APP_ENV is not 'prod', so a
// misconfigured production install cannot silently swallow mail.
'env' => $get('APP_ENV', 'prod'),
'dev_print' => filter_var($get('EMAIL_DEV_PRINT', ''), FILTER_VALIDATE_BOOLEAN),
'dev_redirect' => $get('EMAIL_DEV_REDIRECT'),
];
if ($override) {
foreach ($override as $k => $v) {
// Only accept keys we know, and never let an override blank a value
// the host already resolved -- a partial array must not wipe the rest.
if (array_key_exists($k, $cfg) && $v !== null && $v !== '') $cfg[$k] = $v;
}
}
return $cfg;
}
function platform_mail_is_configured(?array $override = null): bool {
return platform_mail_config($override)['host'] !== '';
}
/**
* Loads PHPMailer, preferring composer's autoloader and falling back to the
* hand-vendored copy some installs carry at vendor/PHPMailer/src/.
* Both layouts exist across the fleet, so neither can be assumed.
*/
function platform_mail_available(string $repo_root = ''): bool {
if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true;
$root = $repo_root !== '' ? rtrim($repo_root, '/') : dirname(__DIR__, 2);
foreach ([$root . '/vendor/autoload.php'] as $auto) {
if (is_file($auto)) {
require_once $auto;
if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true;
}
}
$base = $root . '/vendor/PHPMailer/src/';
if (is_file($base . 'PHPMailer.php')) {
require_once $base . 'Exception.php';
require_once $base . 'PHPMailer.php';
require_once $base . 'SMTP.php';
return class_exists(\PHPMailer\PHPMailer\PHPMailer::class);
}
return false;
}
/** A configured PHPMailer, or null when the class cannot be loaded. */
function platform_mail_make(string $repo_root = '', ?array $override = null) {
if (!platform_mail_available($repo_root)) return null;
$c = platform_mail_config($override);
$mail = new \PHPMailer\PHPMailer\PHPMailer(true); // true = throw on failure
$mail->CharSet = 'UTF-8';
if ($c['host'] !== '') {
$mail->isSMTP();
$mail->Host = $c['host'];
$mail->Port = $c['port'];
$mail->SMTPAuth = ($c['username'] !== '');
$mail->Username = $c['username'];
$mail->Password = $c['password'];
// '' and 'none' both mean plaintext; assigning either to SMTPSecure
// makes PHPMailer attempt a protocol it cannot negotiate.
if ($c['secure'] !== '' && strtolower($c['secure']) !== 'none') {
$mail->SMTPSecure = $c['secure'];
}
} else {
$mail->isMail(); // hand off to the local MTA if one exists
}
if ($c['from'] !== '') {
$mail->setFrom($c['from'], $c['from_name']);
}
return $mail;
}
/**
* Sends one message.
*
* $msg: to, to_name, subject, body (html), alt_body, reply_to, reply_to_name,
* attachments [[path, name], …]
*
* Returns ['ok' => bool, 'skipped' => bool, 'error' => ?string].
* 'skipped' is true when a dev safety net swallowed the message on purpose --
* distinct from a failure, so a queue worker can mark it sent rather than
* retrying forever against a dev box.
*/
function platform_mail_send(array $msg, string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array {
$c = platform_mail_config($override);
$to = trim((string) ($msg['to'] ?? ''));
if ($to === '') {
return ['ok' => false, 'skipped' => false, 'error' => 'Destinatário vazio.'];
}
$isProd = ($c['env'] === 'prod');
// Log instead of sending. Checked BEFORE loading PHPMailer so a host with
// no mailer installed can still run end to end in development.
if (!$isProd && $c['dev_print']) {
error_log(sprintf('[mail:dev_print] to=%s subject=%s', $to, (string) ($msg['subject'] ?? '')));
return ['ok' => true, 'skipped' => true, 'error' => null];
}
// Tecto de envio. Com $pdo, e antes de se gastar tempo a montar o PHPMailer.
// Uma mensagem recusada aqui devolve rate_limited: quem chama de uma fila
// deve deixá-la pendente e voltar a tentar, não marcá-la como falhada.
if ($pdo instanceof PDO) {
require_once __DIR__ . '/functions_mail_rate.php';
$capped = platform_mail_rate_blocked($pdo);
if ($capped !== null) {
error_log('[mail-rate] envio adiado: ' . $capped);
return ['ok' => false, 'skipped' => false, 'rate_limited' => true, 'error' => $capped];
}
}
$mail = platform_mail_make($repo_root, $override);
if ($mail === null) {
return ['ok' => false, 'skipped' => false,
'error' => 'PHPMailer indisponível — corre "composer install", ou activa EMAIL_DEV_PRINT fora de produção.'];
}
try {
// Redirect every recipient to one inbox outside production, so testing
// against a copy of the live database cannot mail real clients.
if (!$isProd && $c['dev_redirect'] !== '') {
$mail->addAddress($c['dev_redirect']);
$mail->Subject = '[DEV] ' . (string) ($msg['subject'] ?? '');
// Banner naming the real recipient. Without it a redirected mailbox
// fills with messages that all look addressed to itself, and it is
// impossible to tell who each one was actually for.
$orig = $to . (($msg['to_name'] ?? '') !== '' ? ' <' . $msg['to_name'] . '>' : '');
$banner = '<div style="background:#fffae6;border:1px solid #e0c060;padding:10px 14px;'
. 'margin-bottom:16px;font-family:Arial,sans-serif;font-size:13px;color:#664400;">'
. '<strong>[DEV redirect]</strong> Destinatário original: '
. htmlspecialchars($orig, ENT_QUOTES, 'UTF-8') . '</div>';
$msg['body'] = $banner . (string) ($msg['body'] ?? '');
} else {
$mail->addAddress($to, (string) ($msg['to_name'] ?? ''));
$mail->Subject = (string) ($msg['subject'] ?? '');
}
if (!empty($msg['reply_to'])) {
$mail->addReplyTo((string) $msg['reply_to'], (string) ($msg['reply_to_name'] ?? ''));
}
$html = (string) ($msg['body'] ?? '');
$text = (string) ($msg['alt_body'] ?? '');
if ($html !== '') {
$mail->isHTML(true);
$mail->Body = $html;
$mail->AltBody = $text !== '' ? $text : trim(strip_tags($html));
} else {
$mail->isHTML(false);
$mail->Body = $text;
}
foreach ((array) ($msg['attachments'] ?? []) as $att) {
if (is_array($att) && !empty($att[0]) && is_file($att[0])) {
$mail->addAttachment($att[0], (string) ($att[1] ?? ''));
}
}
$mail->send();
if ($pdo instanceof PDO) {
platform_mail_rate_record($pdo);
}
return ['ok' => true, 'skipped' => false, 'error' => null];
} catch (\Throwable $e) {
// ErrorInfo carries PHPMailer's own diagnosis, which is usually more
// specific than the exception message.
$err = $mail->ErrorInfo !== '' ? $mail->ErrorInfo : $e->getMessage();
return ['ok' => false, 'skipped' => false, 'error' => $err];
}
}
/**
* Opens an SMTP connection and closes it without sending. Used by the
* "testar ligação" control so a misconfiguration surfaces before a queue
* fills up with failures.
*/
function platform_mail_test(string $repo_root = '', ?array $override = null): array {
if (!platform_mail_is_configured($override)) {
return ['ok' => false, 'error' => 'SMTP_HOST não está configurado.', 'log' => ''];
}
$mail = platform_mail_make($repo_root, $override);
if ($mail === null) {
return ['ok' => false, 'error' => 'PHPMailer indisponível — corre "composer install".', 'log' => ''];
}
$log = '';
$mail->SMTPDebug = 2; // PHPMailer redacts the credential lines itself
$mail->Debugoutput = function ($str) use (&$log) { $log .= $str; };
try {
$ok = $mail->smtpConnect();
$mail->smtpClose();
return ['ok' => (bool) $ok, 'error' => $ok ? '' : 'A ligação foi recusada.', 'log' => $log];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $mail->ErrorInfo !== '' ? $mail->ErrorInfo : $e->getMessage(), 'log' => $log];
}
}
/**
* O que aconteceria a uma mensagem enviada agora mesmo.
*
* Devolve [classe-bootstrap, título, explicação]. Vale a pena ser explícito:
* "configurado" e "entrega" não são a mesma coisa, e uma rede de segurança de
* desenvolvimento a engolir email em silêncio é, vista de fora, indistinguível
* de sucesso.
*
* Vivia dentro do smtp_status.php. Saiu para aqui quando o comms.php passou a
* mostrar o mesmo veredicto -- duas cópias desta lista de casos divergiriam, e
* a que ficasse para trás mentiria a alguém a meio de uma avaria.
*/
function platform_mail_diagnosis(string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array {
// Com $pdo, diagnostica-se a configuração que realmente envia (a da base),
// e não a que estiver no ambiente.
if ($override === null && $pdo !== null && function_exists('platform_smtp_config')) {
$override = platform_smtp_config($pdo);
}
$cfg = platform_mail_config($override);
$isProd = ($cfg['env'] === 'prod');
if (!$isProd && $cfg['dev_print']) {
return ['warning', 'Não entrega', 'EMAIL_DEV_PRINT está activo e APP_ENV não é prod — as mensagens são registadas no log, não enviadas.'];
}
if ($cfg['host'] === '') {
return ['danger', 'Não configurado', 'O servidor SMTP está vazio. Sem servidor configurado o envio falha (não há MTA local neste contentor).'];
}
if (!platform_mail_available($repo_root)) {
return ['danger', 'PHPMailer em falta', 'A classe não foi encontrada — corre "composer install".'];
}
if (!$isProd && $cfg['dev_redirect'] !== '') {
return ['warning', 'Redireccionado', 'Todas as mensagens vão para ' . $cfg['dev_redirect'] . ' em vez do destinatário real (APP_ENV não é prod).'];
}
return ['success', 'Operacional', 'As mensagens são entregues ao servidor SMTP configurado.'];
}
/**
* Os valores em vigor, prontos para uma tabela: [etiqueta, valor, ok?].
*
* A password nunca sai daqui, nem mascarada com o comprimento real -- isso
* ainda diz quantos caracteres tem.
*/
function platform_mail_config_rows(?array $override = null, ?PDO $pdo = null): array {
if ($override === null && $pdo !== null && function_exists('platform_smtp_config')) {
$override = platform_smtp_config($pdo);
}
$cfg = platform_mail_config($override);
$isProd = ($cfg['env'] === 'prod');
return [
['Servidor', $cfg['host'] !== '' ? $cfg['host'] : '— vazio —', $cfg['host'] !== ''],
['Porta', (string) $cfg['port'], true],
['Cifra', $cfg['secure'] !== '' ? $cfg['secure'] : 'nenhuma', true],
['Utilizador', $cfg['username'] !== '' ? $cfg['username'] : '— vazio —', true],
['Palavra-passe', $cfg['password'] !== '' ? '•••••••• (definida)' : '— vazia —', $cfg['password'] !== ''],
['Remetente', $cfg['from'] !== '' ? $cfg['from'] : '— vazio —', $cfg['from'] !== ''],
['Nome do remetente', $cfg['from_name'] !== '' ? $cfg['from_name'] : '— vazio —', true],
['APP_ENV', $cfg['env'], true],
['EMAIL_DEV_PRINT', $cfg['dev_print'] ? 'true' : 'false', !$cfg['dev_print'] || $isProd],
['EMAIL_DEV_REDIRECT', $cfg['dev_redirect'] !== '' ? $cfg['dev_redirect'] : '— vazio —', true],
];
}