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>
184 lines
7.8 KiB
PHP
184 lines
7.8 KiB
PHP
<?php
|
|
// ─── First-run setup gate ────────────────────────────────────────────────────
|
|
//
|
|
// A platform that cannot send mail is a platform where nobody can recover an
|
|
// account, no notification arrives, and no contact form reaches anyone -- and
|
|
// none of that fails visibly. It fails as silence, weeks later, which is the
|
|
// worst way for it to fail.
|
|
//
|
|
// So SMTP is not optional configuration discovered later: it is part of
|
|
// standing the site up. Until it is configured, platform_setup_guard() holds
|
|
// the site on the setup page.
|
|
//
|
|
// Wiring, in the site's bootstrap after the PDO is open and the session
|
|
// started:
|
|
//
|
|
// require_once __DIR__ . '/../shared/platform-tools/functions_setup.php';
|
|
// platform_setup_guard($pdo, base_url('setup.php'), [
|
|
// 'only' => ['/views/admin/', '/views/sysadmin/'],
|
|
// ]);
|
|
|
|
require_once __DIR__ . '/functions_settings.php';
|
|
require_once __DIR__ . '/functions_mail.php';
|
|
|
|
// Deliberately NOT requiring functions_auth.php. hub2 already defines its own
|
|
// auth_login() in config/functions_user.php, so pulling the shared auth in here
|
|
// would fatal on a redeclare for any site that has not migrated yet -- and the
|
|
// setup gate has to work on exactly those sites. setup.php needs two column
|
|
// names, not the whole module.
|
|
|
|
/**
|
|
* The users table and role column, for the setup page's access check.
|
|
*
|
|
* Uses auth_schema() when the site has wired the shared auth, and the same
|
|
* defaults otherwise. Kept separate from auth_schema() so this file has no
|
|
* dependency on it: a missing helper must never be the reason a page that
|
|
* writes SMTP credentials decides nobody is an admin.
|
|
*/
|
|
function platform_setup_user_schema(): array {
|
|
if (function_exists('auth_schema')) {
|
|
$s = auth_schema();
|
|
return ['table' => $s['table'], 'role' => $s['role']];
|
|
}
|
|
return ['table' => 'users', 'role' => 'role'];
|
|
}
|
|
|
|
/**
|
|
* Is outbound mail actually configured?
|
|
*
|
|
* Deliberately checks *resolved* values, not whether a key exists. Every SMTP
|
|
* key on three of the four sites existed in .env with an empty value, which a
|
|
* presence check reads as configured and a value check reads as broken -- that
|
|
* distinction is what hid the estate-wide outage.
|
|
*
|
|
* A host and a from-address are the minimum: without the first nothing can be
|
|
* sent, and without the second nothing that is sent has a sender.
|
|
*/
|
|
function platform_setup_smtp_ok(PDO $pdo): bool {
|
|
$c = platform_smtp_config($pdo);
|
|
return trim((string) ($c['host'] ?? '')) !== ''
|
|
&& trim((string) ($c['from'] ?? '')) !== '';
|
|
}
|
|
|
|
/** Per-field detail, for rendering the setup form and its warnings. */
|
|
function platform_setup_state(PDO $pdo): array {
|
|
$c = platform_smtp_config($pdo);
|
|
return [
|
|
'smtp_ok' => platform_setup_smtp_ok($pdo),
|
|
'config' => $c,
|
|
'secrets_ok' => platform_settings_secret_available(),
|
|
'mailer_present' => function_exists('platform_mail_available'),
|
|
'missing' => array_values(array_filter([
|
|
trim((string) ($c['host'] ?? '')) === '' ? 'smtp_host' : null,
|
|
trim((string) ($c['from'] ?? '')) === '' ? 'email_from' : null,
|
|
])),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hold the backoffice on the setup page until SMTP is configured.
|
|
*
|
|
* `only` lists the path prefixes the gate applies to, and it is the option to
|
|
* use: the gate belongs on the backoffice, not the public site. A portfolio, a
|
|
* class timetable or a public tool has no reason to go dark because outbound
|
|
* mail is unconfigured, and taking the shopfront down to nag the operator
|
|
* punishes the wrong person. The backoffice is where the operator already is,
|
|
* and where the setup page is reachable from.
|
|
*
|
|
* With `only` omitted the gate covers everything, minus `exempt`. That is the
|
|
* right shape only for an install with no public face at all.
|
|
*
|
|
* Two exclusions are unconditional. CLI is never gated: a cron worker or a
|
|
* migration must still run on a half-configured install, and a redirect means
|
|
* nothing without a browser. And a caller that wants JSON gets a 503, never a
|
|
* 302 to an HTML page -- anything expecting JSON would parse that as success.
|
|
*/
|
|
function platform_setup_guard(PDO $pdo, string $setupUrl, array $opts = []): void {
|
|
if (PHP_SAPI === 'cli') return;
|
|
|
|
static $checked = false;
|
|
if ($checked) return;
|
|
$checked = true;
|
|
|
|
if (platform_setup_smtp_ok($pdo)) return;
|
|
|
|
$path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
|
|
|
// Never redirect the setup page to itself.
|
|
$setupPath = (string) parse_url($setupUrl, PHP_URL_PATH);
|
|
if ($setupPath !== '' && str_starts_with($path, $setupPath)) return;
|
|
|
|
// Scoped mode: anything outside the listed prefixes is public, and stays up.
|
|
$only = (array) ($opts['only'] ?? []);
|
|
if ($only) {
|
|
$inScope = false;
|
|
foreach ($only as $prefix) {
|
|
if ($prefix !== '' && str_contains($path, (string) $prefix)) { $inScope = true; break; }
|
|
}
|
|
if (!$inScope) return;
|
|
}
|
|
|
|
foreach ((array) ($opts['exempt'] ?? []) as $prefix) {
|
|
if ($prefix !== '' && str_contains($path, (string) $prefix)) return;
|
|
}
|
|
|
|
// A machine caller gets a machine answer. A 302 to an HTML page would be
|
|
// parsed as a successful response by anything expecting JSON.
|
|
$wantsJson = str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')
|
|
|| str_contains($path, '/api/');
|
|
if ($wantsJson) {
|
|
http_response_code(503);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode([
|
|
'status' => 'error',
|
|
'error' => 'Plataforma por configurar: falta o SMTP.',
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
header('Location: ' . $setupUrl);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Save the SMTP settings.
|
|
*
|
|
* Writes through platform_settings_set(), so smtp_password is encrypted at
|
|
* rest by the estate-wide policy rather than by anything decided here. An empty
|
|
* password means "leave the stored one alone" -- the field is never rendered
|
|
* with the real value, so blank cannot mean "clear it".
|
|
*/
|
|
function platform_setup_save_smtp(PDO $pdo, array $in, ?int $userId = null): array {
|
|
$host = trim((string) ($in['smtp_host'] ?? ''));
|
|
$from = trim((string) ($in['email_from'] ?? ''));
|
|
$port = (int) ($in['smtp_port'] ?? 587);
|
|
|
|
$errors = [];
|
|
if ($host === '') $errors[] = 'Indique o servidor SMTP.';
|
|
if ($from === '' || !filter_var($from, FILTER_VALIDATE_EMAIL))
|
|
$errors[] = 'Indique um remetente válido.';
|
|
if ($port < 1 || $port > 65535) $errors[] = 'Porta inválida.';
|
|
if ($errors) return ['ok' => false, 'errors' => $errors];
|
|
|
|
platform_settings_set($pdo, 'smtp_host', $host, $userId);
|
|
platform_settings_set($pdo, 'smtp_port', (string) $port, $userId);
|
|
platform_settings_set($pdo, 'smtp_secure', (string) ($in['smtp_secure'] ?? 'tls'), $userId);
|
|
platform_settings_set($pdo, 'smtp_username', trim((string) ($in['smtp_username'] ?? '')), $userId);
|
|
platform_settings_set($pdo, 'email_from', $from, $userId);
|
|
platform_settings_set($pdo, 'email_from_name', trim((string) ($in['email_from_name'] ?? '')), $userId);
|
|
|
|
$pw = (string) ($in['smtp_password'] ?? '');
|
|
if ($pw !== '') {
|
|
// platform_settings_is_secret('smtp_password') is true, so this is
|
|
// encrypted on the way in -- no flag passed, no caller decision.
|
|
platform_settings_set($pdo, 'smtp_password', $pw, $userId);
|
|
}
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
/** Connect-and-authenticate probe against the values just saved. */
|
|
function platform_setup_test_smtp(PDO $pdo, string $repoRoot = ''): array {
|
|
return platform_mail_test($repoRoot, platform_smtp_config($pdo));
|
|
}
|