Files
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

439 lines
22 KiB
PHP

<?php
// ─── Shared backups view ─────────────────────────────────────────────────────
//
// Bootstrap contract — the stub at <host>/views/sysadmin/backups.php must:
// 1. Load $pdo (via $repo_root/config/db.php + functions.php)
// 2. Run the sysadmin auth guard
// 3. Set $repo_root
// Optional override variables (same as platform_update.php):
// $platform_layout_header / $platform_layout_navbar / $platform_layout_footer
// $platform_dashboard_url
require_once __DIR__ . '/functions_backup.php';
require_once __DIR__ . '/functions_csrf.php';
// Load BACKUP_KEEP_DAYS (and BACKUP_TOKEN) from the worker config if not yet defined.
if (!defined('BACKUP_KEEP_DAYS')) {
require_once __DIR__ . '/backups/config.php';
}
if (!isset($repo_root) || !is_string($repo_root)) {
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
}
$repo_root = rtrim($repo_root, '/');
// ── Apagar backup (POST à própria página, PRG) ──────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_backup') {
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
];
} else {
$fn = (string) ($_POST['filename'] ?? '');
$dir = backup_dir();
$real = realpath($dir . DIRECTORY_SEPARATOR . $fn);
if (!backup_filename_is_safe($fn) || $real === false
|| !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => 'Ficheiro inválido.'];
} elseif (@unlink($real)) {
if (function_exists('platform_audit')) platform_audit($pdo ?? null, 'backup.delete', ['target_type' => 'backup', 'target_label' => $fn]);
$_SESSION['backup_flash'] = ['type' => 'success', 'message' => "Backup {$fn} apagado."];
} else {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => "Não foi possível apagar {$fn} (permissões?)."];
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
// ── Restaurar backup (POST à própria página, PRG) ───────────────────────────
// Destrutivo: substitui o estado actual. Exige CSRF + confirmação escrita, e
// restore_db()/restore_files() tiram sempre um backup de segurança antes de
// escrever, cujo nome é devolvido na flash para servir de "desfazer".
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'restore_backup') {
require_once __DIR__ . '/functions_restore.php';
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
];
} elseif (trim((string) ($_POST['confirm'] ?? '')) !== 'RESTAURAR') {
// Guarda contra o clique por reflexo: um confirm() do browser é
// demasiado fácil de despachar sem ler.
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Confirmação incorrecta — restauro cancelado. Nada foi alterado.',
];
} else {
$fn = (string) ($_POST['filename'] ?? '');
$purge = !empty($_POST['purge']);
@set_time_limit(0);
ignore_user_abort(true);
$info = restore_inspect($fn);
if (!$info['ok']) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro recusado: ' . (string) $info['error'],
];
} elseif ($info['type'] === 'db' && !isset($pdo)) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro da base de dados indisponível: o arranque do site (config/db.php) não abriu a ligação à base de dados.',
];
} else {
$r = ($info['type'] === 'db')
? restore_db($pdo, $fn)
: restore_files($fn, ['purge' => $purge]);
if (!empty($r['ok'])) {
if ($info['type'] === 'db') {
$detail = sprintf(
'%d statements aplicados. Tabelas: %s → %s.',
(int) $r['statements'],
$r['tables_before'] ?? '?',
$r['tables_after'] ?? '?'
);
if (!empty($r['dropped_extra'])) {
$detail .= ' Tabelas removidas por não existirem no backup: '
. implode(', ', $r['dropped_extra']) . '.';
}
} else {
$detail = sprintf(
'%d ficheiro(s) restaurado(s)%s.',
(int) $r['extracted'],
!empty($r['purged']) ? ', ' . (int) $r['purged'] . ' apagado(s)' : ''
);
}
// A flash é renderizada com htmlspecialchars(), por isso é texto
// simples: markup aqui apareceria literalmente ao utilizador, e
// escapar aqui daria escape duplo.
$undo = !empty($r['safety_backup'])
? ' Estado anterior guardado em ' . (string) $r['safety_backup']
. ' — restaura esse ficheiro para desfazer.'
: '';
$_SESSION['backup_flash'] = [
'type' => 'success',
'message' => 'Restaurado de ' . $fn . '. ' . $detail . $undo,
];
} else {
$safe = !empty($r['safety_backup'])
? ' O backup de segurança ' . (string) $r['safety_backup']
. ' foi criado antes da falha — usa-o para voltar atrás.'
: '';
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro falhou: ' . (string) ($r['error'] ?? 'erro desconhecido') . $safe,
];
}
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
$flash = $_SESSION['backup_flash'] ?? null;
unset($_SESSION['backup_flash']);
$backups = backup_list();
// ── Auto-refresh após "backup iniciado" ─────────────────────────────────────
// O trigger devolve o nº de backups antes do disparo; enquanto a contagem não
// subir, a página recarrega sozinha (limite de tentativas para não ficar em
// loop se o worker falhar).
$await_count = null;
$await_tries = 0;
if (is_array($flash) && isset($flash['await_count'])) {
$await_count = (int) $flash['await_count'];
} elseif (isset($_GET['pt_await'])) {
$await_count = (int) $_GET['pt_await'];
$await_tries = max(0, (int) ($_GET['pt_tries'] ?? 0));
}
$awaiting = $await_count !== null && count($backups) <= $await_count && $await_tries < 20;
$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');
$base_url_fn = function_exists('base_url') ? 'base_url' : null;
$dashboard_url = $platform_dashboard_url
?? (function_exists('base_url') ? base_url('views/admin/dashboard.php') : 'dashboard.php');
$pageTitle = 'Backups';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1000px;">
<?php
// O estado da encriptação tem de estar à vista. Um backup em claro parece
// igual a um encriptado na listagem, e a diferença só se descobre no dia em
// que o ficheiro sai do servidor.
$bkEnc = function_exists('platform_backup_encryption_available') && platform_backup_encryption_available();
?>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h2 class="mb-1"><i class="bi bi-archive me-2"></i>Backups</h2>
<p class="text-muted small mb-0">Gerir cópias de segurança dos uploads e da base de dados. O código reconstitui-se a partir do git — só a pasta <code>uploads/</code> tem informação externa.</p>
<p class="small mb-0 mt-1">
<?php if ($bkEnc): ?>
<span class="badge text-bg-success"><i class="bi bi-lock-fill me-1"></i>AES-256</span>
<span class="text-muted">Os dumps novos saem encriptados. A chave deriva da <code>APP_SECRET_KEY</code> —
<strong>perdê-la é perder os backups</strong>; guarde-a fora do servidor.</span>
<?php else: ?>
<span class="badge text-bg-warning text-dark"><i class="bi bi-unlock-fill me-1"></i>Sem encriptação</span>
<span class="text-muted">Sem <code>APP_SECRET_KEY</code> no <code>.env</code>, os dumps saem em claro —
e levam a tabela de utilizadores, os contactos e os contratos assinados.</span>
<?php endif; ?>
</p>
</div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
<?= platform_csrf_field() ?><input type="hidden" name="type" value="files">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-archive me-1"></i>Backup de uploads
</button>
</form>
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
<?= platform_csrf_field() ?><input type="hidden" name="type" value="db">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-database-down me-1"></i>Backup da base de dados
</button>
</form>
</div>
</div>
<?php if (is_array($flash) && !empty($flash['type'])): ?>
<?php $flash_type = (string) $flash['type']; ?>
<div class="alert alert-<?= htmlspecialchars($flash_type) ?> alert-dismissible fade show d-flex align-items-center gap-2" role="alert">
<i class="bi bi-<?= $flash_type === 'success' ? 'check-circle' : 'exclamation-triangle' ?> flex-shrink-0"></i>
<div><?= htmlspecialchars((string) ($flash['message'] ?? '')) ?></div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if ($awaiting): ?>
<div class="alert alert-info d-flex align-items-center gap-2" role="status">
<span class="spinner-border spinner-border-sm flex-shrink-0"></span>
<div>A aguardar o novo backup… a lista atualiza automaticamente
<span class="text-muted">(tentativa <?= $await_tries + 1 ?>/20)</span></div>
</div>
<?php elseif ($await_count !== null && $await_tries >= 20): ?>
<div class="alert alert-warning" role="alert">
O backup ainda não apareceu ao fim de <?= $await_tries ?> verificações — confirma os logs do servidor.
</div>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Cópias de segurança disponíveis</span>
<span class="badge text-bg-secondary"><?= count($backups) ?></span>
</div>
<?php if (empty($backups)): ?>
<div class="card-body text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-50"></i>
Nenhuma cópia de segurança encontrada.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0 small align-middle">
<thead class="table-dark">
<tr>
<th>Ficheiro</th>
<th>Tipo</th>
<th>Tamanho</th>
<th>Data de criação</th>
<th class="text-end">Ações</th>
</tr>
</thead>
<tbody>
<?php foreach ($backups as $b): ?>
<tr>
<td class="font-monospace text-break"><?= htmlspecialchars($b['filename']) ?></td>
<td>
<span class="badge text-bg-<?= backup_type_badge($b['type']) ?>">
<?= backup_type_label($b['type']) ?>
</span>
</td>
<td class="text-nowrap"><?= htmlspecialchars($b['size_human']) ?></td>
<td class="text-muted text-nowrap"><?= date('d/m/Y H:i', $b['created_at']) ?></td>
<td class="text-end">
<a href="<?= htmlspecialchars(($base_url_fn ? base_url('api/backup_download.php') : 'api/backup_download.php')) ?>?file=<?= urlencode($b['filename']) ?>"
class="btn btn-sm btn-outline-secondary"
title="Transferir" aria-label="Transferir <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-download"></i>
</a>
<button type="button"
class="btn btn-sm btn-outline-warning js-restore"
data-filename="<?= htmlspecialchars($b['filename']) ?>"
data-type="<?= htmlspecialchars($b['type']) ?>"
data-label="<?= htmlspecialchars(backup_type_label($b['type'])) ?>"
data-created="<?= htmlspecialchars(date('d/m/Y H:i', $b['created_at'])) ?>"
title="Restaurar" aria-label="Restaurar <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<form method="POST" class="d-inline m-0"
onsubmit="return confirm('Apagar <?= htmlspecialchars(addslashes($b['filename'])) ?>? Esta ação não pode ser desfeita.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="delete_backup">
<input type="hidden" name="filename" value="<?= htmlspecialchars($b['filename']) ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Apagar" aria-label="Apagar <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="card-footer text-muted small">
<i class="bi bi-info-circle me-1"></i>
Retenção automática, por tipo: <?= htmlspecialchars(backup_retention_summary()) ?>.
O resto é apagado no fim de cada backup.
Os ficheiros estão armazenados na pasta <code>backups/</code> no servidor.
</div>
</div>
</div>
<!-- ── Modal de restauro ───────────────────────────────────────────────────
Um único modal para todas as linhas, preenchido a partir dos data-* do
botão clicado. Um confirm() do browser não chega aqui: é preciso mostrar
o que vai ser substituído, oferecer o purge só para ficheiros, e exigir
uma confirmação escrita antes de activar o botão. -->
<div class="modal fade" id="ptRestoreModal" tabindex="-1" aria-labelledby="ptRestoreLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form method="POST" class="modal-content">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="restore_backup">
<input type="hidden" name="filename" id="ptRestoreFilename" value="">
<div class="modal-header">
<h5 class="modal-title" id="ptRestoreLabel">
<i class="bi bi-arrow-counterclockwise me-2"></i>Restaurar backup
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<p class="mb-2">Vai restaurar:</p>
<p class="font-monospace small text-break mb-1" id="ptRestoreName"></p>
<p class="text-muted small mb-3">
<span class="badge text-bg-secondary" id="ptRestoreType"></span>
<span id="ptRestoreCreated" class="ms-2"></span>
</p>
<div class="alert alert-warning small mb-3" role="alert">
<strong>Isto substitui o estado actual.</strong>
<span id="ptRestoreScopeDb" class="d-none">
Toda a base de dados é substituída pelo conteúdo do backup. Tabelas que
não existam no backup são removidas.
</span>
<span id="ptRestoreScopeFiles" class="d-none">
A pasta de uploads é reposta a partir do backup.
</span>
<br>
É tirado automaticamente um backup do estado actual antes de escrever —
o nome aparece no fim para poderes desfazer.
</div>
<div class="form-check mb-3 d-none" id="ptRestorePurgeWrap">
<input class="form-check-input" type="checkbox" name="purge" value="1" id="ptRestorePurge">
<label class="form-check-label small" for="ptRestorePurge">
Apagar ficheiros que não existam no backup (espelho exacto).
<span class="text-danger">Desligado por omissão: um backup parcial apagaria uploads em uso.</span>
</label>
</div>
<label for="ptRestoreConfirm" class="form-label small mb-1">
Escreve <code>RESTAURAR</code> para confirmar:
</label>
<input type="text" class="form-control" name="confirm" id="ptRestoreConfirm"
autocomplete="off" placeholder="RESTAURAR">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-warning" id="ptRestoreSubmit" disabled>
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var modalEl = document.getElementById('ptRestoreModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
var modal = new bootstrap.Modal(modalEl);
var confirmInput = document.getElementById('ptRestoreConfirm');
var submitBtn = document.getElementById('ptRestoreSubmit');
document.querySelectorAll('.js-restore').forEach(function (btn) {
btn.addEventListener('click', function () {
var isDb = btn.dataset.type === 'db';
document.getElementById('ptRestoreFilename').value = btn.dataset.filename;
document.getElementById('ptRestoreName').textContent = btn.dataset.filename;
document.getElementById('ptRestoreType').textContent = btn.dataset.label || btn.dataset.type;
document.getElementById('ptRestoreCreated').textContent = btn.dataset.created || '';
document.getElementById('ptRestoreScopeDb').classList.toggle('d-none', !isDb);
document.getElementById('ptRestoreScopeFiles').classList.toggle('d-none', isDb);
// purge só faz sentido para ficheiros; garante que não vai marcado
// de uma abertura anterior.
document.getElementById('ptRestorePurgeWrap').classList.toggle('d-none', isDb);
document.getElementById('ptRestorePurge').checked = false;
confirmInput.value = '';
submitBtn.disabled = true;
modal.show();
});
});
confirmInput.addEventListener('input', function () {
submitBtn.disabled = confirmInput.value.trim() !== 'RESTAURAR';
});
})();
</script>
<script>
// Anti-duplo-clique: bloqueia reenvios e mostra spinner no botão submetido.
document.addEventListener('submit', function (e) {
var form = e.target;
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
if (form.dataset.ptSubmitted) { e.preventDefault(); return; }
form.dataset.ptSubmitted = '1';
form.querySelectorAll('button[type="submit"]').forEach(function (btn) {
btn.disabled = true;
btn.insertAdjacentHTML('afterbegin',
'<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
});
});
<?php if ($awaiting): ?>
// Recarrega até o novo backup aparecer (contagem no servidor decide).
setTimeout(function () {
var url = new URL(window.location.href);
url.searchParams.set('pt_await', '<?= (int) $await_count ?>');
url.searchParams.set('pt_tries', '<?= (int) ($await_tries + 1) ?>');
window.location.replace(url.toString());
}, 8000);
<?php else: ?>
// Poll terminado (ou nunca ativo): limpa os parâmetros de acompanhamento.
if (window.location.search.indexOf('pt_await') !== -1) {
var cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('pt_await');
cleanUrl.searchParams.delete('pt_tries');
window.history.replaceState(null, '', cleanUrl.toString());
}
<?php endif; ?>
</script>
<?php if (is_file($layout_footer)) include $layout_footer; ?>