Files
fitme3070.pt/config/functions_trash.php
T
andreandClaude Opus 5.5 895b3d58d7 fix(segurança): lixeira de 30 dias, CSRF em todas as ações, IP real, sessão e uploads
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>
2026-09-25 07:16:10 +01:00

214 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}