mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
181 lines
6.2 KiB
PHP
181 lines
6.2 KiB
PHP
<?php
|
|
/**
|
|
* Thin application helper — centralises bootstrap, auth gating, CSRF lifecycle,
|
|
* flash messages, redirect, and template rendering.
|
|
*
|
|
* Eliminates the ~6-8 lines of identical preamble repeated across every page
|
|
* and action handler. See REFACTORING_RECOMMENDATIONS.md §1 + §3.
|
|
*/
|
|
class App
|
|
{
|
|
private static bool $booted = false;
|
|
|
|
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Boot once per request: load Database, ensure CSRF token exists.
|
|
* Suitable for public pages (no auth required).
|
|
*/
|
|
public static function boot(): Database
|
|
{
|
|
if (!self::$booted) {
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
self::$booted = true;
|
|
}
|
|
self::ensureCsrf();
|
|
return Database::getInstance();
|
|
}
|
|
|
|
/**
|
|
* Gate for admin pages: require auth + CSRF token + load Database.
|
|
*/
|
|
public static function adminGuard(): Database
|
|
{
|
|
require_once APP_ROOT . '/src/AdminAuth.php';
|
|
AdminAuth::requireLogin();
|
|
return self::boot();
|
|
}
|
|
|
|
// ── CSRF lifecycle ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Ensure a CSRF token exists in the session.
|
|
* Called automatically by boot() / adminGuard().
|
|
*/
|
|
private static function ensureCsrf(): void
|
|
{
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate the CSRF token on a POST request.
|
|
* Halts with 403 if the token is missing or invalid.
|
|
*/
|
|
public static function verifyCsrf(): void
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
|
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
|
http_response_code(403);
|
|
exit('CSRF token invalide.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Regenerate the CSRF token after a successful mutation.
|
|
*/
|
|
public static function rotateCsrf(): void
|
|
{
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// ── Flash messages ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Store a flash message in the session.
|
|
*
|
|
* @param 'success'|'error' $type
|
|
*/
|
|
public static function flash(string $type, string $message): void
|
|
{
|
|
$_SESSION["_flash_{$type}"] = $message;
|
|
}
|
|
|
|
/**
|
|
* Store the name of the field that should receive autofocus after a
|
|
* validation failure (WCAG 3.3.1).
|
|
*/
|
|
public static function flashAutofocus(string $fieldName): void
|
|
{
|
|
$_SESSION['_flash_autofocus'] = $fieldName;
|
|
}
|
|
|
|
/**
|
|
* Consume and return the autofocus field name, then clear it.
|
|
* Returns null when no autofocus hint is present.
|
|
*/
|
|
public static function consumeAutofocus(): ?string
|
|
{
|
|
$field = $_SESSION['_flash_autofocus'] ?? null;
|
|
unset($_SESSION['_flash_autofocus']);
|
|
return $field;
|
|
}
|
|
|
|
/**
|
|
* Consume and return flash messages, then clear them from the session.
|
|
*
|
|
* @return array{error: ?string, success: ?string}
|
|
*/
|
|
public static function consumeFlash(): array
|
|
{
|
|
$error = $_SESSION['_flash_error'] ?? null;
|
|
$success = $_SESSION['_flash_success'] ?? null;
|
|
|
|
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']);
|
|
|
|
// Note: autofocus is consumed separately via consumeAutofocus().
|
|
return ['error' => $error, 'success' => $success];
|
|
}
|
|
|
|
// ── Redirect ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Flash a message and redirect. Terminates the script.
|
|
*/
|
|
public static function redirect(string $url, ?string $success = null, ?string $error = null): never
|
|
{
|
|
if ($success !== null) {
|
|
self::flash('success', $success);
|
|
}
|
|
if ($error !== null) {
|
|
self::flash('error', $error);
|
|
}
|
|
header('Location: ' . $url);
|
|
exit;
|
|
}
|
|
|
|
// ── Asset versioning ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return an asset URL with a filemtime-based cache-busting query string.
|
|
* Input is a root-relative URL path (e.g. /assets/css/main.css).
|
|
*/
|
|
public static function assetV(string $urlPath): string
|
|
{
|
|
$file = APP_ROOT . '/public' . $urlPath;
|
|
$v = file_exists($file) ? filemtime($file) : 0;
|
|
return $urlPath . ($v ? '?v=' . $v : '');
|
|
}
|
|
|
|
// ── Template rendering ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Render a full page: head → header → content template → footer.
|
|
*
|
|
* Expects $vars to contain the same keys the templates already rely on
|
|
* ($pageTitle, $bodyClass, $isAdmin, $extraCss, $ogTags, etc.).
|
|
* The footer variant (public vs admin) is chosen automatically based
|
|
* on $isAdmin.
|
|
*
|
|
* @param string $template Path relative to APP_ROOT/templates/
|
|
* @param array $vars Variables to expose inside the templates
|
|
*/
|
|
public static function render(string $template, array $vars = []): void
|
|
{
|
|
// Make all vars available in the template scope.
|
|
extract($vars);
|
|
|
|
include APP_ROOT . '/templates/head.php';
|
|
include APP_ROOT . '/templates/header.php';
|
|
include APP_ROOT . '/templates/' . $template;
|
|
|
|
if (!empty($isAdmin)) {
|
|
include APP_ROOT . '/templates/admin/footer.php';
|
|
} else {
|
|
include APP_ROOT . '/templates/footer.php';
|
|
}
|
|
}
|
|
}
|