feat(rgpd): política de privacidade e termos, mapa a pedido, fotos locais, retenção, tema claro/escuro
- 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 <head> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
04c8c83e66
commit
b202262da0
16 files changed
+297
-34
No files matched your search
@@ -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 ───────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
// ─── Terceiros só depois de o visitante pedir ───────────────────────────────
|
||||
// RGPD: nada da Google carrega sem o visitante o pedir. O iframe do Google Maps
|
||||
// punha cookies e enviava o IP de cada visita; as fotos dos autores das
|
||||
// reviews vinham de lh3.googleusercontent.com. Assim:
|
||||
// - map_click_to_load(): o mapa fica num <template> 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 !== ''
|
||||
? '<a href="' . $esc($mapsLink) . '" target="_blank" rel="noopener" class="map-consent-link">Abrir no Google Maps</a>'
|
||||
: '';
|
||||
return '<div class="map-consent" id="' . $id . '" style="position:absolute;inset:0;display:flex;flex-direction:column;'
|
||||
. 'align-items:center;justify-content:center;gap:.6rem;padding:1rem;text-align:center;color:#f5f5f5;">'
|
||||
. '<i class="fas fa-map-marker-alt" aria-hidden="true" style="font-size:1.8rem;color:#C2D500"></i>'
|
||||
. '<button type="button" class="site-btn" style="min-height:44px" '
|
||||
. 'onclick="var w=document.getElementById(\'' . $id . '\');var t=w.nextElementSibling;'
|
||||
. 'w.insertAdjacentHTML(\'afterend\',t.innerHTML);w.remove();">Mostrar mapa</button>'
|
||||
. '<small style="max-width:32em;opacity:.8">O mapa é do Google Maps: ao abri-lo, a Google recebe o seu endereço IP e pode guardar cookies.</small>'
|
||||
. $link
|
||||
. '</div><template>' . $iframeHtml . '</template>';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user