Files
fitme3070.pt/shared/platform-tools/setup.php
T
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

236 lines
11 KiB
PHP

<?php
// ─── Shared first-run setup page ─────────────────────────────────────────────
//
// A view fragment, like platform_update.php and backups.php: the site's own
// setup.php stub opens the PDO, defines $repo_root and $setup_title, then
// requires this file.
//
// Who may run it. On a fresh install there is no one to authenticate, so the
// page must be usable before any account exists -- but it writes credentials,
// so it cannot stay open afterwards. The rule: open while the platform has no
// sysadmin, sysadmin-only once it has one. That way the window closes by
// itself, without anyone remembering to delete a file.
require_once __DIR__ . '/functions_setup.php';
require_once __DIR__ . '/functions_csrf.php';
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('setup.php: no PDO — the stub must open the connection first.');
}
$repo_root = $repo_root ?? dirname(__DIR__, 2);
$setup_title = $setup_title ?? 'Configuração inicial';
// ─── Access ─────────────────────────────────────────────────────────────────
// Fail closed. The only state that legitimately opens this page is "the users
// table does not exist yet" -- a genuinely fresh install. Anything else that
// goes wrong (a query error, a missing helper, a permissions problem) is
// treated as "already configured", because a page that writes SMTP credentials
// must not become public the moment an unrelated thing breaks.
//
// This was originally a try/catch around the whole check that set
// $sysadminExists = false on any Throwable. On a site that had not wired the
// shared auth, auth_schema() was undefined, the catch swallowed the Error, and
// the page served itself to anonymous visitors on an install that had a
// sysadmin -- confirmed on fotografia. That is the wrong direction to fail in.
$s = platform_setup_user_schema();
$usersTableExists = false;
try {
$usersTableExists = $pdo->query("SHOW TABLES LIKE " . $pdo->quote($s['table']))->fetchColumn() !== false;
} catch (Throwable $e) {
$usersTableExists = true; // cannot tell -> assume configured
}
if (!$usersTableExists) {
$sysadminExists = false; // fresh install: nobody to authenticate yet
} else {
try {
$sysadminExists = (bool) $pdo->query(
"SELECT 1 FROM `{$s['table']}` WHERE `{$s['role']}` = 'sysadmin' LIMIT 1"
)->fetchColumn();
} catch (Throwable $e) {
error_log('[setup] sysadmin check failed, denying access: ' . $e->getMessage());
$sysadminExists = true;
}
}
$isSysadmin = function_exists('auth_is_sysadmin')
? auth_is_sysadmin()
: (($_SESSION['user']['role'] ?? '') === 'sysadmin');
if ($sysadminExists && !$isSysadmin) {
http_response_code(403);
exit('Configuração já concluída. Entre como sysadmin para a alterar.');
}
// ─── Actions ────────────────────────────────────────────────────────────────
$errors = [];
$saved = false;
$test = null;
$state = platform_setup_state($pdo);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!platform_csrf_ok()) {
$errors[] = 'Pedido rejeitado (token CSRF ausente ou inválido). Recarregue a página.';
} else {
$action = (string) ($_POST['_action'] ?? 'save');
$uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null;
$r = platform_setup_save_smtp($pdo, $_POST, $uid);
if (!$r['ok']) {
$errors = $r['errors'];
} else {
$saved = true;
$state = platform_setup_state($pdo);
if ($action === 'save_test') {
$test = platform_setup_test_smtp($pdo, $repo_root);
}
}
}
}
$c = $state['config'];
$h = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?><!doctype html>
<html lang="pt"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= $h($setup_title) ?></title>
<style>
:root { color-scheme: light dark; --ink:#14181B; --soft:#4A555A; --line:#D3DADC;
--bg:#F4F6F7; --card:#FFF; --ok:#35704A; --okbg:#DFEDE3;
--bad:#AF3A2B; --badbg:#F7E3E0; --warn:#8C6410; --warnbg:#F6ECD6; --accent:#0E6E6B; }
@media (prefers-color-scheme: dark) { :root {
--ink:#E3E8E9; --soft:#A5B1B5; --line:#2A3336; --bg:#0F1315; --card:#161B1E;
--ok:#74B98C; --okbg:#172C1F; --bad:#E8867A; --badbg:#35201C;
--warn:#D9AC55; --warnbg:#2F2716; --accent:#4FC0B8; } }
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);
font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:0 1.25rem 5rem}
.wrap{max-width:44rem;margin:0 auto;padding-top:3.5rem}
h1{font-size:1.7rem;letter-spacing:-.02em;margin:0 0 .4rem}
.lede{color:var(--soft);margin:0 0 1.75rem}
.card{background:var(--card);border:1px solid var(--line);padding:1.5rem;margin-bottom:1.25rem}
label{display:block;font-weight:600;font-size:.9rem;margin-bottom:.3rem}
.hint{color:var(--soft);font-size:.82rem;margin:.3rem 0 0}
input,select{width:100%;padding:.55rem .7rem;border:1px solid var(--line);
background:var(--bg);color:var(--ink);font:inherit;font-size:.95rem}
.row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.field{margin-bottom:1.1rem}
.actions{display:flex;gap:.75rem;flex-wrap:wrap;margin-top:1.5rem}
button{padding:.6rem 1.1rem;border:1px solid var(--accent);background:var(--accent);
color:var(--card);font:inherit;font-weight:600;cursor:pointer}
button.ghost{background:transparent;color:var(--accent)}
button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.msg{padding:.75rem 1rem;margin-bottom:1rem;font-size:.92rem;border-left:3px solid}
.msg.ok{background:var(--okbg);color:var(--ok);border-color:var(--ok)}
.msg.bad{background:var(--badbg);color:var(--bad);border-color:var(--bad)}
.msg.warn{background:var(--warnbg);color:var(--warn);border-color:var(--warn)}
pre{background:var(--bg);border:1px solid var(--line);padding:.8rem;overflow-x:auto;
font:.8rem/1.5 ui-monospace,Menlo,Consolas,monospace;color:var(--soft);margin:.75rem 0 0}
.why{border-left:3px solid var(--line);padding-left:1rem;color:var(--soft);font-size:.9rem}
</style>
</head><body>
<div class="wrap">
<h1><?= $h($setup_title) ?></h1>
<p class="lede">
Falta configurar o envio de email. A plataforma fica retida aqui até estar feito.
</p>
<?php foreach ($errors as $e): ?>
<div class="msg bad"><?= $h($e) ?></div>
<?php endforeach; ?>
<?php if ($saved && $state['smtp_ok']): ?>
<div class="msg ok">Configuração guardada. A plataforma já pode enviar email.</div>
<?php elseif ($saved): ?>
<div class="msg warn">Guardado, mas ainda falta: <?= $h(implode(', ', $state['missing'])) ?>.</div>
<?php endif; ?>
<?php if (!$state['secrets_ok']): ?>
<div class="msg bad">
<strong>APP_SECRET_KEY não está definida no <code>.env</code>.</strong>
A palavra-passe de SMTP seria guardada em claro na base de dados — e a base
de dados vai para dentro de cada backup. Defina a chave antes de continuar.
</div>
<?php endif; ?>
<?php if ($test !== null): ?>
<div class="msg <?= !empty($test['ok']) ? 'ok' : 'bad' ?>">
<?= !empty($test['ok'])
? 'Ligação e autenticação bem-sucedidas.'
: $h((string) ($test['error'] ?? 'Não foi possível ligar/autenticar.')) ?>
</div>
<?php if (!empty($test['log'])): ?><pre><?= $h($test['log']) ?></pre><?php endif; ?>
<?php endif; ?>
<form method="post" class="card">
<?= platform_csrf_field() ?>
<div class="row">
<div class="field">
<label for="smtp_host">Servidor SMTP</label>
<input id="smtp_host" name="smtp_host" required value="<?= $h($c['host'] ?? '') ?>"
autocomplete="off" placeholder="smtp.exemplo.pt">
</div>
<div class="field">
<label for="smtp_port">Porta</label>
<input id="smtp_port" name="smtp_port" type="number" min="1" max="65535"
value="<?= $h($c['port'] ?: 587) ?>">
<p class="hint">587 para TLS, 465 para SSL.</p>
</div>
</div>
<div class="field">
<label for="smtp_secure">Encriptação</label>
<select id="smtp_secure" name="smtp_secure">
<?php foreach (['tls' => 'TLS (StartTLS)', 'ssl' => 'SSL', '' => 'Nenhuma'] as $v => $lbl): ?>
<option value="<?= $h($v) ?>" <?= ($c['secure'] ?? 'tls') === $v ? 'selected' : '' ?>><?= $h($lbl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="row">
<div class="field">
<label for="smtp_username">Utilizador</label>
<input id="smtp_username" name="smtp_username" value="<?= $h($c['username'] ?? '') ?>"
autocomplete="off">
</div>
<div class="field">
<label for="smtp_password">Palavra-passe</label>
<input id="smtp_password" name="smtp_password" type="password" autocomplete="new-password"
placeholder="<?= ($c['password'] ?? '') !== '' ? '•••••••• (definida)' : '' ?>">
<p class="hint">
<?= ($c['password'] ?? '') !== ''
? 'Deixe vazio para manter a atual.'
: 'Guardada cifrada (AES-256-GCM); a chave vive só no .env.' ?>
</p>
</div>
</div>
<div class="row">
<div class="field">
<label for="email_from">Remetente</label>
<input id="email_from" name="email_from" type="email" required
value="<?= $h($c['from'] ?? '') ?>" placeholder="noreply@exemplo.pt">
</div>
<div class="field">
<label for="email_from_name">Nome do remetente</label>
<input id="email_from_name" name="email_from_name" value="<?= $h($c['from_name'] ?? '') ?>">
</div>
</div>
<div class="actions">
<button type="submit" name="_action" value="save_test">Guardar e testar ligação</button>
<button type="submit" name="_action" value="save" class="ghost">Guardar</button>
</div>
</form>
<p class="why">
Porque é obrigatório: sem SMTP ninguém recupera a palavra-passe, nenhum aviso
chega e nenhum formulário de contacto é entregue — e nada disso falha de
forma visível. Falha como silêncio, semanas depois.
</p>
</div>
</body></html>