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>
This commit is contained in:
1 parent
d70f13aea0
commit
895b3d58d7
41 files changed
+688
-139
No files matched your search
@@ -16,6 +16,27 @@ Options -Indexes
|
||||
RewriteEngine On
|
||||
RewriteRule "(^|/)\.(?!well-known)" - [F,L]
|
||||
|
||||
# A raiz web é o repositório inteiro, por isso fecha-se o que não é página:
|
||||
# - parciais (_form.php e afins) só se incluem, nunca se pedem;
|
||||
# - PHP soltos do platform-tools: só api/ e backups/ (auto-chamada com token)
|
||||
# são endpoints; o resto é incluído pelas páginas de views/sysadmin/;
|
||||
# - documentação (*.md) não é para servir.
|
||||
RewriteRule "(^|/)_[^/]*\.php$" - [F,L]
|
||||
RewriteRule "^shared/platform-tools/[^/]+\.php$" - [F,L]
|
||||
RewriteRule "\.md$" - [F,L]
|
||||
|
||||
# ── Headers de segurança ─────────────────────────────────────────────────────
|
||||
# A CSP não restringe scripts ainda (o site tem scripts inline e incorporações
|
||||
# da Google); fecha o que não parte nada: quem pode meter o site num iframe,
|
||||
# <base>, plugins e para onde os formulários podem enviar.
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self' https://forms.zohopublic.eu"
|
||||
</IfModule>
|
||||
|
||||
# ── Canonical host: force HTTPS + strip www ───────────────────────────────────
|
||||
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
|
||||
RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]
|
||||
@@ -48,3 +69,6 @@ RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
DirectoryIndex index.php
|
||||
|
||||
# Página 404 própria (a do Apache mostrava a versão do servidor).
|
||||
ErrorDocument 404 /site/404.php
|
||||
@@ -86,6 +86,7 @@ if ($type === '' || $type === 'event') {
|
||||
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL
|
||||
WHERE h.deleted_at IS NULL
|
||||
ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
|
||||
h.hora_inicio ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -66,7 +66,16 @@ try {
|
||||
// Distinct session cookie per app so installs that share a host (e.g.
|
||||
// development.andmore.pt/id.andmore.pt/ and /fitme3070.pt/) don't overwrite
|
||||
// each other's PHPSESSID. Must run before session_start().
|
||||
//
|
||||
// Cookie e modo estrito definidos aqui, no código, para valerem em qualquer
|
||||
// servidor: antes o cookie saía só com path=/ (sem Secure, HttpOnly nem
|
||||
// SameSite) e o PHP aceitava IDs de sessão inventados pelo cliente.
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
if (request_is_https()) ini_set('session.cookie_secure', '1');
|
||||
session_name('FIT_SESS');
|
||||
session_start();
|
||||
}
|
||||
@@ -44,6 +44,33 @@ function env(string $key, $default = null) {
|
||||
return $v === false ? $default : $v;
|
||||
}
|
||||
|
||||
// IP do visitante. Em produção o Apache só vê o Nginx Proxy Manager
|
||||
// (REMOTE_ADDR = 172.18.0.2 para toda a gente), e o throttle do login contava
|
||||
// tudo contra esse IP — cinco falhas de qualquer pessoa bloqueavam todos.
|
||||
//
|
||||
// Só se confia no proxy quando TRUSTED_PROXY=true E o pedido vem mesmo de uma
|
||||
// rede privada. Nesse caso:
|
||||
// - X-Real-IP primeiro: o NPM escreve-o com o $remote_addr que viu, por cima
|
||||
// de qualquer valor que o cliente tenha mandado;
|
||||
// - senão o ÚLTIMO valor do X-Forwarded-For, que é o que o proxy acrescentou.
|
||||
// O primeiro é escrito pelo cliente (o NPM acrescenta, não substitui) e
|
||||
// serve para forjar identidades — o platform_client_ip() partilhado ainda
|
||||
// o usa.
|
||||
function client_ip(): string {
|
||||
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$trusted = filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN);
|
||||
$fromProxy = $remote !== '' && filter_var($remote, FILTER_VALIDATE_IP)
|
||||
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||||
if ($trusted && $fromProxy) {
|
||||
$real = trim((string) ($_SERVER['HTTP_X_REAL_IP'] ?? ''));
|
||||
if (filter_var($real, FILTER_VALIDATE_IP)) return $real;
|
||||
$xff = array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')));
|
||||
$last = end($xff);
|
||||
if ($last !== false && filter_var($last, FILTER_VALIDATE_IP)) return $last;
|
||||
}
|
||||
return $remote !== '' ? $remote : '0.0.0.0';
|
||||
}
|
||||
|
||||
// True when the current request is HTTPS — including when TLS terminates at a
|
||||
// reverse proxy. Behind a proxy you MUST set TRUSTED_PROXY=true in .env or the
|
||||
// X-Forwarded-Proto header is ignored (otherwise any client could spoof it).
|
||||
|
||||
@@ -14,7 +14,7 @@ function _error_log_write(string $type, string $message, string $file = '', int
|
||||
$userId = isset($_SESSION['user_id']) ? (int) $_SESSION['user_id'] : null;
|
||||
$userName = isset($_SESSION['user']['name']) ? (string) $_SESSION['user']['name'] : null;
|
||||
$url = (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '') ;
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ip = function_exists('client_ip') ? client_ip() : ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
|
||||
$entry = json_encode([
|
||||
'ts' => date('c'),
|
||||
|
||||
@@ -13,6 +13,7 @@ require_once __DIR__ . '/config.php'; // APP_ENV + hub notify URL +
|
||||
require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache
|
||||
require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_secret() + AES-GCM at rest
|
||||
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
|
||||
require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias
|
||||
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
|
||||
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
|
||||
require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints
|
||||
|
||||
@@ -31,8 +31,19 @@ function csrf_field(): string {
|
||||
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES) . '">';
|
||||
}
|
||||
|
||||
// Só aceita POST. Antes, um pedido GET saía daqui sem verificar nada — e dez
|
||||
// ações de apagar/restaurar eram GET, por isso o csrf_check() delas não
|
||||
// protegia coisa nenhuma. Quem chama isto está prestes a alterar dados; um
|
||||
// método seguro nunca deve chegar aqui.
|
||||
function csrf_check(): void {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') return;
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!doctype html><meta charset="utf-8"><title>Método não permitido</title>'
|
||||
. '<p style="font-family:sans-serif;text-align:center;margin:80px auto;">Esta ação só pode ser feita a partir do formulário.</p>';
|
||||
exit;
|
||||
}
|
||||
|
||||
$supplied = $_POST['_csrf']
|
||||
?? $_SERVER['HTTP_X_CSRF_TOKEN']
|
||||
|
||||
+16
-14
@@ -153,16 +153,13 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php).
|
||||
function db_events_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE events SET deleted_at = NOW() WHERE e_id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'event', $id);
|
||||
}
|
||||
|
||||
function db_events_restore(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE events SET deleted_at = NULL WHERE e_id = ? AND deleted_at IS NOT NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_restore($pdo, 'event', $id);
|
||||
}
|
||||
|
||||
// ─── Hero image upload / removal ───────────────────────────────────────────────
|
||||
@@ -192,9 +189,11 @@ function events_image_upload(array $file, ?string &$error = null): ?string {
|
||||
return null;
|
||||
}
|
||||
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
||||
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
$error = 'Formato inválido. Permitidos: ' . implode(', ', $allowed) . '.';
|
||||
// Tipo decidido pelo conteúdo, não pelo nome; SVG só sem scripts
|
||||
// (config/functions_images.php).
|
||||
$ext = upload_image_ext($file, $allowed);
|
||||
if ($ext === null) {
|
||||
$error = 'Formato inválido ou ficheiro que não é uma imagem válida. Permitidos: ' . implode(', ', $allowed) . '.';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -226,14 +225,14 @@ function events_image_remove(?string $filename): void {
|
||||
// ─── Leads ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function db_event_leads_list(PDO $pdo, int $eventId): array {
|
||||
$stmt = $pdo->prepare("SELECT * FROM event_leads WHERE e_id = ? ORDER BY created_at DESC, lead_id DESC");
|
||||
$stmt = $pdo->prepare("SELECT * FROM event_leads WHERE e_id = ? AND deleted_at IS NULL ORDER BY created_at DESC, lead_id DESC");
|
||||
$stmt->execute([$eventId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function db_event_leads_count(PDO $pdo, int $eventId): int {
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM event_leads WHERE e_id = ?");
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM event_leads WHERE e_id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$eventId]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
@@ -260,8 +259,11 @@ function db_event_lead_create(PDO $pdo, int $eventId, array $data): int {
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// A inscrição tem de pertencer ao evento aberto (não se apaga a de outro
|
||||
// evento mudando o id). Vai para a lixeira.
|
||||
function db_event_lead_delete(PDO $pdo, int $leadId, int $eventId): bool {
|
||||
$stmt = $pdo->prepare("DELETE FROM event_leads WHERE lead_id = ? AND e_id = ?");
|
||||
$stmt->execute([$leadId, $eventId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
$own = $pdo->prepare("SELECT 1 FROM event_leads WHERE lead_id = ? AND e_id = ?");
|
||||
$own->execute([$leadId, $eventId]);
|
||||
if (!$own->fetchColumn()) return false;
|
||||
return trash_delete($pdo, 'event_lead', $leadId);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// Also uses `images` table for standalone uploaded images (img_id, img_file_name).
|
||||
|
||||
function db_gallery_list(PDO $pdo): array {
|
||||
return $pdo->query("SELECT * FROM galleries ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
return $pdo->query("SELECT * FROM galleries WHERE deleted_at IS NULL ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function db_gallery_get(PDO $pdo, int $id): ?array {
|
||||
@@ -26,17 +26,15 @@ function db_gallery_update(PDO $pdo, int $id, string $name): bool {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php). As imagens ficam
|
||||
// ligadas à galeria e só saem com a eliminação definitiva, 30 dias depois.
|
||||
function db_gallery_delete(PDO $pdo, int $id): bool {
|
||||
// Delete associated images first, then the gallery record.
|
||||
$pdo->prepare("DELETE FROM gallery WHERE i_project_id = ?")->execute([$id]);
|
||||
$stmt = $pdo->prepare("DELETE FROM galleries WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'gallery', $id);
|
||||
}
|
||||
|
||||
// Gallery images
|
||||
function db_gallery_images(PDO $pdo, int $gallery_id): array {
|
||||
$stmt = $pdo->prepare("SELECT * FROM gallery WHERE i_project_id = ? ORDER BY i_created_at DESC");
|
||||
$stmt = $pdo->prepare("SELECT * FROM gallery WHERE i_project_id = ? AND deleted_at IS NULL ORDER BY i_created_at DESC");
|
||||
$stmt->execute([$gallery_id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
@@ -44,9 +42,10 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
|
||||
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) return null;
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
if (!in_array($ext, $allowed)) return null;
|
||||
if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null;
|
||||
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
|
||||
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
if ($ext === null) return null;
|
||||
|
||||
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
|
||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||
@@ -60,15 +59,12 @@ function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?strin
|
||||
return $filename;
|
||||
}
|
||||
|
||||
// A imagem tem de pertencer à galeria aberta. Vai para a lixeira: o ficheiro
|
||||
// passa para uploads/gallery/<id>/.trash/ (sai do site, que lista a pasta) e
|
||||
// só é apagado com a eliminação definitiva.
|
||||
function db_gallery_image_delete(PDO $pdo, int $image_id, int $gallery_id): bool {
|
||||
$stmt = $pdo->prepare("SELECT i_file_name FROM gallery WHERE i_id = ? AND i_project_id = ? LIMIT 1");
|
||||
$stmt->execute([$image_id, $gallery_id]);
|
||||
$img = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($img) {
|
||||
$path = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/' . $img['i_file_name'];
|
||||
if (is_file($path)) @unlink($path);
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM gallery WHERE i_id = ? AND i_project_id = ?");
|
||||
$stmt->execute([$image_id, $gallery_id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
$own = $pdo->prepare("SELECT 1 FROM gallery WHERE i_id = ? AND i_project_id = ?");
|
||||
$own->execute([$image_id, $gallery_id]);
|
||||
if (!$own->fetchColumn()) return false;
|
||||
return trash_delete($pdo, 'gallery_image', $image_id);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ function db_horario_list(PDO $pdo): array {
|
||||
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade
|
||||
WHERE h.deleted_at IS NULL
|
||||
ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
|
||||
h.hora_inicio ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -23,7 +24,7 @@ function db_horario_get(PDO $pdo, int $id): ?array {
|
||||
m.nome AS modalidade_nome
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade
|
||||
WHERE h.id = ? LIMIT 1
|
||||
WHERE h.id = ? AND h.deleted_at IS NULL LIMIT 1
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
@@ -61,10 +62,9 @@ function db_horario_update(PDO $pdo, int $id, array $data): bool {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php).
|
||||
function db_horario_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("DELETE FROM horario WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'horario', $id);
|
||||
}
|
||||
|
||||
// Reschedule only: day + start/end time (used by the calendar drag-and-drop).
|
||||
|
||||
@@ -20,6 +20,58 @@ const IMG_MAX_EVENT = 1600;
|
||||
const IMG_JPEG_QUALITY = 82;
|
||||
const IMG_WEBP_QUALITY = 80;
|
||||
|
||||
// ─── Validação de uploads ────────────────────────────────────────────────────
|
||||
// A extensão vem do nome que o browser manda e não prova nada: um PHP chamado
|
||||
// foto.jpg passava. Aqui decide-se pelo conteúdo (finfo, com getimagesize como
|
||||
// segunda opinião) e devolve-se a extensão CERTA para gravar, ou null.
|
||||
//
|
||||
// SVG é texto e pode levar <script>: só passa se a lista o permitir e se
|
||||
// svg_is_safe() não encontrar nada executável. Aberto diretamente no browser,
|
||||
// um SVG corre no domínio do site.
|
||||
|
||||
function upload_image_ext(array $file, array $allowed): ?string {
|
||||
$tmp = (string) ($file['tmp_name'] ?? '');
|
||||
if ($tmp === '' || !is_uploaded_file($tmp)) return null;
|
||||
|
||||
$mime = '';
|
||||
if (class_exists('finfo')) {
|
||||
$mime = (string) (new finfo(FILEINFO_MIME_TYPE))->file($tmp);
|
||||
}
|
||||
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||
|
||||
if (isset($raster[$mime]) || $mime === '') {
|
||||
$info = @getimagesize($tmp);
|
||||
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
|
||||
if ($ext === null) return null;
|
||||
if ($ext === 'jpg' && !in_array('jpg', $allowed, true) && in_array('jpeg', $allowed, true)) $ext = 'jpeg';
|
||||
return in_array($ext, $allowed, true) ? $ext : null;
|
||||
}
|
||||
|
||||
// SVG: o finfo diz image/svg+xml, text/xml ou text/plain conforme o ficheiro.
|
||||
if (in_array('svg', $allowed, true) && in_array($mime, ['image/svg+xml', 'text/xml', 'application/xml', 'text/plain', 'text/html'], true)) {
|
||||
$svg = (string) @file_get_contents($tmp, false, null, 0, 2 * 1024 * 1024);
|
||||
if (stripos($svg, '<svg') === false) return null;
|
||||
return svg_is_safe($svg) ? 'svg' : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recusa tudo o que num SVG executa código ou vai buscar coisas fora:
|
||||
// <script>, handlers on*=, javascript:, <foreignObject> (HTML embutido),
|
||||
// iframes/objetos, entidades XML e href/xlink:href que não sejam âncoras
|
||||
// internas (#id) nem imagens data:.
|
||||
function svg_is_safe(string $svg): bool {
|
||||
$bad = '/<\s*script|<\s*foreignObject|<\s*(iframe|embed|object)\b|\bon[a-z]+\s*=|javascript\s*:|<!ENTITY|<!DOCTYPE[^>]*\[/i';
|
||||
if (preg_match($bad, $svg)) return false;
|
||||
if (preg_match_all('/\b(?:xlink:)?href\s*=\s*["\']\s*([^"\']*)/i', $svg, $m)) {
|
||||
foreach ($m[1] as $href) {
|
||||
if ($href === '' || $href[0] === '#' || stripos($href, 'data:image/') === 0) continue;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resize one file in place. Returns ['status' => 'resized'|'skipped'|'failed',
|
||||
// 'saved' => bytes freed (0 unless resized), 'reason' => short PT label].
|
||||
function img_optimize(string $path, int $max_dim): array {
|
||||
|
||||
@@ -105,16 +105,16 @@ function db_memberships_update(PDO $pdo, int $id, array $d): bool {
|
||||
return $changed;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php). Deixa de ser o
|
||||
// cartão em destaque: restaurar não deve roubar o destaque a outro.
|
||||
function db_memberships_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE memberships SET deleted_at = NOW(), is_featured = 0 WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
$ok = trash_delete($pdo, 'membership', $id);
|
||||
if ($ok) $pdo->prepare("UPDATE memberships SET is_featured = 0 WHERE id = ?")->execute([$id]);
|
||||
return $ok;
|
||||
}
|
||||
|
||||
function db_memberships_restore(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE memberships SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_restore($pdo, 'membership', $id);
|
||||
}
|
||||
|
||||
// "34,90 €" / "+10 €" / "Sob consulta" — PT formatting, drops ",00".
|
||||
@@ -142,9 +142,9 @@ function membership_lead_statuses(): array {
|
||||
}
|
||||
|
||||
function db_membership_leads_list(PDO $pdo, string $status = ''): array {
|
||||
$sql = "SELECT * FROM membership_leads";
|
||||
$sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL";
|
||||
$args = [];
|
||||
if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " WHERE status = ?"; $args[] = $status; }
|
||||
if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; }
|
||||
$sql .= " ORDER BY created_at DESC, id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($args);
|
||||
@@ -152,7 +152,7 @@ function db_membership_leads_list(PDO $pdo, string $status = ''): array {
|
||||
}
|
||||
|
||||
function db_membership_leads_count_new(PDO $pdo): int {
|
||||
try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo'")->fetchColumn(); }
|
||||
try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo' AND deleted_at IS NULL")->fetchColumn(); }
|
||||
catch (Throwable $e) { return 0; }
|
||||
}
|
||||
|
||||
@@ -184,8 +184,7 @@ function db_membership_lead_update(PDO $pdo, int $id, string $status, string $no
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Vai para a lixeira (config/functions_trash.php).
|
||||
function db_membership_lead_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("DELETE FROM membership_leads WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'membership_lead', $id);
|
||||
}
|
||||
@@ -48,16 +48,13 @@ function db_modalidades_update(PDO $pdo, int $id, array $data): bool {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php).
|
||||
function db_modalidades_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE modalidades SET deleted_at = NOW() WHERE m_id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'modalidade', $id);
|
||||
}
|
||||
|
||||
function db_modalidades_restore(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE modalidades SET deleted_at = NULL WHERE m_id = ? AND deleted_at IS NOT NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_restore($pdo, 'modalidade', $id);
|
||||
}
|
||||
|
||||
// All flaticon names (populated from the flaticon table).
|
||||
@@ -100,9 +97,11 @@ function modalidades_icon_upload(array $file, ?string &$error = null): ?string {
|
||||
return null;
|
||||
}
|
||||
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
|
||||
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
$error = 'Formato inválido. Permitidos: ' . implode(', ', $allowed) . '.';
|
||||
// Tipo decidido pelo conteúdo, não pelo nome; SVG só sem scripts
|
||||
// (config/functions_images.php).
|
||||
$ext = upload_image_ext($file, $allowed);
|
||||
if ($ext === null) {
|
||||
$error = 'Formato inválido ou ficheiro que não é uma imagem válida. Permitidos: ' . implode(', ', $allowed) . '.';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function site_offer(PDO $pdo): array {
|
||||
}
|
||||
|
||||
function site_work_hours(PDO $pdo): array {
|
||||
try { return $pdo->query("SELECT * FROM work_hours")->fetchAll(PDO::FETCH_ASSOC); }
|
||||
try { return $pdo->query("SELECT * FROM work_hours WHERE deleted_at IS NULL")->fetchAll(PDO::FETCH_ASSOC); }
|
||||
catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
@@ -96,9 +96,10 @@ function site_horario(PDO $pdo, string $local = ''): array {
|
||||
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim,
|
||||
m.nome, m.cor, m.icon
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade";
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL
|
||||
WHERE h.deleted_at IS NULL";
|
||||
$args = [];
|
||||
if ($local !== '') { $sql .= " WHERE h.local = ?"; $args[] = $local; }
|
||||
if ($local !== '') { $sql .= " AND h.local = ?"; $args[] = $local; }
|
||||
$sql .= " ORDER BY h.hora_inicio ASC, m.nome ASC";
|
||||
try { $st = $pdo->prepare($sql); $st->execute($args); return $st->fetchAll(PDO::FETCH_ASSOC); }
|
||||
catch (Throwable $e) { return []; }
|
||||
|
||||
@@ -58,14 +58,11 @@ function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null):
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php).
|
||||
function db_team_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE team SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'team', $id);
|
||||
}
|
||||
|
||||
function db_team_restore(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("UPDATE team SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_restore($pdo, 'team', $id);
|
||||
}
|
||||
@@ -12,12 +12,20 @@
|
||||
//
|
||||
// Schema: database/migration_20260913_local_auth.sql (`auth_attempts`).
|
||||
|
||||
// IP real do visitante (config/env.php). Antes era o REMOTE_ADDR, que atrás do
|
||||
// proxy é o mesmo para toda a gente — o limite era global.
|
||||
function _throttle_ip(): string {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
return $ip !== '' ? $ip : '0.0.0.0';
|
||||
return client_ip();
|
||||
}
|
||||
|
||||
function throttle_check(PDO $pdo, string $scope, int $maxPerMinute = 5, int $maxPerHour = 30): bool {
|
||||
// Chave por conta: o mesmo email atacado a partir de muitos IPs só se vê
|
||||
// contado pela conta. Guardada na coluna ip (VARCHAR 45) como "acct:" + hash,
|
||||
// nunca o email em claro.
|
||||
function throttle_account_key(string $identifier): string {
|
||||
return 'acct:' . substr(hash('sha256', strtolower(trim($identifier))), 0, 40);
|
||||
}
|
||||
|
||||
function throttle_check(PDO $pdo, string $scope, int $maxPerMinute = 5, int $maxPerHour = 30, ?string $key = null): bool {
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
@@ -26,7 +34,7 @@ function throttle_check(PDO $pdo, string $scope, int $maxPerMinute = 5, int $max
|
||||
FROM auth_attempts
|
||||
WHERE ip = ? AND scope = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
|
||||
);
|
||||
$stmt->execute([_throttle_ip(), $scope]);
|
||||
$stmt->execute([$key ?? _throttle_ip(), $scope]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['minute' => 0, 'hour' => 0];
|
||||
} catch (PDOException $e) {
|
||||
// Fail open if the table is missing (migration not applied yet during a
|
||||
@@ -39,10 +47,10 @@ function throttle_check(PDO $pdo, string $scope, int $maxPerMinute = 5, int $max
|
||||
return true;
|
||||
}
|
||||
|
||||
function throttle_record(PDO $pdo, string $scope): void {
|
||||
function throttle_record(PDO $pdo, string $scope, ?string $key = null): void {
|
||||
try {
|
||||
$pdo->prepare("INSERT INTO auth_attempts (ip, scope) VALUES (?, ?)")
|
||||
->execute([_throttle_ip(), $scope]);
|
||||
->execute([$key ?? _throttle_ip(), $scope]);
|
||||
// Probabilistic cleanup so the table doesn't grow forever — cheap on a
|
||||
// 1% sample of writes, and saves wiring up a cron for a small install.
|
||||
if (random_int(0, 99) === 0) {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ function admin_exists(PDO $pdo): bool {
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
// Hash bcrypt (custo 12) de uma password aleatória descartada — só serve para
|
||||
// igualar o tempo de resposta quando a conta não existe.
|
||||
const AUTH_DUMMY_HASH = '$2y$12$luPhZpP46FkyufPCM1d8Gu4lPNS3A/I0gSvoBiNEL3lZAauMkuzH2';
|
||||
|
||||
// Verifies credentials and stamps the login trail. Returns the user row on
|
||||
// success, null on bad email / bad password / inactive account / no password
|
||||
// set. Callers must not leak which of the four it was.
|
||||
@@ -37,7 +41,14 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
if (!$user) {
|
||||
// Sem conta, verifica na mesma contra um hash de referência: um bcrypt
|
||||
// de custo 12 demora dezenas de ms, e responder mais depressa quando o
|
||||
// email não existe diria a quem tenta quais emails têm conta.
|
||||
password_verify($password, AUTH_DUMMY_HASH);
|
||||
return null;
|
||||
}
|
||||
if (!password_verify($password, $user['password_hash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,7 +58,7 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ip = client_ip();
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
@@ -219,7 +230,10 @@ function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword):
|
||||
// Delivery is centralized at the hub's /notify endpoint — see
|
||||
// config/functions_queue.php (enqueue_email + flush_message).
|
||||
|
||||
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null): bool {
|
||||
// $flush=false deixa a mensagem na fila para o cron (≤5 min). O pedido de
|
||||
// recuperação usa-o: um envio SMTP síncrono só quando a conta existe fazia a
|
||||
// resposta demorar segundos apenas nesse caso — um oráculo de contas.
|
||||
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null, bool $flush = true): bool {
|
||||
$body = load_email_template('password_reset', [
|
||||
'user_name' => $name,
|
||||
'reset_url' => $resetUrl,
|
||||
@@ -235,7 +249,7 @@ function send_password_reset_email(string $name, string $email, string $resetUrl
|
||||
'context_type' => 'password_reset',
|
||||
'context_id' => $userId,
|
||||
]);
|
||||
return flush_message($id);
|
||||
return $flush ? flush_message($id) : $id > 0;
|
||||
}
|
||||
|
||||
// An account created from the backoffice has no password, so the person has no
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
function db_whours_list(PDO $pdo): array {
|
||||
return $pdo->query("
|
||||
SELECT * FROM work_hours
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY FIELD(week_day,'segunda','terça','quarta','quinta','sexta','sabado','domingo','feriados')
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
@@ -27,8 +28,7 @@ function db_whours_update(PDO $pdo, int $id, array $data): bool {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// Soft delete para a lixeira (config/functions_trash.php).
|
||||
function db_whours_delete(PDO $pdo, int $id): bool {
|
||||
$stmt = $pdo->prepare("DELETE FROM work_hours WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
return trash_delete($pdo, 'work_hours', $id);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-- ─── Lixeira: soft delete em tudo o que é importante ────────────────────────
|
||||
-- Apagar no backoffice passa a marcar deleted_at (+ quem apagou). O registo
|
||||
-- fica em Sistema → Lixeira, onde se restaura a qualquer momento e só se
|
||||
-- elimina de vez depois de 30 dias (regra em config/functions_trash.php, no
|
||||
-- próprio WHERE do DELETE).
|
||||
--
|
||||
-- Só acrescenta colunas e índices. Safe to re-run.
|
||||
|
||||
-- Tabelas que já tinham deleted_at: falta saber quem apagou.
|
||||
ALTER TABLE `team` ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL AFTER `deleted_at`;
|
||||
ALTER TABLE `modalidades` ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL AFTER `deleted_at`;
|
||||
ALTER TABLE `events` ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL AFTER `deleted_at`;
|
||||
ALTER TABLE `memberships` ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL AFTER `deleted_at`;
|
||||
|
||||
-- Tabelas que eram apagadas de vez.
|
||||
ALTER TABLE `galleries`
|
||||
ADD COLUMN IF NOT EXISTS `deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
ADD INDEX IF NOT EXISTS `idx_galleries_deleted` (`deleted_at`);
|
||||
|
||||
ALTER TABLE `gallery`
|
||||
ADD COLUMN IF NOT EXISTS `deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
ADD INDEX IF NOT EXISTS `idx_gallery_deleted` (`deleted_at`);
|
||||
|
||||
ALTER TABLE `horario`
|
||||
ADD COLUMN IF NOT EXISTS `deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
ADD INDEX IF NOT EXISTS `idx_horario_deleted` (`deleted_at`);
|
||||
|
||||
ALTER TABLE `work_hours`
|
||||
ADD COLUMN IF NOT EXISTS `deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `event_leads`
|
||||
ADD COLUMN IF NOT EXISTS `deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `membership_leads`
|
||||
ADD COLUMN IF NOT EXISTS `deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `deleted_by` INT UNSIGNED NULL DEFAULT NULL;
|
||||
@@ -142,6 +142,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/info/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/info/edit.php') ?>"><i class="bi bi-telephone-fill"></i>Contactos</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trash/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/trash/index.php') ?>"><i class="bi bi-trash3-fill"></i>Lixeira</a>
|
||||
</li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// Página 404 (ErrorDocument no .htaccess). Autónoma de propósito: sem base de
|
||||
// dados nem sessão, para funcionar mesmo quando o resto não funciona.
|
||||
http_response_code(404);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="pt-PT">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Página não encontrada — FitMe 3070</title>
|
||||
<style>
|
||||
:root { --bg:#0b0b0d; --ink:#f5f5f5; --muted:#a3a69e; --accent:#c2d500; }
|
||||
@media (prefers-color-scheme: light) { :root { --bg:#f7f8fa; --ink:#0b0b0d; --muted:#5b5e57; --accent:#6b7a00; } }
|
||||
body { margin:0; min-height:100svh; display:grid; place-items:center; background:var(--bg); color:var(--ink);
|
||||
font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; padding:16px; box-sizing:border-box; }
|
||||
main { max-width:460px; text-align:center; }
|
||||
h1 { font-size:clamp(3rem,12vw,5rem); margin:0; color:var(--accent); line-height:1; }
|
||||
p { color:var(--muted); }
|
||||
a { display:inline-block; margin-top:.5rem; padding:.7rem 1.4rem; border-radius:999px; background:var(--accent);
|
||||
color:#0b0b0d; font-weight:600; text-decoration:none; min-height:44px; box-sizing:border-box; }
|
||||
a:focus-visible { outline:3px solid var(--ink); outline-offset:3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>404</h1>
|
||||
<h2>Página não encontrada</h2>
|
||||
<p>O endereço que procura não existe ou mudou de sítio.</p>
|
||||
<a href="/">Voltar ao início</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+3
-2
@@ -36,8 +36,9 @@ if ($can_view && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
'message' => trim((string) ($_POST['lead_message'] ?? '')),
|
||||
'consent' => !empty($_POST['lead_consent']) ? 1 : 0,
|
||||
'consent_text' => $consent_text,
|
||||
// First hop of X-Forwarded-For (behind the id.andmore.pt proxy), else peer.
|
||||
'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
// IP real atrás do proxy (config/env.php) — o primeiro valor do
|
||||
// X-Forwarded-For é escrito pelo cliente.
|
||||
'ip' => client_ip(),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
];
|
||||
if ($lead['name'] === '') $lead_errors[] = 'Indica o teu nome.';
|
||||
|
||||
+3
-2
@@ -38,8 +38,9 @@ $lead = [
|
||||
'message' => trim((string) ($_POST['mensagem'] ?? '')),
|
||||
'consent' => !empty($_POST['consent']) ? 1 : 0,
|
||||
'consent_text' => $consent_text,
|
||||
// First hop of X-Forwarded-For (behind the reverse proxy), else peer.
|
||||
'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
// IP real atrás do proxy (config/env.php) — o primeiro valor do
|
||||
// X-Forwarded-For é escrito pelo cliente.
|
||||
'ip' => client_ip(),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ $user = require_admin();
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if (!$id) { header('Location: index.php'); exit; }
|
||||
|
||||
if (isset($_GET['restore'])) {
|
||||
// Restaurar só por POST + CSRF (o formulário aponta para ?restore=1).
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['restore']) || isset($_GET['restore']))) {
|
||||
csrf_check();
|
||||
db_events_restore($pdo, $id);
|
||||
session()->setFlashdata('success', 'Evento restaurado.');
|
||||
@@ -21,7 +22,7 @@ if (!$item) { header('Location: index.php'); exit; }
|
||||
if (isset($_POST['delete_lead'])) {
|
||||
csrf_check();
|
||||
db_event_lead_delete($pdo, (int) $_POST['delete_lead'], $id);
|
||||
session()->setFlashdata('success', 'Inscrição removida.');
|
||||
session()->setFlashdata('success', 'Inscrição removida — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/events/edit.php?id=' . $id . '#leads'));
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Handle delete BEFORE any output
|
||||
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['delete']) && is_numeric($_POST['delete'])) {
|
||||
csrf_check();
|
||||
db_events_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Evento apagado.');
|
||||
db_events_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Evento apagado — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/events/index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -70,7 +71,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('evento/' . rawurlencode($item['slug'])) ?>" target="_blank" rel="noopener" title="Ver página"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['e_id'] ?>"><i class="bi bi-pencil"></i></a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['e_id'] ?>" onclick="return confirm('Apagar este evento?')"><i class="bi bi-trash"></i></a>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Apagar este evento?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $item['e_id'] ?>" aria-label="Apagar"><i class="bi bi-trash"></i></button></form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -45,10 +45,11 @@ if (isset($_POST['optimize'])) {
|
||||
}
|
||||
|
||||
// Delete image
|
||||
if (isset($_GET['del_img']) && is_numeric($_GET['del_img'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['del_img']) && is_numeric($_POST['del_img'])) {
|
||||
csrf_check();
|
||||
db_gallery_image_delete($pdo, (int) $_GET['del_img'], $id);
|
||||
session()->setFlashdata('success', 'Imagem removida.');
|
||||
db_gallery_image_delete($pdo, (int) $_POST['del_img'], $id);
|
||||
session()->setFlashdata('success', 'Imagem removida — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
@@ -112,10 +113,11 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<div class="col-6 col-lg-4 position-relative">
|
||||
<img src="<?= base_url('uploads/gallery/' . $id . '/' . $img['i_file_name']) ?>"
|
||||
alt="" class="img-thumbnail w-100" style="height:140px;object-fit:cover;" loading="lazy">
|
||||
<a class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 rounded-circle"
|
||||
href="?id=<?= $id ?>&del_img=<?= $img['i_id'] ?>"
|
||||
onclick="return confirm('Remover esta imagem?')"
|
||||
style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:12px">×</a>
|
||||
<form method="POST" class="position-absolute top-0 end-0 m-1" onsubmit="return confirm('Remover esta imagem? Fica na lixeira durante 30 dias.')">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-sm btn-danger rounded-circle" name="del_img" value="<?= (int) $img['i_id'] ?>" aria-label="Remover imagem"
|
||||
style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:12px">×</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,11 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Handle delete BEFORE any output
|
||||
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['delete']) && is_numeric($_POST['delete'])) {
|
||||
csrf_check();
|
||||
db_gallery_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Galeria apagada.');
|
||||
db_gallery_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Galeria apagada — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/galerias/index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -58,7 +59,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<td><strong><?= htmlspecialchars($g['name']) ?></strong></td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $g['id'] ?>"><i class="bi bi-pencil"></i> Gerir</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $g['id'] ?>" onclick="return confirm('Apagar esta galeria e todas as suas imagens?')"><i class="bi bi-trash"></i></a>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Apagar esta galeria e todas as suas imagens?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $g['id'] ?>" aria-label="Apagar"><i class="bi bi-trash"></i></button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
|
||||
@@ -4,10 +4,11 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Handle delete BEFORE any output
|
||||
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['delete']) && is_numeric($_POST['delete'])) {
|
||||
csrf_check();
|
||||
db_horario_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Aula removida.');
|
||||
db_horario_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Aula removida — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/horario/index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -49,7 +50,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
|
||||
<td><span class="badge bg-light text-dark"><?= htmlspecialchars($item['local']) ?></span></td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['id'] ?>"><i class="bi bi-pencil"></i></a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['id'] ?>" onclick="return confirm('Remover esta aula?')"><i class="bi bi-trash"></i></a>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Remover esta aula?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $item['id'] ?>" aria-label="Apagar"><i class="bi bi-trash"></i></button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
|
||||
@@ -8,7 +8,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
if (isset($_POST['delete'])) {
|
||||
db_memberships_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Mensalidade apagada.');
|
||||
session()->setFlashdata('success', 'Mensalidade apagada — fica na lixeira 30 dias.');
|
||||
} elseif (isset($_POST['restore'])) {
|
||||
db_memberships_restore($pdo, (int) $_POST['restore']);
|
||||
session()->setFlashdata('success', 'Mensalidade restaurada.');
|
||||
|
||||
@@ -12,7 +12,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
if (isset($_POST['delete'])) {
|
||||
db_membership_lead_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Pedido apagado.');
|
||||
session()->setFlashdata('success', 'Pedido apagado — fica na lixeira 30 dias.');
|
||||
} elseif (isset($_POST['save'])) {
|
||||
$id = (int) $_POST['save'];
|
||||
db_membership_lead_update($pdo, $id, (string) ($_POST['status'][$id] ?? ''), trim((string) ($_POST['notes'][$id] ?? '')));
|
||||
|
||||
@@ -6,7 +6,8 @@ $user = require_admin();
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if (!$id) { header('Location: index.php'); exit; }
|
||||
|
||||
if (isset($_GET['restore'])) {
|
||||
// Restaurar só por POST + CSRF (o formulário aponta para ?restore=1).
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['restore']) || isset($_GET['restore']))) {
|
||||
csrf_check();
|
||||
db_modalidades_restore($pdo, $id);
|
||||
session()->setFlashdata('success', 'Modalidade restaurada.');
|
||||
|
||||
@@ -4,10 +4,11 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Handle delete BEFORE any output
|
||||
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['delete']) && is_numeric($_POST['delete'])) {
|
||||
csrf_check();
|
||||
db_modalidades_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Modalidade apagada.');
|
||||
db_modalidades_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Modalidade apagada — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/modalidades/index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -60,10 +61,10 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<td><?= (int) $item['item_order'] ?></td>
|
||||
<td>
|
||||
<?php if ($item['deleted_at']): ?>
|
||||
<a class="btn btn-sm btn-outline-info" href="edit.php?id=<?= $item['m_id'] ?>&restore=1">Restaurar</a>
|
||||
<form method="POST" action="edit.php?id=<?= (int) $item['m_id'] ?>&restore=1" class="d-inline"><?= csrf_field() ?><button class="btn btn-sm btn-outline-info" name="restore" value="1">Restaurar</button></form>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['m_id'] ?>"><i class="bi bi-pencil"></i></a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['m_id'] ?>" onclick="return confirm('Apagar esta modalidade?')"><i class="bi bi-trash"></i></a>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Apagar esta modalidade?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $item['m_id'] ?>" aria-label="Apagar"><i class="bi bi-trash"></i></button></form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -24,8 +24,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Photo upload
|
||||
$photo_name = null;
|
||||
if (!empty($_FILES['photo']['tmp_name']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) {
|
||||
$ext = strtolower(pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) $errors[] = 'Formato de foto inválido.';
|
||||
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
|
||||
$ext = upload_image_ext($_FILES['photo'], ['jpg','jpeg','png','gif','webp']);
|
||||
if ($ext === null) $errors[] = 'Formato de foto inválido (JPG, PNG, GIF ou WebP).';
|
||||
else {
|
||||
$upload_dir = __DIR__ . '/../../../uploads/team/foto/';
|
||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||
|
||||
@@ -7,7 +7,8 @@ $id = (int) ($_GET['id'] ?? 0);
|
||||
if (!$id) { header('Location: index.php'); exit; }
|
||||
|
||||
// Handle restore
|
||||
if (isset($_GET['restore'])) {
|
||||
// Restaurar só por POST + CSRF (o formulário aponta para ?restore=1).
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['restore']) || isset($_GET['restore']))) {
|
||||
csrf_check();
|
||||
db_team_restore($pdo, $id);
|
||||
session()->setFlashdata('success', 'Membro restaurado.');
|
||||
@@ -37,8 +38,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$photo_name = null;
|
||||
if (!empty($_FILES['photo']['tmp_name']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) {
|
||||
$ext = strtolower(pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) $errors[] = 'Formato de foto inválido.';
|
||||
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
|
||||
$ext = upload_image_ext($_FILES['photo'], ['jpg','jpeg','png','gif','webp']);
|
||||
if ($ext === null) $errors[] = 'Formato de foto inválido (JPG, PNG, GIF ou WebP).';
|
||||
else {
|
||||
$upload_dir = __DIR__ . '/../../../uploads/team/foto/';
|
||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||
|
||||
@@ -4,10 +4,11 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Handle delete BEFORE any output
|
||||
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['delete']) && is_numeric($_POST['delete'])) {
|
||||
csrf_check();
|
||||
db_team_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Membro apagado.');
|
||||
db_team_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Membro apagado — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/team/index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -59,10 +60,10 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<?php if ($item['deleted_at']): ?><span class="badge bg-danger ms-1">apagado</span><?php endif; ?></td>
|
||||
<td>
|
||||
<?php if ($item['deleted_at']): ?>
|
||||
<a class="btn btn-sm btn-outline-info" href="edit.php?id=<?= $item['id'] ?>&restore=1">Restaurar</a>
|
||||
<form method="POST" action="edit.php?id=<?= (int) $item['id'] ?>&restore=1" class="d-inline"><?= csrf_field() ?><button class="btn btn-sm btn-outline-info" name="restore" value="1">Restaurar</button></form>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['id'] ?>"><i class="bi bi-pencil"></i></a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['id'] ?>" onclick="return confirm('Apagar este membro?')"><i class="bi bi-trash"></i></a>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Apagar este membro?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $item['id'] ?>" aria-label="Apagar"><i class="bi bi-trash"></i></button></form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../../config/db.php';
|
||||
require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Lixeira: tudo o que foi apagado no backoffice. Restaurar é sempre possível;
|
||||
// eliminar de vez só depois de TRASH_RETENTION_DAYS dias — trash_purge() volta
|
||||
// a verificar a data no próprio DELETE, por isso um POST forjado antes do prazo
|
||||
// não apaga nada.
|
||||
$types = trash_types();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$type = (string) ($_POST['type'] ?? '');
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!isset($types[$type]) || $id < 1) {
|
||||
session()->setFlashdata('error', 'Pedido inválido.');
|
||||
} elseif (isset($_POST['restore'])) {
|
||||
trash_restore($pdo, $type, $id)
|
||||
? session()->setFlashdata('success', $types[$type]['label'] . ' restaurado(a).')
|
||||
: session()->setFlashdata('error', 'Não foi possível restaurar (já não está na lixeira?).');
|
||||
} elseif (isset($_POST['purge'])) {
|
||||
trash_purge($pdo, $type, $id)
|
||||
? session()->setFlashdata('success', $types[$type]['label'] . ' eliminado(a) definitivamente.')
|
||||
: session()->setFlashdata('error', 'Só é possível eliminar de vez ao fim de ' . TRASH_RETENTION_DAYS . ' dias na lixeira.');
|
||||
}
|
||||
header('Location: ' . base_url('views/admin/trash/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$items = trash_list($pdo);
|
||||
$page_title = 'Lixeira';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h1 class="h3 mb-0">Lixeira</h1>
|
||||
<span class="badge bg-secondary"><?= count($items) ?></span>
|
||||
</div>
|
||||
<p class="text-muted small mb-4">
|
||||
O que se apaga no backoffice fica aqui. Pode ser restaurado a qualquer momento;
|
||||
só pode ser eliminado de vez depois de <?= (int) TRASH_RETENTION_DAYS ?> dias na lixeira.
|
||||
</p>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<th>Tipo</th><th>Registo</th><th>Apagado em</th><th>Por</th><th class="text-end">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php if (!$items): ?>
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">A lixeira está vazia.</td></tr>
|
||||
<?php else: foreach ($items as $it): ?>
|
||||
<tr>
|
||||
<td class="small text-nowrap">
|
||||
<?= htmlspecialchars($it['label']) ?>
|
||||
<?php if ($it['personal']): ?><span class="badge bg-warning text-dark ms-1" title="Contém dados pessoais">RGPD</span><?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars((string) $it['title']) ?></td>
|
||||
<td class="small text-nowrap"><?= htmlspecialchars(date('d/m/Y H:i', strtotime((string) $it['deleted_at']))) ?></td>
|
||||
<td class="small"><?= htmlspecialchars((string) ($it['deleted_by_name'] ?? '—')) ?></td>
|
||||
<td class="text-end text-nowrap">
|
||||
<form method="POST" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="type" value="<?= htmlspecialchars($it['type']) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int) $it['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-info" name="restore" value="1"><i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar</button>
|
||||
</form>
|
||||
<?php if ($it['can_purge']): ?>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Eliminar de vez? Esta ação não pode ser desfeita.')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="type" value="<?= htmlspecialchars($it['type']) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int) $it['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger" name="purge" value="1"><i class="bi bi-x-octagon me-1"></i>Eliminar de vez</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled
|
||||
title="Pode ser eliminado de vez daqui a <?= (int) $it['days_left'] ?> dia(s)">
|
||||
<i class="bi bi-hourglass-split me-1"></i><?= (int) $it['days_left'] ?> dia(s)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -4,10 +4,11 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
// Handle delete BEFORE any output
|
||||
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
|
||||
// POST + CSRF: um link GET podia ser disparado a partir de outro site.
|
||||
if (isset($_POST['delete']) && is_numeric($_POST['delete'])) {
|
||||
csrf_check();
|
||||
db_whours_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Horário removido.');
|
||||
db_whours_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata('success', 'Horário removido — fica na lixeira 30 dias.');
|
||||
header('Location: ' . base_url('views/admin/work_hours/index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -40,7 +41,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
|
||||
<td><?= htmlspecialchars(substr((string) $item['close'], 0, 5)) ?></td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['id'] ?>"><i class="bi bi-pencil"></i></a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['id'] ?>" onclick="return confirm('Remover este dia?')"><i class="bi bi-trash"></i></a>
|
||||
<form method="POST" class="d-inline" onsubmit="return confirm('Remover este dia?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $item['id'] ?>" aria-label="Apagar"><i class="bi bi-trash"></i></button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
|
||||
@@ -40,7 +40,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
(string) $user['name'],
|
||||
(string) $user['email'],
|
||||
base_url('views/auth/reset_password.php?token=' . $token),
|
||||
(int) $user['id']
|
||||
(int) $user['id'],
|
||||
false // fica na fila: o tempo de resposta não pode denunciar a conta
|
||||
);
|
||||
}
|
||||
// Same message either way.
|
||||
@@ -59,8 +60,8 @@ include __DIR__ . '/../../includes/auth_header.php';
|
||||
|
||||
<?php if ($sent): ?>
|
||||
<div class="alert alert-success py-2 small">
|
||||
Se existir uma conta com esse email, enviámos-lhe as instruções.
|
||||
O link é válido durante 2 horas.
|
||||
Se existir uma conta com esse email, vai receber as instruções
|
||||
nos próximos minutos. O link é válido durante 2 horas.
|
||||
</div>
|
||||
<a href="<?= htmlspecialchars(base_url('views/auth/login.php')) ?>" class="btn btn-outline-secondary w-100">Voltar ao login</a>
|
||||
<?php else: ?>
|
||||
|
||||
@@ -37,11 +37,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
// Counts successes too, so a guessed password can't be parlayed into
|
||||
// stuffing the rest of the user list from the same IP.
|
||||
if (!throttle_check($pdo, 'login')) {
|
||||
// Dois limites: por IP (5/min, 30/h) e por conta (10/min, 50/h). O da conta
|
||||
// apanha um ataque distribuído a um só email; é mais largo para que um
|
||||
// estranho não consiga trancar o dono com meia dúzia de tentativas.
|
||||
$acct = throttle_account_key((string) ($_POST['email'] ?? ''));
|
||||
if (!throttle_check($pdo, 'login') || !throttle_check($pdo, 'login_acct', 10, 50, $acct)) {
|
||||
$report_login('login_throttled');
|
||||
throttle_block('login form');
|
||||
}
|
||||
throttle_record($pdo, 'login');
|
||||
throttle_record($pdo, 'login_acct', $acct);
|
||||
|
||||
$user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
|
||||
if ($user) {
|
||||
@@ -89,7 +94,7 @@ include __DIR__ . '/../../includes/auth_header.php';
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="login-email">Email</label>
|
||||
<input type="email" id="login-email" name="email" class="form-control" required autofocus
|
||||
autocomplete="email" inputmode="email"
|
||||
autocomplete="username" inputmode="email"
|
||||
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
$user = require_admin();
|
||||
$user = require_sysadmin(); // zona de sysadmin: o link só aparece a sysadmin e o servidor tem de concordar
|
||||
|
||||
$logo_dir = __DIR__ . '/../../assets/img/logo/';
|
||||
$allowed_ext = ['png', 'jpg', 'jpeg', 'svg', 'webp'];
|
||||
@@ -54,9 +54,11 @@ function _settings_handle_logo_upload(array $file, string $dir, array $allowed,
|
||||
$errors[] = "O logo {$kind} excede 2 MB.";
|
||||
return null;
|
||||
}
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
$errors[] = "Tipo de ficheiro não permitido para o logo {$kind}.";
|
||||
// Tipo decidido pelo conteúdo, não pelo nome; SVG só sem scripts
|
||||
// (config/functions_images.php).
|
||||
$ext = upload_image_ext($file, $allowed);
|
||||
if ($ext === null) {
|
||||
$errors[] = "Tipo de ficheiro não permitido (ou SVG com conteúdo ativo) para o logo {$kind}.";
|
||||
return null;
|
||||
}
|
||||
$name = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
|
||||
Reference in new issue
Block a user