Files
fitme3070.pt/site/evento.php
T
andreandClaude Opus 5.5 895b3d58d7 fix(segurança): lixeira de 30 dias, CSRF em todas as ações, IP real, sessão e uploads
Correções da auditoria de 24/09 (audits/fitme3070-auditoria-2026-09-24.html).

Lixeira
- Tudo o que é importante passa a soft delete (deleted_at + deleted_by):
  equipa, modalidades, eventos, mensalidades, galerias, imagens, horário,
  horas de funcionamento, inscrições em eventos e pedidos de inscrição.
- config/functions_trash.php: um registo por tipo; apagar, restaurar, listar
  e eliminar leem todos dele. Eliminar de vez só com 30 dias na lixeira —
  a condição está no WHERE do DELETE, um POST forjado não apaga nada.
- Gestão → Lixeira (views/admin/trash/index.php): lista única com quem e
  quando, restauro a qualquer momento.
- Imagens de galeria: o site lista a pasta, por isso o ficheiro passa para
  .trash/ (negado pelo .htaccess) e volta no restauro.
- As listagens e o site público filtram deleted_at (horário também filtra
  modalidades apagadas). Migração: database/migration_20260925_trash.sql
  (só acrescenta colunas; tem de ser aplicada antes deste código).

CSRF
- csrf_check() recusa qualquer pedido que não seja POST (405). Antes saía
  sem verificar nada em GET, e dez ações de apagar/restaurar eram GET.
- Os dez handlers passaram a POST com token; links viraram formulários.

Autenticação e sessão
- client_ip(): X-Real-IP / último X-Forwarded-For, só vindo de proxy privado
  com TRUSTED_PROXY. O throttle contava o IP do proxy (limite global).
- Throttle também por conta (hash do email), além do IP.
- Sem oráculo de tempo: login verifica contra um hash de referência quando
  a conta não existe; a recuperação deixa o email na fila.
- Cookie de sessão Secure/HttpOnly/SameSite=Lax e use_strict_mode.
- Definições de sistema só para sysadmin; autocomplete=username.

Servidor e uploads
- .htaccess: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy,
  CSP (frame-ancestors, base-uri, object-src, form-action); nega parciais
  _*.php, PHP soltos do platform-tools e *.md; 404 própria.
- Uploads validados pelo conteúdo (finfo/getimagesize); SVG só sem scripts,
  handlers on*=, foreignObject ou hrefs externos.

Testado numa cópia da BD de produção (fitme_audit_test) com um contentor
temporário: 34/34 verificações HTTP ponta-a-ponta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:16:10 +01:00

235 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ── Event landing page ──────────────────────────────────────────────────────
// Branded, time-limited landing page reached at /evento/<slug> (see .htaccess).
// Visible to the public only while the event is live (db_events_active rule);
// logged-in staff may preview drafts/scheduled/expired events. Carries an
// optional lead-capture form that stores submissions in event_leads.
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
// Resolve any SSO session up front (mirrors _open.php) so the is_staff() preview
// gate below sees a logged-in admin. _open.php's later call then no-ops.
$slug = preg_replace('/[^a-z0-9\-]/', '', strtolower((string) ($_GET['slug'] ?? '')));
$event = $slug !== '' ? db_events_get_by_slug($pdo, $slug) : null;
// Gate: live for everyone, otherwise only staff (preview).
$can_view = $event && (events_is_live($event) || is_staff());
// ── Lead form submission (PRG) ──────────────────────────────────────────────
$lead_errors = [];
if ($can_view && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$back = base_url('evento/' . rawurlencode($event['slug']));
// Honeypot: a filled "website" field means a bot — silently accept & drop.
if (trim((string) ($_POST['website'] ?? '')) !== '') {
header('Location: ' . $back . '?ok=1#inscricao');
exit;
}
// The exact wording the visitor consents to — stored for GDPR evidence.
$consent_text = 'Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da Política de Privacidade.';
$lead = [
'name' => trim((string) ($_POST['lead_name'] ?? '')),
'email' => trim((string) ($_POST['lead_email'] ?? '')),
'phone' => trim((string) ($_POST['lead_phone'] ?? '')),
'message' => trim((string) ($_POST['lead_message'] ?? '')),
'consent' => !empty($_POST['lead_consent']) ? 1 : 0,
'consent_text' => $consent_text,
// IP real atrás do proxy (config/env.php) — o primeiro valor do
// X-Forwarded-For é escrito pelo cliente.
'ip' => client_ip(),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
];
if ($lead['name'] === '') $lead_errors[] = 'Indica o teu nome.';
if ($lead['email'] === '' && $lead['phone'] === '') $lead_errors[] = 'Indica um email ou um telefone para te podermos contactar.';
if ($lead['email'] !== '' && !filter_var($lead['email'], FILTER_VALIDATE_EMAIL)) $lead_errors[] = 'O email indicado não é válido.';
if (!$lead['consent']) $lead_errors[] = 'É necessário aceitar o tratamento dos dados para enviar a inscrição.';
if (!$lead_errors) {
db_event_lead_create($pdo, (int) $event['e_id'], $lead);
header('Location: ' . $back . '?ok=1#inscricao');
exit;
}
// fall through and re-render with $lead_errors + the submitted $lead values
$lead_old = $lead;
}
// ── Not found / not visible ─────────────────────────────────────────────────
if (!$can_view) {
http_response_code(404);
$current_uri_2 = '';
require __DIR__ . '/_open.php';
?>
<section class="event-notfound" style="padding:160px 0 90px;text-align:center;">
<div class="container">
<h1 style="font-size:2.4rem;">Evento não encontrado</h1>
<p class="text-muted">Este evento pode já ter terminado ou o link estar incorreto.</p>
<a href="<?= base_url('/') ?>" class="site-btn mt-3">Voltar ao início</a>
</div>
</section>
<?php
echo view('footer');
return;
}
// ── Visible: render the landing page ────────────────────────────────────────
$is_preview = !events_is_live($event);
$hero_url = $event['hero_image'] !== '' ? base_url('uploads/events/' . rawurlencode($event['hero_image'])) : '';
$accent = $event['cor'] !== '' ? $event['cor'] : '#C2D500';
// Human date line for the event window.
$date_line = '';
if (!empty($event['event_starts_at'])) {
$date_line = format_date_pt((string) $event['event_starts_at']);
if (!empty($event['event_ends_at'])
&& substr((string) $event['event_ends_at'], 0, 10) !== substr((string) $event['event_starts_at'], 0, 10)) {
$date_line .= ' – ' . format_date_pt((string) $event['event_ends_at']);
}
}
// CTA target: explicit URL, else the on-page form (when shown), else contacts.
$cta_label = trim((string) $event['cta_label']);
$cta_href = trim((string) $event['cta_url']);
if ($cta_href === '') {
$cta_href = ((int) $event['show_lead_form'] === 1) ? '#inscricao' : base_url('home/contactos');
}
// ── SEO ─────────────────────────────────────────────────────────────────────
// include.php / seo.php honour these globals when set before _open.php runs.
$event_url = base_url('evento/' . rawurlencode($event['slug']));
$seo_desc = $event['summary'] !== ''
? $event['summary']
: trim(mb_substr(strip_tags((string) ($event['subtitle'] ?: $event['description'])), 0, 200));
$current_uri_2 = 'evento';
$GLOBALS['_page_meta_override'] = [
($event['title'] !== '' ? $event['title'] : 'Evento') . ' | FitMe 3070',
$seo_desc,
];
$GLOBALS['_canonical_override'] = $event_url;
$GLOBALS['_og_type_override'] = 'article';
if ($hero_url !== '') $GLOBALS['_og_image_override'] = $hero_url;
// Event structured data (schema.org/Event) for rich link previews / search.
$info = site_info($pdo);
$ld_event = [
'@context' => 'https://schema.org',
'@type' => 'Event',
'name' => $event['title'],
'description' => $seo_desc !== '' ? $seo_desc : $event['title'],
'url' => $event_url,
'eventStatus' => 'https://schema.org/EventScheduled',
'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
];
if ($hero_url !== '') $ld_event['image'] = [$hero_url];
if (!empty($event['event_starts_at'])) $ld_event['startDate'] = str_replace(' ', 'T', (string) $event['event_starts_at']);
if (!empty($event['event_ends_at'])) $ld_event['endDate'] = str_replace(' ', 'T', (string) $event['event_ends_at']);
$ld_event['location'] = $event['location'] !== ''
? ['@type' => 'Place', 'name' => $event['location']]
: ['@type' => 'Place',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
'address' => $info['adress'] ?? ''];
$ld_event['organizer'] = [
'@type' => 'Organization',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
'url' => base_url(),
];
$GLOBALS['_jsonld_extra'] = $ld_event;
require __DIR__ . '/_open.php';
?>
<?php if ($is_preview): ?>
<div class="event-preview-bar">Pré-visualização — este evento não está visível ao público (<?= htmlspecialchars(events_status($event)) ?>).</div>
<?php endif; ?>
<section class="event-hero set-bg<?= $hero_url ? '' : ' event-hero--plain' ?>"
<?= $hero_url ? 'style="background-image:url(\'' . htmlspecialchars($hero_url, ENT_QUOTES) . '\')"' : '' ?>>
<div class="event-hero-overlay"></div>
<div class="container">
<div class="event-hero-inner">
<span class="event-hero-eyebrow" style="color:<?= htmlspecialchars($accent, ENT_QUOTES) ?>">Evento</span>
<h1><?= htmlspecialchars($event['title']) ?></h1>
<?php if ($event['subtitle'] !== ''): ?><p class="event-hero-sub"><?= htmlspecialchars($event['subtitle']) ?></p><?php endif; ?>
<ul class="event-hero-meta">
<?php if ($date_line !== ''): ?><li><i class="fas fa-calendar-alt"></i> <?= htmlspecialchars($date_line) ?></li><?php endif; ?>
<?php if ($event['location'] !== ''): ?><li><i class="fas fa-map-marker-alt"></i> <?= htmlspecialchars($event['location']) ?></li><?php endif; ?>
</ul>
<?php if ($cta_label !== ''): ?>
<a href="<?= htmlspecialchars($cta_href, ENT_QUOTES) ?>" class="site-btn event-hero-cta"
style="background:<?= htmlspecialchars($accent, ENT_QUOTES) ?>"
<?= ($cta_href[0] ?? '') === '#' ? 'data-scroll' : '' ?>><?= htmlspecialchars($cta_label) ?></a>
<?php endif; ?>
</div>
</div>
</section>
<?php if (trim((string) $event['description']) !== ''): ?>
<section class="event-body">
<div class="container">
<div class="row justify-content-md-center">
<div class="col-lg-8">
<?php /* description is admin-authored HTML — sanitised allowlist HTML */ ?>
<?= clean_richtext($event['description']) ?>
</div>
</div>
</div>
</section>
<?php endif; ?>
<?php if ((int) $event['show_lead_form'] === 1): ?>
<section class="event-leadform" id="inscricao">
<div class="container">
<div class="row justify-content-md-center">
<div class="col-lg-7">
<div class="section-title text-center deco">
<span class="section-eyebrow">Participa</span>
<h2>Inscreve-te</h2>
<p class="section-subtitle">Deixa os teus dados e entramos em contacto.</p>
</div>
<?php if (isset($_GET['ok'])): ?>
<div class="event-alert event-alert--ok">Inscrição enviada com sucesso. Obrigado!</div>
<?php endif; ?>
<?php foreach ($lead_errors as $e): ?>
<div class="event-alert event-alert--err"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<form method="POST" action="<?= base_url('evento/' . rawurlencode($event['slug'])) ?>#inscricao" class="event-form">
<?= csrf_field() ?>
<!-- honeypot: must stay empty -->
<input type="text" name="website" tabindex="-1" autocomplete="off" class="event-hp" aria-hidden="true">
<div class="event-form-grid">
<div class="event-field">
<label>Nome *</label>
<input type="text" name="lead_name" required maxlength="150" value="<?= htmlspecialchars($lead_old['name'] ?? '') ?>">
</div>
<div class="event-field">
<label>Telefone</label>
<input type="tel" name="lead_phone" maxlength="40" value="<?= htmlspecialchars($lead_old['phone'] ?? '') ?>">
</div>
<div class="event-field event-field--full">
<label>Email</label>
<input type="email" name="lead_email" maxlength="190" value="<?= htmlspecialchars($lead_old['email'] ?? '') ?>">
</div>
<div class="event-field event-field--full">
<label>Mensagem</label>
<textarea name="lead_message" rows="3" maxlength="1000"><?= htmlspecialchars($lead_old['message'] ?? '') ?></textarea>
</div>
</div>
<label class="event-consent">
<input type="checkbox" name="lead_consent" value="1" required <?= !empty($lead_old['consent']) ? 'checked' : '' ?>>
<span>Autorizo o contacto e o tratamento dos meus dados para esta inscrição, nos termos da <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span>
</label>
<div class="text-center mt-3">
<button type="submit" class="site-btn" style="background:<?= htmlspecialchars($accent, ENT_QUOTES) ?>">Enviar inscrição</button>
</div>
</form>
</div>
</div>
</div>
</section>
<?php endif; ?>
<?php
echo view('footer');