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