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:
andreandClaude Opus 5.5 committed 2026-09-25 11:39:05 +01:00
1 parent 04c8c83e66
commit b202262da0
16 files changed
+297 -34

No files matched your search

+61
View File
@@ -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);
}