Files
fitme3070.pt/config/functions.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

99 lines
6.4 KiB
PHP

<?php
// ─── Bootstrap chain ─────────────────────────────────────────────────────────
// Loaded by every page right after config/db.php (which opens $pdo and starts
// the FIT_SESS session). Mirrors the fotografia/Andmore fleet shape, trimmed to
// what fitme3070 needs this pass: settings, generic CRUD, local auth, the
// migrations runner, and the centralized notify façade. Domain CRUD
// (plataforma_fitness, team, modalidades, horário, galerias) is added per
// feature in a follow-up under config/functions_*.php.
if (session_status() === PHP_SESSION_NONE) session_start();
require_once __DIR__ . '/config.php'; // APP_ENV + hub notify URL + error_handler
require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
// Outbound delivery is centralized at id.andmore.pt — no local PHPMailer/SMTP.
require_once __DIR__ . '/functions_notify_client.php'; // notify_remote_send()
require_once __DIR__ . '/functions_queue.php'; // enqueue_*/flush_message façade → relay
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
require_once __DIR__ . '/functions_images.php'; // upload resize/compress (gallery/team/modalidades caps)
require_once __DIR__ . '/functions_team.php'; // team CRUD
require_once __DIR__ . '/functions_modalidades.php'; // modalidades CRUD
require_once __DIR__ . '/functions_galerias.php'; // galleries + images CRUD
require_once __DIR__ . '/functions_work_hours.php'; // work hours CRUD
require_once __DIR__ . '/functions_horario.php'; // class schedule CRUD
require_once __DIR__ . '/functions_events.php'; // time-limited events + landing pages + leads
// ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants.
define('APP_NAME', (string) setting('app_name', 'FitMe 3070'));
// APP_BASE_URL is per-install (deployment topology). .env only — never the DB,
// so a reused dump can't drag another install's URL in.
// Auto-detect from the request if .env doesn't have it (dev/staging safety net).
$_aburl = env('APP_BASE_URL', '');
if ($_aburl === '' && isset($_SERVER['HTTP_HOST'])) {
$scriptDir = dirname($_SERVER['SCRIPT_NAME'] ?? '');
$_aburl = (request_is_https() ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . rtrim($scriptDir, '/') . '/';
}
define('APP_BASE_URL', $_aburl);
define('EMAIL_FROM', (string) setting('email_from', 'noreply@andmore.pt'));
define('EMAIL_FROM_NAME', (string) setting('email_from_name', 'FitMe 3070'));
define('EMAIL_SUBJECT_PREFIX', (string) setting('email_subject_prefix', '[FitMe 3070] '));
// ─── URL helper ───────────────────────────────────────────────────────────────
// Always use for Location: redirects and internal links.
function base_url(string $path = ''): string {
return APP_BASE_URL . ltrim($path, '/');
}
// ─── Date helper (PT-PT) ──────────────────────────────────────────────────────
function format_date_pt(string $date): string {
if (!$date) return '';
return (new DateTime($date))->format('d/m/Y');
}
// ─── Email template loader ────────────────────────────────────────────────────
// {{placeholder}} HTML bodies under templates/emails/. Built locally, delivered
// by id.andmore.pt via the enqueue_email()/flush_message() façade.
function load_email_template(string $template, array $vars): string {
$path = __DIR__ . '/../templates/emails/' . $template . '.html';
if (!is_file($path)) {
throw new RuntimeException("Email template not found: {$template}");
}
$html = file_get_contents($path);
$brand = htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8');
$logo = (string) setting('logo_light_filename', '');
$logoTag = $logo !== ''
? '<img src="' . htmlspecialchars(base_url('assets/img/logo/' . rawurlencode($logo)), ENT_QUOTES, 'UTF-8')
. '" alt="' . $brand . '" style="max-height:60px;border:0;">'
: '<p style="margin:0;color:#fff;font-size:22px;font-weight:bold;letter-spacing:1px;">' . $brand . '</p>';
$html = str_replace('{{logo_html}}', $logoTag, $html);
foreach ($vars as $key => $value) {
$html = str_replace('{{' . $key . '}}', htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
}
return $html;
}
// ─── User-agent prettifier (last-login display) ──────────────────────────────
// Mirrors fotografia's helper so views/user/index.php can show a short label.
function parse_user_agent(string $ua): string {
if ($ua === '') return '—';
$browser = 'Navegador';
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Chrome' => 'Chrome', 'Firefox' => 'Firefox', 'Safari' => 'Safari'] as $needle => $label) {
if (stripos($ua, $needle) !== false) { $browser = $label; break; }
}
$os = 'SO';
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $label) {
if (stripos($ua, $needle) !== false) { $os = $label; break; }
}
return "$browser / $os";
}