From b202262da0e3b774ba318662b1711aaad2b2d263 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 25 Sep 2026 11:39:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(rgpd):=20pol=C3=ADtica=20de=20privacidade?= =?UTF-8?q?=20e=20termos,=20mapa=20a=20pedido,=20fotos=20locais,=20reten?= =?UTF-8?q?=C3=A7=C3=A3o,=20tema=20claro/escuro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Política de privacidade reescrita: tabela de dados/finalidades/fundamento (inclui os pedidos de aula, inscrição e eventos com IP), prazos concretos, subcontratantes, Google Maps e reviews, cookies. Termos e condições novos (/home/termos), no rodapé e no sitemap. Campos [ENTRE PARÊNTESES RETOS] e revisão por jurista pendentes antes de publicar no domínio real. - Google Maps só carrega ao carregar em «Mostrar mapa» (map_click_to_load); as fotos dos autores das reviews são guardadas pelo servidor em uploads/reviews/ e servidas localmente (review_avatar_url). - Retenção que a política declara, aplicada pelo cron (04:00): leads com mais de 12 meses → lixeira; blocked_submissions e outbox de segurança apagados aos 90 dias (trash_retention_sweep, auditado). - Backoffice e login com tema claro/escuro/automático: data-bs-theme aplicado no antes do CSS (sem flash), escolha em localStorage, segue o sistema em tempo real, color-scheme e theme-color para os dois. Cores próprias passaram a variáveis do Bootstrap. lang="pt-PT". Testado numa cópia da BD de produção: 72/72 verificações. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 ++ .htaccess | 1 + config/functions.php | 1 + config/functions_privacy.php | 61 +++++++++++++++++++++++++++++ config/functions_site.php | 1 + config/functions_trash.php | 29 ++++++++++++++ cron.php | 2 + includes/auth_header.php | 23 +++++++++-- includes/footer.php | 33 ++++++++++++++++ includes/header.php | 40 ++++++++++++++++--- site/includes/footer.php | 2 + site/includes/include.php | 2 + site/includes/map.php | 2 +- site/index.php | 5 ++- site/privacidade.php | 74 ++++++++++++++++++++++++++---------- site/termos.php | 51 +++++++++++++++++++++++++ 16 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 config/functions_privacy.php create mode 100644 site/termos.php diff --git a/.gitignore b/.gitignore index 6a31658..df0e4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ uploads/events/* # apagados à espera dos 30 dias. Nunca vão para o git. uploads/**/.trash/ +# Fotos dos autores das reviews do Google, guardadas pelo servidor +# (config/functions_privacy.php) para o browser não as pedir à Google. +uploads/reviews/ + # Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão # versionados (vieram assim do site antigo). Em produção uploads/ é um volume # (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é diff --git a/.htaccess b/.htaccess index 2bc1f02..73816ed 100644 --- a/.htaccess +++ b/.htaccess @@ -60,6 +60,7 @@ RewriteRule ^home/agendar/?$ site/agendar.php [L,QSA] RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA] RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA] RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA] +RewriteRule ^home/termos/?$ site/termos.php [L,QSA] RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA] # ── Ginásios: // (código postal da zona, ex.: /3070/) ───────────────── # Uma pasta por ginásio (Gestão → Ginásios); pastas desconhecidas ou por publicar diff --git a/config/functions.php b/config/functions.php index 69f524d..c29a481 100644 --- a/config/functions.php +++ b/config/functions.php @@ -38,6 +38,7 @@ require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_i require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental +require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos // ─── DB-backed settings → legacy constants ─────────────────────────────────── diff --git a/config/functions_privacy.php b/config/functions_privacy.php new file mode 100644 index 0000000..7eb06ec --- /dev/null +++ b/config/functions_privacy.php @@ -0,0 +1,61 @@ + e só entra na página +// quando se carrega no botão; o link «Abrir no Google Maps» funciona sem ele; +// - review_avatar_url(): o servidor guarda a foto uma vez em uploads/reviews/ +// e o browser pede-a ao próprio site. + +function map_click_to_load(string $iframeHtml, string $mapsLink = ''): string { + if (trim($iframeHtml) === '') return ''; + static $n = 0; + $id = 'map-consent-' . (++$n); + $esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); + $link = $mapsLink !== '' + ? 'Abrir no Google Maps' + : ''; + return ''; +} + +function review_avatar_url(array $r): string { + $src = (string) ($r['author_photo'] ?? ''); + if ($src === '' || !preg_match('#^https://[a-z0-9.-]*googleusercontent\.com/#i', $src)) return ''; + + $base = substr(sha1($src), 0, 24); + $dir = dirname(__DIR__) . '/uploads/reviews/'; + foreach (['jpg', 'png', 'webp'] as $ext) { + if (is_file($dir . $base . '.' . $ext)) return base_url('uploads/reviews/' . $base . '.' . $ext); + } + // Uma falha recente não se repete a cada visita (cada tentativa custa até 4 s). + $fail = $dir . '.' . $base . '.fail'; + if (is_file($fail) && filemtime($fail) > time() - 86400) return ''; + if (!function_exists('curl_init')) return ''; + + $ch = curl_init($src); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2, + CURLOPT_TIMEOUT => 4, CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + ]); + $bin = curl_exec($ch); + curl_close($ch); + + $info = (is_string($bin) && strlen($bin) > 0 && strlen($bin) < 512 * 1024) ? @getimagesizefromstring($bin) : false; + $ext = $info ? (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$info['mime']] ?? null) : null; + if (!is_dir($dir)) @mkdir($dir, 0775, true); + if ($ext === null || !@file_put_contents($dir . $base . '.' . $ext, $bin)) { + @touch($fail); + return ''; + } + return base_url('uploads/reviews/' . $base . '.' . $ext); +} diff --git a/config/functions_site.php b/config/functions_site.php index 450af61..9cd08fc 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -140,6 +140,7 @@ function site_sitemap_urls(PDO $pdo): array { if (!empty($g['has_crossfit'])) $urls[] = ['loc' => gym_url($g, 'crossfit'), 'priority' => '0.8']; } $urls[] = ['loc' => base_url('home/privacidade'), 'priority' => '0.2']; + $urls[] = ['loc' => base_url('home/termos'), 'priority' => '0.1']; foreach (function_exists('db_events_active') ? db_events_active($pdo) : [] as $ev) { $urls[] = ['loc' => base_url('evento/' . rawurlencode($ev['slug'])), 'priority' => '0.6']; } diff --git a/config/functions_trash.php b/config/functions_trash.php index bd32875..e8ed8a4 100644 --- a/config/functions_trash.php +++ b/config/functions_trash.php @@ -216,6 +216,35 @@ function _trash_audit(PDO $pdo, string $action, string $type, int $id, string $s ]); } +// ─── Retenção (política de privacidade, secção 3) ──────────────────────────── +// Leads com mais de 12 meses vão para a lixeira (deleted_by NULL = sistema); a +// eliminação definitiva continua a exigir os 30 dias e um clique. Submissões +// recusadas pelo anti-spam e o outbox de segurança: apagados aos 90 dias. +// Chamado uma vez por dia pelo cron.php. +function trash_retention_sweep(PDO $pdo): array { + $done = []; + foreach (['trial_lead' => 'trial_leads', 'membership_lead' => 'membership_leads', 'event_lead' => 'event_leads'] as $type => $table) { + try { + $n = $pdo->exec("UPDATE `{$table}` SET deleted_at = NOW(), deleted_by = NULL + WHERE deleted_at IS NULL AND created_at < DATE_SUB(NOW(), INTERVAL 12 MONTH)"); + if ($n) { + $done[$type] = (int) $n; + if (function_exists('platform_audit')) { + platform_audit($pdo, 'trash.retention', ['target_type' => $type, 'actor' => false, + 'summary' => $n . ' registo(s) com mais de 12 meses enviados para a lixeira']); + } + } + } catch (Throwable $e) { error_log('[retention] ' . $table . ': ' . $e->getMessage()); } + } + foreach (['blocked_submissions' => 'created_at', 'hub_security_outbox' => 'occurred_at'] as $table => $col) { + try { + $n = $pdo->exec("DELETE FROM `{$table}` WHERE `{$col}` < DATE_SUB(NOW(), INTERVAL 90 DAY)"); + if ($n) $done[$table] = (int) $n; + } catch (Throwable $e) { /* tabela ainda não existe */ } + } + return $done; +} + // ─── 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 diff --git a/cron.php b/cron.php index 4d779e8..dc7743f 100644 --- a/cron.php +++ b/cron.php @@ -94,6 +94,8 @@ if ((int) date('G') === 4 && (int) date('i') < 5) { $soft = db_soft_delete_sent_older_than($pdo, 90); $hard = db_purge_deleted_messages($pdo, 90); if ($soft || $hard) cron_log("retenção: {$soft} arquivada(s), {$hard} eliminada(s)"); + // Leads > 12 meses → lixeira; registos de segurança > 90 dias. + foreach (trash_retention_sweep($pdo) as $what => $n) cron_log("retenção: {$n} em {$what}"); } catch (Throwable $e) { cron_log('ERRO na retenção: ' . $e->getMessage()); } diff --git a/includes/auth_header.php b/includes/auth_header.php index cc7bf18..69deafe 100644 --- a/includes/auth_header.php +++ b/includes/auth_header.php @@ -8,12 +8,26 @@ $page_title = $page_title ?? 'Entrar'; $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; ?> - + <?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?> + + + + diff --git a/includes/footer.php b/includes/footer.php index 376e935..683dc9e 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -34,6 +34,39 @@ document.addEventListener('DOMContentLoaded', function () { }); }); +// Tema: claro / escuro / automático (o já aplicou o guardado). +(function () { + var root = document.documentElement, btn = document.getElementById('themeToggle'); + var mq = window.matchMedia('(prefers-color-scheme: dark)'); + var icons = { light: 'bi-sun', dark: 'bi-moon-stars', auto: 'bi-circle-half' }; + var names = { light: 'claro', dark: 'escuro', auto: 'automático' }; + function apply(pref) { + var dark = pref === 'dark' || (pref === 'auto' && mq.matches); + root.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); + root.setAttribute('data-theme-pref', pref); + if (btn) { + btn.querySelector('i').className = 'bi ' + icons[pref]; + btn.setAttribute('aria-label', 'Tema: ' + names[pref]); + } + document.querySelectorAll('[data-theme-set]').forEach(function (b) { + var on = b.getAttribute('data-theme-set') === pref; + b.classList.toggle('active', on); + b.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + } + var pref = root.getAttribute('data-theme-pref') || 'auto'; + apply(pref); + document.querySelectorAll('[data-theme-set]').forEach(function (b) { + b.addEventListener('click', function () { + pref = b.getAttribute('data-theme-set'); + try { localStorage.setItem('bo-theme', pref); } catch (e) {} + apply(pref); + }); + }); + // O sistema mudou de tema: só conta em «automático». + mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); }); +})(); + // Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop; // tapping the backdrop, a nav link, Escape, or resizing to desktop closes it. (function () { diff --git a/includes/header.php b/includes/header.php index 2d0dd44..1558925 100644 --- a/includes/header.php +++ b/includes/header.php @@ -6,11 +6,25 @@ $page_title = $page_title ?? 'Painel'; $user = $user ?? current_user() ?? ['name' => 'Visitante', 'role' => 'client']; $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; ?> - + <?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?> + + + + @@ -22,6 +36,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; --bo-dark-2: #151515; --bo-surface: #eef0f4; } + [data-bs-theme="dark"] { --bo-surface: #121416; } body { background: var(--bo-surface); font-size: .925rem; } .sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100; padding: 68px 0 0; width: 240px; background: var(--bo-dark); overflow-y: auto; } @@ -38,7 +53,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; } main { margin-left: 240px; padding-top: 68px; } .topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px; - background: #fff; border-bottom: 1px solid #e2e6ec; padding: 0 1.5rem; + background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; } .topbar .user-dropdown { display: flex; align-items: center; gap: .5rem; margin-left: auto; } .badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); } @@ -50,17 +65,19 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; .btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); } .btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); } .card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); } - a { color: #0a0a0a; } + [data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); } + .theme-toggle { min-width: 44px; min-height: 44px; } + a { color: var(--bs-emphasis-color); } /* Summernote (WYSIWYG) — align with the backoffice look */ .note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; } .note-editor .note-editing-area .note-editable { font-size: .95rem; } .note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); } - .note-toolbar { background: #f8f9fa; } + .note-toolbar { background: var(--bs-tertiary-bg); } .note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); } /* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */ .sidebar { transition: transform .25s ease; } - .menu-toggle { display: none; width: 40px; height: 40px; padding: 0; border: 1px solid #e2e6ec; - background: #fff; color: var(--bo-dark); border-radius: 8px; font-size: 1.35rem; + .menu-toggle { display: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--bs-border-color); + background: var(--bs-body-bg); color: var(--bs-body-color); border-radius: 8px; font-size: 1.35rem; line-height: 1; align-items: center; justify-content: center; } .sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); } .sidebar-backdrop.show { display: block; } @@ -228,6 +245,17 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
+ diff --git a/site/includes/footer.php b/site/includes/footer.php index 3f0eb2d..4676ed2 100644 --- a/site/includes/footer.php +++ b/site/includes/footer.php @@ -26,6 +26,8 @@
- +
\ No newline at end of file diff --git a/site/index.php b/site/index.php index bb399f1..0ab0db2 100644 --- a/site/index.php +++ b/site/index.php @@ -540,7 +540,8 @@ if ($reviews):
- + +
@@ -641,7 +642,7 @@ $session = session(); if (strpos($maps_embed, 'loading=') === false) { $maps_embed = str_replace('