diff --git a/.htaccess b/.htaccess index 0ed500c..a9369dd 100644 --- a/.htaccess +++ b/.htaccess @@ -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, +# , plugins e para onde os formulários podem enviar. + + 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" + + # ── 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 diff --git a/api/delete_hub-content.php b/api/delete_hub-content.php index ebb060a..95a19a1 100644 --- a/api/delete_hub-content.php +++ b/api/delete_hub-content.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); diff --git a/config/db.php b/config/db.php index 5819b37..a037d0d 100644 --- a/config/db.php +++ b/config/db.php @@ -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(); } diff --git a/config/env.php b/config/env.php index e8860b1..a768fdf 100644 --- a/config/env.php +++ b/config/env.php @@ -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). diff --git a/config/error_handler.php b/config/error_handler.php index 53a3315..8a74d15 100644 --- a/config/error_handler.php +++ b/config/error_handler.php @@ -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'), diff --git a/config/functions.php b/config/functions.php index ee54c12..a988bc0 100644 --- a/config/functions.php +++ b/config/functions.php @@ -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 diff --git a/config/functions_csrf.php b/config/functions_csrf.php index 3c5d74a..5ea1819 100644 --- a/config/functions_csrf.php +++ b/config/functions_csrf.php @@ -31,8 +31,19 @@ function csrf_field(): string { return ''; } +// 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 'Método não permitido' + . '

Esta ação só pode ser feita a partir do formulário.

'; + exit; + } $supplied = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] diff --git a/config/functions_events.php b/config/functions_events.php index 20922d4..e55f53c 100644 --- a/config/functions_events.php +++ b/config/functions_events.php @@ -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); } diff --git a/config/functions_galerias.php b/config/functions_galerias.php index 3b3d045..f1342c9 100644 --- a/config/functions_galerias.php +++ b/config/functions_galerias.php @@ -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//.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); } diff --git a/config/functions_horario.php b/config/functions_horario.php index 7f2f0af..32c1e45 100644 --- a/config/functions_horario.php +++ b/config/functions_horario.php @@ -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). diff --git a/config/functions_images.php b/config/functions_images.php index c1b670f..d3625c7 100644 --- a/config/functions_images.php +++ b/config/functions_images.php @@ -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