- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de segurança da frota (o gancho que o login já tinha passa a funcionar) e a correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele. - config/functions_audit.php: audit() sobre platform_audit(); tabela audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação. - Registado: login, falha de login, bloqueio por excesso de tentativas, logout, pedidos de recuperação, mudança/definição de palavra-passe, registo e criação de utilizadores, convites, perfil (antes/depois), todas as definições (db_update_setting, com valor anterior), cada passo da lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens. - Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
11 KiB
PHP
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 site bootstrap (config/db.php) 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>
|