Files
fitme3070.pt/site/evento.php
T
andreandClaude Opus 5 aec7b47820 Replace Andmore ID SSO with local login
fitme3070.pt delegated all authentication to hub.andmore.pt: views/auth/*
were bounce stubs, and because this domain is outside the .andmore.pt cookie
scope the real handoff was a one-time HTTPS ticket redeem. Bring identity back
in-house — passwords live in users.password_hash, which the SSO migration
never dropped.

Removed: config/sso.php, IDENTITY_MODE, every SSO_* constant and env key,
identity_is_federated() and the cookie/ticket paths in the auth views and the
public-site bootstrap. ANDMORE_ID_URL stays — it is still the hub /notify
endpoint for outbound email/SMS, which the reset mails ride on, and has
nothing to do with authentication now.

Added, ported from the fotografia.andmore.pt sibling rather than invented:

  - auth_login() with transparent password_needs_rehash upgrades and the
    last_login trail; auth_register_client(); auth_set_password(); the
    single-use 2h reset tokens.
  - Real login / register / forgot_password / reset_password pages on a new
    anonymous shell (includes/auth_header.php). includes/header.php renders
    the backoffice sidebar and a logout button, which is wrong for a visitor
    who is not signed in yet.
  - safe_return_path(), so a crafted ?return_to= cannot bounce a freshly
    authenticated user off-site.
  - Per-IP throttling on login, register and reset-request. Successes count
    too, so a guessed password cannot be parlayed into stuffing the rest of
    the user list from the same IP.
  - views/user/set_password.php: admin override for anyone whose email is
    undeliverable. Prefers mailing an invite link so the backoffice never
    handles the password. A plain admin cannot act on a sysadmin, and nobody
    can use it on themselves — self-service goes through profile.php, which
    demands the current password so a hijacked session cannot lock the owner
    out.

Accounts provisioned during the SSO era have password_hash NULL. They get in
via the reset email, or by registering again with the same address:
auth_register_client() claims a passwordless row, but only a plain `client`
one, so knowing a staff address does not let anyone self-assign the role.

users.andmore_id is deliberately left in place. No code reads it any more, but
it records which central identity each row was linked to.

Deployment: apply database/migration_20260913_local_auth.sql (password_resets
+ auth_attempts) in the SAME sysadmin session that pulls this commit — the
forgot-password flow needs that table, and after the pull a login requires a
local password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:00:57 +01:00

234 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,
// First hop of X-Forwarded-For (behind the id.andmore.pt proxy), else peer.
'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
'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');