Files
fitme3070.pt/config/functions_trash.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

244 lines
11 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');
_trash_audit($pdo, 'trash.delete', $type, $id, 'Enviado para a lixeira');
}
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');
_trash_audit($pdo, 'trash.restore', $type, $id, 'Restaurado da lixeira');
}
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();
if ($ok) _trash_audit($pdo, 'trash.purge', $type, $id, 'Eliminado de vez', (string) $row['title']);
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));
}
// Cada passo da lixeira fica no audit_log, com o nome do registo (na
// eliminação definitiva é a única coisa que resta dele).
function _trash_audit(PDO $pdo, string $action, string $type, int $id, string $summary, ?string $label = null): void {
if (!function_exists('platform_audit')) return;
if ($label === null) {
$row = trash_get($pdo, $type, $id);
if ($row === null) {
// Restaurado: já não está na lixeira; o nome vem do próprio registo.
$t = trash_type($type);
try {
$st = $pdo->prepare("SELECT {$t['title']} FROM `{$t['table']}` t WHERE t.`{$t['pk']}` = ?");
$st->execute([$id]);
$label = (string) $st->fetchColumn();
} catch (Throwable $e) { $label = ''; }
} else {
$label = (string) $row['title'];
}
}
platform_audit($pdo, $action, [
'target_type' => $type, 'target_id' => $id, 'target_label' => $label, 'summary' => $summary,
]);
}
// ─── 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);
}
}