Files
fitme3070.pt/shared/platform-tools/restore.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- 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>
2026-09-25 07:28:13 +01:00

258 lines
12 KiB
PHP

<?php
// ─── Página de restauro (partilhada) ────────────────────────────────────────
//
// Contraparte da backups.php. O functions_restore.php existia há tempo e estava
// completo — inspecção, backup de segurança, divisão de statements, remoção de
// tabelas que o dump não conhece — mas nenhuma página o expunha. Havia backups
// que ninguém sabia se restauravam, que é o mesmo que não ter backups.
//
// Contrato do stub, como o backups.php: $pdo, guarda de sysadmin, $repo_root.
//
// Três passos, e não um botão:
//
// 1. escolher a lista só mostra artefactos de base de dados
// 2. inspeccionar o que está dentro do ZIP, antes de se decidir
// 3. confirmar escrevendo o nome do ficheiro à mão
//
// O passo 3 é de propósito. Uma checkbox aprende-se a clicar sem ler; escrever
// "db_backup_25_08_2026_02_00_11_a3f9c1.zip" obriga a olhar para QUAL artefacto
// se está prestes a aplicar. Isto apaga a base de dados inteira -- não é o sítio
// para poupar dois segundos ao utilizador.
//
// O restauro tira sempre um backup de segurança do estado actual antes de tocar
// em nada, e o nome dele aparece no relatório: restaurar o artefacto errado é,
// ele próprio, recuperável.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('restore.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_restore.php';
require_once __DIR__ . '/functions_csrf.php';
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
// ── POST ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!platform_csrf_ok()) {
$_SESSION['restore_flash'] = ['type' => 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).'];
header('Location: ' . $selfUrl, true, 303);
exit;
}
$file = (string) ($_POST['filename'] ?? '');
$typed = trim((string) ($_POST['confirm_name'] ?? ''));
$dropExtra = !empty($_POST['drop_extra']);
if ($typed !== $file) {
$_SESSION['restore_flash'] = ['type' => 'warning', 'msg' =>
'O nome escrito não corresponde ao ficheiro escolhido — nada foi feito.'];
header('Location: ' . $selfUrl . '?f=' . urlencode($file), true, 303);
exit;
}
// A partir daqui a base de dados vai ser substituída.
$report = restore_db($pdo, $file, ['safety' => true, 'drop_extra_tables' => $dropExtra]);
// Depois do restauro: a tabela do registo também foi reposta, e é na nova que
// tem de ficar escrito que isto aconteceu.
if (function_exists('platform_audit')) {
platform_audit($pdo, 'backup.restore', ['target_type' => 'backup', 'target_label' => $file,
'summary' => !empty($report['ok']) ? 'Base de dados reposta' : 'Restauro falhou',
'changes' => ['drop_extra_tables' => $dropExtra]]);
}
$_SESSION['restore_report'] = $report;
header('Location: ' . $selfUrl, true, 303);
exit;
}
$flash = $_SESSION['restore_flash'] ?? null;
$report = $_SESSION['restore_report'] ?? null;
unset($_SESSION['restore_flash'], $_SESSION['restore_report']);
$all = backup_list();
$dbFiles = array_values(array_filter($all, fn($b) => ($b['type'] ?? '') === 'db'));
$selected = (string) ($_GET['f'] ?? '');
$inspect = $selected !== '' ? restore_inspect($selected) : null;
$tables = restore_current_tables($pdo);
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$backupsUrl = $platform_backups_url
?? (function_exists('platform_admin_url') ? platform_admin_url('backups.php') : 'backups.php');
$pageTitle = 'Restaurar base de dados';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4" style="max-width:900px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-arrow-counterclockwise me-2"></i>Restaurar base de dados</h3>
<p class="text-muted mb-0">Substitui o esquema e os dados actuais por um artefacto de backup.</p>
</div>
<a href="<?= $h($backupsUrl) ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-archive me-1"></i>Backups
</a>
</div>
<?php if ($flash): ?>
<div class="alert alert-<?= $h($flash['type']) ?> py-2"><?= $h($flash['msg']) ?></div>
<?php endif; ?>
<?php if (is_array($report)): ?>
<div class="card shadow-sm mb-4 border-<?= $report['ok'] ? 'success' : 'danger' ?>">
<div class="card-header fw-semibold">
<i class="bi bi-<?= $report['ok'] ? 'check-circle' : 'x-octagon' ?> me-1"></i>
<?= $report['ok'] ? 'Restauro concluído' : 'Restauro falhou' ?>
</div>
<div class="card-body">
<table class="table table-sm align-middle mb-0">
<tbody>
<tr><td class="text-muted" style="width:14rem;">Artefacto aplicado</td>
<td><code><?= $h($report['filename']) ?></code></td></tr>
<tr><td class="text-muted">Backup de segurança</td>
<td><?= $report['safety_backup']
? '<code>' . $h($report['safety_backup']) . '</code>'
: '<span class="text-danger">nenhum</span>' ?></td></tr>
<tr><td class="text-muted">Statements</td>
<td><?= (int) $report['applied'] ?> aplicados de <?= (int) $report['statements'] ?></td></tr>
<?php if (isset($report['tables_before'])): ?>
<tr><td class="text-muted">Tabelas</td>
<td><?= (int) $report['tables_before'] ?> antes &rarr; <?= (int) $report['tables_after'] ?> depois</td></tr>
<?php endif; ?>
<?php if (!empty($report['dropped_extra'])): ?>
<tr><td class="text-muted">Tabelas removidas</td>
<td><code><?= $h(implode(', ', $report['dropped_extra'])) ?></code>
<div class="form-text">Existiam aqui e não no artefacto.</div></td></tr>
<?php endif; ?>
<?php if (!empty($report['error'])): ?>
<tr><td class="text-muted">Erro</td>
<td class="text-danger"><?= $h($report['error']) ?></td></tr>
<?php endif; ?>
</tbody>
</table>
<?php if (!$report['ok'] && $report['safety_backup']): ?>
<p class="form-text mb-0 mt-2">
O estado anterior está em <code><?= $h($report['safety_backup']) ?></code> —
restaurá-lo devolve a base ao que era antes desta tentativa.
</p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="alert alert-warning d-flex gap-2 align-items-start">
<i class="bi bi-exclamation-triangle-fill mt-1"></i>
<div>
<strong>Isto é destrutivo.</strong> A base de dados actual
(<?= count($tables) ?> tabelas) é substituída pela do artefacto. Antes de tocar
em nada é tirado um backup de segurança do estado actual, cujo nome aparece no
relatório — mas planeie o restauro para uma altura em que ninguém esteja a usar
a plataforma.
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-list-ul me-1"></i>Artefactos de base de dados</div>
<div class="card-body">
<?php if (!$dbFiles): ?>
<p class="text-muted mb-0">
Não há backups de base de dados em <code>backups/</code>.
Crie um a partir da página de <a href="<?= $h($backupsUrl) ?>">Backups</a>.
</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Ficheiro</th><th class="text-end">Tamanho</th><th>Criado</th><th></th></tr></thead>
<tbody>
<?php foreach ($dbFiles as $b): ?>
<tr<?= $b['filename'] === $selected ? ' class="table-active"' : '' ?>>
<td><code class="small"><?= $h($b['filename']) ?></code></td>
<td class="text-end text-muted small"><?= $h($b['size_human']) ?></td>
<td class="text-muted small"><?= $h(date('Y-m-d H:i', (int) $b['created_at'])) ?></td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm"
href="<?= $h($selfUrl) ?>?f=<?= urlencode($b['filename']) ?>">Inspeccionar</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($inspect !== null): ?>
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-search me-1"></i>Inspecção</div>
<div class="card-body">
<?php if (!$inspect['ok']): ?>
<div class="alert alert-danger py-2 mb-0"><?= $h($inspect['error']) ?></div>
<?php elseif ($inspect['type'] !== 'db'): ?>
<div class="alert alert-warning py-2 mb-0">
<code><?= $h($inspect['filename']) ?></code> é um backup de ficheiros, não de base de dados.
</div>
<?php else: ?>
<table class="table table-sm align-middle">
<tbody>
<tr><td class="text-muted" style="width:14rem;">Ficheiro</td>
<td><code><?= $h($inspect['filename']) ?></code></td></tr>
<tr><td class="text-muted">Tamanho</td><td><?= $h($inspect['size_human']) ?></td></tr>
<tr><td class="text-muted">Entradas no ZIP</td><td><?= (int) $inspect['entries'] ?></td></tr>
<?php if (!empty($inspect['sql_entry'])): ?>
<tr><td class="text-muted">Dump</td><td><code><?= $h($inspect['sql_entry']) ?></code></td></tr>
<?php endif; ?>
</tbody>
</table>
<hr>
<form method="post" class="d-flex flex-column gap-3">
<?= platform_csrf_field() ?>
<input type="hidden" name="filename" value="<?= $h($inspect['filename']) ?>">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="dropExtra" name="drop_extra" value="1" checked>
<label class="form-check-label" for="dropExtra">
Remover tabelas que o artefacto não conhece
<span class="d-block text-muted small">
Sem isto, uma tabela criada depois deste backup sobrevive e deixa o
esquema num estado que o artefacto nunca descreveu. Desligue só se
souber que quer manter alguma.
</span>
</label>
</div>
<div>
<label class="form-label small fw-semibold" for="confirmName">
Para confirmar, escreva o nome do ficheiro
</label>
<input type="text" class="form-control font-monospace" id="confirmName"
name="confirm_name" autocomplete="off" spellcheck="false"
placeholder="<?= $h($inspect['filename']) ?>">
<div class="form-text">
É escrito à mão de propósito: obriga a olhar para qual artefacto está
prestes a aplicar.
</div>
</div>
<div>
<button type="submit" class="btn btn-danger">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar esta base de dados
</button>
</div>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>