Correções da auditoria de 24/09 (audits/fitme3070-auditoria-2026-09-24.html). Lixeira - Tudo o que é importante passa a soft delete (deleted_at + deleted_by): equipa, modalidades, eventos, mensalidades, galerias, imagens, horário, horas de funcionamento, inscrições em eventos e pedidos de inscrição. - config/functions_trash.php: um registo por tipo; apagar, restaurar, listar e eliminar leem todos dele. Eliminar de vez só com 30 dias na lixeira — a condição está no WHERE do DELETE, um POST forjado não apaga nada. - Gestão → Lixeira (views/admin/trash/index.php): lista única com quem e quando, restauro a qualquer momento. - Imagens de galeria: o site lista a pasta, por isso o ficheiro passa para .trash/ (negado pelo .htaccess) e volta no restauro. - As listagens e o site público filtram deleted_at (horário também filtra modalidades apagadas). Migração: database/migration_20260925_trash.sql (só acrescenta colunas; tem de ser aplicada antes deste código). CSRF - csrf_check() recusa qualquer pedido que não seja POST (405). Antes saía sem verificar nada em GET, e dez ações de apagar/restaurar eram GET. - Os dez handlers passaram a POST com token; links viraram formulários. Autenticação e sessão - client_ip(): X-Real-IP / último X-Forwarded-For, só vindo de proxy privado com TRUSTED_PROXY. O throttle contava o IP do proxy (limite global). - Throttle também por conta (hash do email), além do IP. - Sem oráculo de tempo: login verifica contra um hash de referência quando a conta não existe; a recuperação deixa o email na fila. - Cookie de sessão Secure/HttpOnly/SameSite=Lax e use_strict_mode. - Definições de sistema só para sysadmin; autocomplete=username. Servidor e uploads - .htaccess: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy, CSP (frame-ancestors, base-uri, object-src, form-action); nega parciais _*.php, PHP soltos do platform-tools e *.md; 404 própria. - Uploads validados pelo conteúdo (finfo/getimagesize); SVG só sem scripts, handlers on*=, foreignObject ou hrefs externos. Testado numa cópia da BD de produção (fitme_audit_test) com um contentor temporário: 34/34 verificações HTTP ponta-a-ponta. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
9.5 KiB
PHP
214 lines
9.5 KiB
PHP
<?php
|
||
// ─── Lixeira ─────────────────────────────────────────────────────────────────
|
||
// Tudo o que é importante apaga-se por soft delete: deleted_at + deleted_by.
|
||
// O registo sai das listagens e do site, aparece em Sistema → Lixeira
|
||
// (views/admin/trash/index.php), restaura-se a qualquer momento e só pode ser
|
||
// eliminado de vez passados TRASH_RETENTION_DAYS dias.
|
||
//
|
||
// A regra dos 30 dias vive no WHERE do DELETE (trash_purge), não na vista:
|
||
// esconder o botão não chega, um POST forjado tem de falhar também.
|
||
//
|
||
// Cada tipo declara-se uma vez em trash_types(); apagar, restaurar, listar e
|
||
// eliminar leem todos do mesmo registo. Os nomes de tabela e colunas vêm só
|
||
// deste array (nunca do pedido) e ainda assim são validados por regex antes de
|
||
// entrarem no SQL.
|
||
//
|
||
// Schema: database/migration_20260925_trash.sql.
|
||
|
||
const TRASH_RETENTION_DAYS = 30;
|
||
|
||
function trash_types(): array {
|
||
return [
|
||
'team' => [
|
||
'label' => 'Membro da equipa', 'table' => 'team', 'pk' => 'id',
|
||
'title' => "t.name", 'edit' => 'views/admin/team/edit.php?id=',
|
||
],
|
||
'modalidade' => [
|
||
'label' => 'Modalidade', 'table' => 'modalidades', 'pk' => 'm_id',
|
||
'title' => "t.nome", 'edit' => 'views/admin/modalidades/edit.php?id=',
|
||
],
|
||
'event' => [
|
||
'label' => 'Evento', 'table' => 'events', 'pk' => 'e_id',
|
||
'title' => "t.title", 'edit' => 'views/admin/events/edit.php?id=',
|
||
],
|
||
'membership' => [
|
||
'label' => 'Mensalidade', 'table' => 'memberships', 'pk' => 'id',
|
||
'title' => "t.name", 'edit' => 'views/admin/memberships/edit.php?id=',
|
||
],
|
||
'gallery' => [
|
||
'label' => 'Galeria', 'table' => 'galleries', 'pk' => 'id',
|
||
'title' => "t.name", 'edit' => null,
|
||
],
|
||
'gallery_image' => [
|
||
'label' => 'Imagem de galeria', 'table' => 'gallery', 'pk' => 'i_id',
|
||
'title' => "CONCAT(COALESCE((SELECT g.name FROM galleries g WHERE g.id = t.i_project_id), 'Galeria'), ' — ', t.i_file_name)",
|
||
'edit' => null,
|
||
],
|
||
'horario' => [
|
||
'label' => 'Aula do horário', 'table' => 'horario', 'pk' => 'id',
|
||
'title' => "CONCAT(COALESCE((SELECT m.nome FROM modalidades m WHERE m.m_id = t.id_modalidade), 'Aula'), ' — ', t.dia_semana, ' ', TIME_FORMAT(t.hora_inicio, '%H:%i'))",
|
||
'edit' => null,
|
||
],
|
||
'work_hours' => [
|
||
'label' => 'Horário de funcionamento', 'table' => 'work_hours', 'pk' => 'id',
|
||
'title' => "CONCAT(t.week_day, ' ', TIME_FORMAT(t.open, '%H:%i'), '–', TIME_FORMAT(t.close, '%H:%i'))",
|
||
'edit' => null,
|
||
],
|
||
'event_lead' => [
|
||
'label' => 'Inscrição em evento', 'table' => 'event_leads', 'pk' => 'lead_id',
|
||
'title' => "CONCAT(t.name, COALESCE(CONCAT(' — ', (SELECT e.title FROM events e WHERE e.e_id = t.e_id)), ''))",
|
||
'edit' => null, 'personal' => true,
|
||
],
|
||
'membership_lead' => [
|
||
'label' => 'Pedido de inscrição', 'table' => 'membership_leads', 'pk' => 'id',
|
||
'title' => "CONCAT(t.name, IF(t.membership_name <> '', CONCAT(' — ', t.membership_name), ''))",
|
||
'edit' => null, 'personal' => true,
|
||
],
|
||
];
|
||
}
|
||
|
||
function trash_type(string $type): array {
|
||
$types = trash_types();
|
||
if (!isset($types[$type])) throw new InvalidArgumentException("Tipo de lixeira desconhecido: {$type}");
|
||
$t = $types[$type];
|
||
foreach (['table', 'pk'] as $k) {
|
||
if (!preg_match('/^[A-Za-z0-9_]+$/', $t[$k])) throw new InvalidArgumentException("Identificador inválido em {$type}.{$k}");
|
||
}
|
||
return $t;
|
||
}
|
||
|
||
function _trash_user_id(): ?int {
|
||
$id = (int) ($_SESSION['user_id'] ?? 0);
|
||
return $id > 0 ? $id : null;
|
||
}
|
||
|
||
// Marca como apagado. Devolve false se já estava apagado ou não existe.
|
||
function trash_delete(PDO $pdo, string $type, int $id): bool {
|
||
$t = trash_type($type);
|
||
$stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NOW(), deleted_by = ? WHERE `{$t['pk']}` = ? AND deleted_at IS NULL");
|
||
$stmt->execute([_trash_user_id(), $id]);
|
||
$ok = $stmt->rowCount() > 0;
|
||
if ($ok) _trash_after($pdo, $type, $id, 'delete');
|
||
return $ok;
|
||
}
|
||
|
||
function trash_restore(PDO $pdo, string $type, int $id): bool {
|
||
$t = trash_type($type);
|
||
$stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NULL, deleted_by = NULL WHERE `{$t['pk']}` = ? AND deleted_at IS NOT NULL");
|
||
$stmt->execute([$id]);
|
||
$ok = $stmt->rowCount() > 0;
|
||
if ($ok) _trash_after($pdo, $type, $id, 'restore');
|
||
return $ok;
|
||
}
|
||
|
||
// Elimina de vez — só se estiver na lixeira há pelo menos TRASH_RETENTION_DAYS
|
||
// dias. A condição está no próprio DELETE, por isso não há corrida entre a
|
||
// verificação e a remoção.
|
||
function trash_purge(PDO $pdo, string $type, int $id): bool {
|
||
$t = trash_type($type);
|
||
$row = trash_get($pdo, $type, $id);
|
||
if (!$row || !$row['can_purge']) return false;
|
||
|
||
$pdo->beginTransaction();
|
||
try {
|
||
if ($type === 'gallery') {
|
||
// As imagens da galeria vão com ela.
|
||
$imgs = $pdo->prepare("SELECT i_id FROM gallery WHERE i_project_id = ?");
|
||
$imgs->execute([$id]);
|
||
foreach ($imgs->fetchAll(PDO::FETCH_COLUMN) as $iid) _trash_image_file($pdo, (int) $iid, 'purge');
|
||
$pdo->prepare("DELETE FROM gallery WHERE i_project_id = ?")->execute([$id]);
|
||
}
|
||
if ($type === 'gallery_image') _trash_image_file($pdo, $id, 'purge');
|
||
|
||
$stmt = $pdo->prepare(
|
||
"DELETE FROM `{$t['table']}` WHERE `{$t['pk']}` = ?
|
||
AND deleted_at IS NOT NULL
|
||
AND deleted_at <= DATE_SUB(NOW(), INTERVAL " . (int) TRASH_RETENTION_DAYS . " DAY)"
|
||
);
|
||
$stmt->execute([$id]);
|
||
$ok = $stmt->rowCount() > 0;
|
||
$ok ? $pdo->commit() : $pdo->rollBack();
|
||
return $ok;
|
||
} catch (Throwable $e) {
|
||
$pdo->rollBack();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
function trash_get(PDO $pdo, string $type, int $id): ?array {
|
||
foreach (trash_list($pdo, $type, $id) as $row) return $row;
|
||
return null;
|
||
}
|
||
|
||
// Tudo o que está na lixeira, mais recente primeiro. Com $type/$id devolve só
|
||
// esse registo (usado por trash_purge).
|
||
function trash_list(PDO $pdo, ?string $onlyType = null, ?int $onlyId = null): array {
|
||
$out = [];
|
||
foreach (trash_types() as $type => $_) {
|
||
if ($onlyType !== null && $type !== $onlyType) continue;
|
||
$t = trash_type($type);
|
||
$sql = "SELECT t.`{$t['pk']}` AS id, {$t['title']} AS title, t.deleted_at, t.deleted_by,
|
||
u.name AS deleted_by_name,
|
||
(t.deleted_at <= DATE_SUB(NOW(), INTERVAL " . (int) TRASH_RETENTION_DAYS . " DAY)) AS can_purge,
|
||
GREATEST(0, " . (int) TRASH_RETENTION_DAYS . " - DATEDIFF(NOW(), t.deleted_at)) AS days_left
|
||
FROM `{$t['table']}` t
|
||
LEFT JOIN users u ON u.id = t.deleted_by
|
||
WHERE t.deleted_at IS NOT NULL";
|
||
$args = [];
|
||
if ($onlyId !== null) { $sql .= " AND t.`{$t['pk']}` = ?"; $args[] = $onlyId; }
|
||
try {
|
||
$st = $pdo->prepare($sql);
|
||
$st->execute($args);
|
||
} catch (PDOException $e) {
|
||
// Migração ainda não aplicada nesta tabela: a lixeira mostra o resto.
|
||
error_log('[trash] ' . $type . ': ' . $e->getMessage());
|
||
continue;
|
||
}
|
||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||
$r['type'] = $type;
|
||
$r['label'] = $t['label'];
|
||
$r['personal'] = !empty($t['personal']);
|
||
$r['can_purge'] = (bool) $r['can_purge'];
|
||
$r['days_left'] = (int) $r['days_left'];
|
||
$out[] = $r;
|
||
}
|
||
}
|
||
usort($out, fn($a, $b) => strcmp((string) $b['deleted_at'], (string) $a['deleted_at']));
|
||
return $out;
|
||
}
|
||
|
||
function trash_count(PDO $pdo): int {
|
||
return count(trash_list($pdo));
|
||
}
|
||
|
||
// ─── Efeitos em ficheiros ────────────────────────────────────────────────────
|
||
// O site público lista as fotos da galeria com glob() na pasta, não pela BD.
|
||
// Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o
|
||
// .htaccess da raiz recusa qualquer caminho com um componente começado por
|
||
// ponto) e volta no restauro. Só a eliminação definitiva apaga o ficheiro.
|
||
|
||
function _trash_after(PDO $pdo, string $type, int $id, string $action): void {
|
||
if ($type === 'gallery_image') _trash_image_file($pdo, $id, $action);
|
||
}
|
||
|
||
function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
|
||
$st = $pdo->prepare("SELECT i_file_name, i_project_id FROM gallery WHERE i_id = ? LIMIT 1");
|
||
$st->execute([$imageId]);
|
||
$img = $st->fetch(PDO::FETCH_ASSOC);
|
||
if (!$img) return;
|
||
$name = basename((string) $img['i_file_name']);
|
||
if ($name === '' || $name[0] === '.') return;
|
||
$dir = dirname(__DIR__) . '/uploads/gallery/' . (int) $img['i_project_id'] . '/';
|
||
$live = $dir . $name;
|
||
$trash = $dir . '.trash/' . $name;
|
||
|
||
if ($action === 'delete' && is_file($live)) {
|
||
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
|
||
@rename($live, $trash);
|
||
} elseif ($action === 'restore' && is_file($trash)) {
|
||
@rename($trash, $live);
|
||
} elseif ($action === 'purge') {
|
||
if (is_file($trash)) @unlink($trash);
|
||
}
|
||
}
|