mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
feat: extract MediaController, wire into Dispatcher, delete media.php
This commit is contained in:
170
app/src/AdminAuth.php
Normal file
170
app/src/AdminAuth.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal PHP session guard for the admin panel.
|
||||
*
|
||||
* This is a defence-in-depth layer that sits behind nginx Basic Auth.
|
||||
* It protects against proxy misconfiguration, bypass, and local-dev
|
||||
* scenarios where the reverse proxy may be absent.
|
||||
*
|
||||
* The admin password hash is stored in the site_settings table
|
||||
* (key = 'admin_password_hash').
|
||||
*
|
||||
* If the hash is empty/missing the guard is a no-op (dev / cli-server).
|
||||
*/
|
||||
class AdminAuth
|
||||
{
|
||||
private const SESSION_KEY = 'admin_authenticated';
|
||||
private const LOGIN_URL = '/admin/login.php';
|
||||
|
||||
/**
|
||||
* Start the PHP session with hardened cookie parameters.
|
||||
* Idempotent — safe to call even if session is already active.
|
||||
*/
|
||||
private static function startSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_NONE) {
|
||||
return;
|
||||
}
|
||||
// Harden session cookie (item #8)
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/admin',
|
||||
'secure' => (php_sapi_name() !== 'cli-server'),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the admin password hash from site_settings.
|
||||
* Returns null if not set (dev mode).
|
||||
*/
|
||||
private static function getStoredHash(): ?string
|
||||
{
|
||||
// Legacy fallback: if the old constant is still defined, honour it.
|
||||
if (defined('ADMIN_PASSWORD_HASH') && ADMIN_PASSWORD_HASH !== '') {
|
||||
return ADMIN_PASSWORD_HASH;
|
||||
}
|
||||
|
||||
// Lazy-load minimal DB just for this lookup.
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
$db = new Database();
|
||||
$hash = $db->getSetting('admin_password_hash');
|
||||
return $hash !== '' ? $hash : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate every admin page.
|
||||
*
|
||||
* Authentication order:
|
||||
* 1. No password hash configured → dev mode, pass through.
|
||||
* 2. Session already authenticated → pass through.
|
||||
* 3. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW']
|
||||
* → validate it with password_verify; on success create session
|
||||
* (seamless: user only sees the browser Basic Auth dialog).
|
||||
* 4. Neither → redirect to the PHP login form.
|
||||
*/
|
||||
public static function requireLogin(): void
|
||||
{
|
||||
self::startSession();
|
||||
$storedHash = self::getStoredHash();
|
||||
if ($storedHash === null) {
|
||||
return; // No password configured → dev / cli-server, skip.
|
||||
}
|
||||
if (!empty($_SESSION[self::SESSION_KEY])) {
|
||||
return; // Already authenticated via session.
|
||||
}
|
||||
// Try to auto-authenticate from the nginx Basic Auth credentials.
|
||||
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
|
||||
return;
|
||||
}
|
||||
header('Location: ' . self::LOGIN_URL);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a plaintext password against the stored hash.
|
||||
* On success: regenerates the session ID and marks the session authenticated.
|
||||
*
|
||||
* @return bool true on success, false on wrong password / no hash stored.
|
||||
*/
|
||||
public static function login(string $password): bool
|
||||
{
|
||||
$storedHash = self::getStoredHash();
|
||||
if ($storedHash === null || !self::verifyHash($password, $storedHash)) {
|
||||
return false;
|
||||
}
|
||||
self::startSession();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::SESSION_KEY] = true;
|
||||
$_SESSION['admin_login_at'] = time();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bcrypt verification wrapper.
|
||||
*/
|
||||
private static function verifyHash(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored admin password hash in the database.
|
||||
*/
|
||||
public static function setPasswordHash(string $newHash): void
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
$db = new Database();
|
||||
$db->setSetting('admin_password_hash', $newHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the stored admin password hash (revert to dev mode).
|
||||
*/
|
||||
public static function removePasswordHash(): void
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
$db = new Database();
|
||||
$db->setSetting('admin_password_hash', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current request is authenticated (without redirecting).
|
||||
*/
|
||||
public static function isAuthenticated(): bool
|
||||
{
|
||||
self::startSession();
|
||||
$storedHash = self::getStoredHash();
|
||||
if ($storedHash === null) {
|
||||
return true; // No password configured → dev mode.
|
||||
}
|
||||
return !empty($_SESSION[self::SESSION_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a password hash is configured in the system.
|
||||
*/
|
||||
public static function hasPassword(): bool
|
||||
{
|
||||
return self::getStoredHash() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the session (logout).
|
||||
*/
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(), '', time() - 86400,
|
||||
$p['path'], $p['domain'], $p['secure'], $p['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
180
app/src/App.php
Normal file
180
app/src/App.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/src/Controllers/AboutController.php
Normal file
43
app/src/Controllers/AboutController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/Parsedown.php';
|
||||
|
||||
class AboutController {
|
||||
private string $defaultContent = "Ce site POSTERG a été créé pour répertorier et valoriser les mémoires de l'erg – École de Recherches Graphiques de Bruxelles.\n\nL'objectif est à la fois d'offrir une vitrine aux projets des anciennes étudiantes et de mettre en lumière la diversité des disciplines et des parcours qui façonnent l'histoire de l'école à travers les âges, depuis près de 100 ans.";
|
||||
|
||||
public static function create(): self { return new self(); }
|
||||
|
||||
public function handle(): array {
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$aboutPage = $db->getPage('about');
|
||||
$rawContent = $aboutPage ? $aboutPage['content'] : '';
|
||||
if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') {
|
||||
$rawContent = $this->defaultContent;
|
||||
}
|
||||
$contacts = $db->getAproposContent('contacts');
|
||||
$credits = $db->getAproposContent('credits');
|
||||
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
|
||||
$credits = is_array($credits) && !empty($credits) ? $credits : null;
|
||||
} catch (Exception $e) {
|
||||
error_log("Error loading about page: " . $e->getMessage());
|
||||
$rawContent = $this->defaultContent;
|
||||
$contacts = null;
|
||||
$credits = null;
|
||||
}
|
||||
|
||||
$pd = new Parsedown();
|
||||
$pd->setSafeMode(true);
|
||||
|
||||
return [
|
||||
'nav' => 'apropos',
|
||||
'aboutHtml' => $pd->text($rawContent),
|
||||
'contacts' => $contacts,
|
||||
'credits' => $credits,
|
||||
'pageTitle' => 'À Propos – Posterg',
|
||||
'metaDescription' => "À propos de Posterg, le répertoire des mémoires de fin d'études de l'erg – École de Recherches Graphiques de Bruxelles.",
|
||||
'extraCss' => ['/assets/css/apropos.css'],
|
||||
'bodyClass' => 'apropos-body',
|
||||
];
|
||||
}
|
||||
}
|
||||
166
app/src/Controllers/ExportController.php
Normal file
166
app/src/Controllers/ExportController.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
/**
|
||||
* ExportController
|
||||
*
|
||||
* Centralises all export logic for admin-facing data dumps.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Export the full SQLite database as a .sqlite file download
|
||||
* - Export TFE listings as CSV (the reverse of the CSV import)
|
||||
*
|
||||
* The class has NO output side-effects; the thin dispatcher files
|
||||
* (public/admin/actions/…) perform headers and echo.
|
||||
*/
|
||||
class ExportController
|
||||
{
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
return new self(Database::getInstance());
|
||||
}
|
||||
|
||||
// ── Database export ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the absolute path of the live database file.
|
||||
*/
|
||||
public function getDatabasePath(): string
|
||||
{
|
||||
return $this->db->getDatabasePath();
|
||||
}
|
||||
|
||||
// ── CSV export ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Column headers matching the import format.
|
||||
*/
|
||||
public const CSV_HEADERS = [
|
||||
'Identifiant',
|
||||
'Titre',
|
||||
'Sous-titre',
|
||||
'Auteur·ice(s)',
|
||||
'Contact',
|
||||
'Promoteur·ice(s)',
|
||||
'Format(s)',
|
||||
'Année',
|
||||
'AP',
|
||||
'Orientation',
|
||||
'Finalité',
|
||||
'Mots-clés',
|
||||
'Synopsis',
|
||||
'Contexte',
|
||||
'Remarques',
|
||||
'Langue',
|
||||
'Autorisation',
|
||||
'Licence',
|
||||
'Taille',
|
||||
'Points sur 20',
|
||||
'Lien BAIU',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetch all theses and their related data, then return a list of rows
|
||||
* shaped to match the import CSV column order.
|
||||
*
|
||||
* Uses batch queries (one per related table) to avoid N+1.
|
||||
*
|
||||
* @return list<list<string>> Each inner list has CSV_HEADERS_COUNT elements.
|
||||
*/
|
||||
public function exportAllTheses(): array
|
||||
{
|
||||
// 1) Base thesis data
|
||||
$theses = $this->db->getAllThesesForExport();
|
||||
if ($theses === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2) Load related data in batches
|
||||
$byThesis = function (array $rows): array {
|
||||
$map = [];
|
||||
foreach ($rows as $r) {
|
||||
$tid = (int) $r['thesis_id'];
|
||||
$map[$tid][] = $r;
|
||||
}
|
||||
return $map;
|
||||
};
|
||||
|
||||
$authors = $byThesis($this->db->getAllThesisAuthorsForExport());
|
||||
$supervisors = $byThesis($this->db->getAllThesisSupervisorsForExport());
|
||||
$tags = $byThesis($this->db->getAllThesisTagsForExport());
|
||||
$languages = $byThesis($this->db->getAllThesisLanguagesForExport());
|
||||
$formats = $byThesis($this->db->getAllThesisFormatsForExport());
|
||||
|
||||
// 3) Build CSV rows
|
||||
$csvRows = [];
|
||||
foreach ($theses as $t) {
|
||||
$tid = (int) $t['id'];
|
||||
|
||||
// Authors + contact (first author with email)
|
||||
$authorList = [];
|
||||
$contact = '';
|
||||
foreach (($authors[$tid] ?? []) as $a) {
|
||||
$authorList[] = $a['name'];
|
||||
if ($contact === '' && !empty($a['email'])) {
|
||||
$contact = $a['email'];
|
||||
}
|
||||
}
|
||||
|
||||
// Supervisors
|
||||
$supList = [];
|
||||
foreach (($supervisors[$tid] ?? []) as $s) {
|
||||
$supList[] = $s['name'];
|
||||
}
|
||||
|
||||
// Tags
|
||||
$tagList = [];
|
||||
foreach (($tags[$tid] ?? []) as $tg) {
|
||||
$tagList[] = $tg['name'];
|
||||
}
|
||||
|
||||
// Languages
|
||||
$langList = [];
|
||||
foreach (($languages[$tid] ?? []) as $l) {
|
||||
$langList[] = $l['name'];
|
||||
}
|
||||
|
||||
// Formats
|
||||
$fmtList = [];
|
||||
foreach (($formats[$tid] ?? []) as $f) {
|
||||
$fmtList[] = $f['name'];
|
||||
}
|
||||
|
||||
$csvRows[] = [
|
||||
$t['identifier'] ?? '',
|
||||
$t['title'] ?? '',
|
||||
$t['subtitle'] ?? '',
|
||||
implode(', ', $authorList),
|
||||
$contact,
|
||||
implode(', ', $supList),
|
||||
implode(', ', $fmtList),
|
||||
$t['year'] ?? '',
|
||||
$t['ap_program'] ?? '',
|
||||
$t['orientation'] ?? '',
|
||||
$t['finality_type'] ?? '',
|
||||
implode(', ', $tagList),
|
||||
$t['synopsis'] ?? '',
|
||||
$t['context_note'] ?? '',
|
||||
$t['remarks'] ?? '',
|
||||
implode(', ', $langList),
|
||||
$t['access_type'] ?? '',
|
||||
$t['license_name'] ?? '',
|
||||
$t['file_size_info'] ?? '',
|
||||
isset($t['jury_points']) ? (string) $t['jury_points'] : '',
|
||||
$t['baiu_link'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $csvRows;
|
||||
}
|
||||
}
|
||||
140
app/src/Controllers/HomeController.php
Normal file
140
app/src/Controllers/HomeController.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* HomeController
|
||||
*
|
||||
* Handles all data-fetching and view-variable assembly for the public home page
|
||||
* (public/index.php).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Parse and validate GET parameters (`page`, `year`)
|
||||
* - Determine the display mode (default random-latest / year-filtered / paginated all)
|
||||
* - Run the appropriate Database queries
|
||||
* - Batch-load cover images for theses without a banner_path
|
||||
* - Assemble OG / meta tag array
|
||||
* - Return a flat array of view variables ready for template extraction
|
||||
*
|
||||
* The class has NO output side-effects; all template rendering stays in
|
||||
* public/index.php so the view layer remains easy to inspect and modify.
|
||||
*/
|
||||
class HomeController
|
||||
{
|
||||
private const ITEMS_PER_PAGE = 24;
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience factory: loads the Database singleton and returns a ready
|
||||
* controller instance.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
|
||||
return new self(Database::getInstance());
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process the current request and return all variables needed by the view.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(): array
|
||||
{
|
||||
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||
$year = isset($_GET['year']) ? (int) $_GET['year'] : null;
|
||||
// Normalise zero (e.g. ?year=0) to null so it is treated as "no filter"
|
||||
if ($year === 0) {
|
||||
$year = null;
|
||||
}
|
||||
|
||||
// Default home view: random theses from latest year (no year filter, no explicit page)
|
||||
$isDefaultView = ($year === null && $page === 1);
|
||||
|
||||
$itemsToLoad = [];
|
||||
$totalItems = 0;
|
||||
$availableYears = [];
|
||||
$latestYear = null;
|
||||
$coverMap = [];
|
||||
|
||||
try {
|
||||
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
|
||||
$availableYears = $this->db->getAvailableYears();
|
||||
|
||||
if ($year !== null) {
|
||||
$itemsToLoad = $this->db->searchTheses(['year' => $year], self::ITEMS_PER_PAGE, $offset);
|
||||
$totalItems = $this->db->countSearchResults(['year' => $year]);
|
||||
} elseif ($isDefaultView) {
|
||||
$latestYear = $this->db->getLatestPublishedYear();
|
||||
$itemsToLoad = $this->db->getLatestYearTheses(self::ITEMS_PER_PAGE);
|
||||
$totalItems = count($itemsToLoad); // no multi-page on default view
|
||||
} else {
|
||||
$itemsToLoad = $this->db->getPublishedTheses(self::ITEMS_PER_PAGE, $offset);
|
||||
$totalItems = $this->db->countPublishedTheses();
|
||||
}
|
||||
|
||||
// Batch-load cover images for theses that have no banner_path
|
||||
if (!empty($itemsToLoad)) {
|
||||
$needCover = array_column(
|
||||
array_filter($itemsToLoad, static fn($t) => empty($t['banner_path'])),
|
||||
'id'
|
||||
);
|
||||
if (!empty($needCover)) {
|
||||
$coverMap = $this->db->getCoverPathsForTheses($needCover);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('HomeController: ' . $e->getMessage());
|
||||
// Return safe empty state; view will show "Aucun mémoire trouvé"
|
||||
$isDefaultView = false;
|
||||
}
|
||||
|
||||
$totalPages = $isDefaultView ? 1 : (int) ceil($totalItems / self::ITEMS_PER_PAGE);
|
||||
// Avoid division by zero on empty DB
|
||||
if ($totalPages < 1) {
|
||||
$totalPages = 0;
|
||||
}
|
||||
|
||||
$baseParams = array_filter(['year' => $year]);
|
||||
|
||||
return [
|
||||
// Pagination / filter state
|
||||
'page' => $page,
|
||||
'year' => $year,
|
||||
'isDefaultView' => $isDefaultView,
|
||||
'totalItems' => $totalItems,
|
||||
'totalPages' => $totalPages,
|
||||
'baseParams' => $baseParams,
|
||||
|
||||
// Thesis data
|
||||
'itemsToLoad' => $itemsToLoad,
|
||||
'latestYear' => $latestYear,
|
||||
'availableYears' => $availableYears,
|
||||
'coverMap' => $coverMap,
|
||||
|
||||
// Page meta
|
||||
'pageTitle' => 'Posterg – Mémoires de l\'ERG',
|
||||
'metaDescription' => 'Posterg répertorie et valorise les mémoires de fin d\'études (TFE) de l\'erg – École de Recherches Graphiques de Bruxelles.',
|
||||
'ogTags' => [
|
||||
'type' => 'website',
|
||||
'title' => 'Posterg – Mémoires de l\'ERG',
|
||||
'description' => 'Posterg répertorie et valorise les mémoires de fin d\'études (TFE) de l\'erg – École de Recherches Graphiques de Bruxelles.',
|
||||
'url' => 'https://posterg.erg.be/',
|
||||
'site_name' => 'Posterg – ERG',
|
||||
],
|
||||
|
||||
// Layout
|
||||
'currentNav' => '',
|
||||
'extraCss' => ['/assets/css/main.css'],
|
||||
'bodyClass' => 'home-body',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/src/Controllers/LicenceController.php
Normal file
36
app/src/Controllers/LicenceController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/Parsedown.php';
|
||||
|
||||
class LicenceController {
|
||||
public static function create(): self {
|
||||
return new self();
|
||||
}
|
||||
|
||||
public function handle(): array {
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$dbPage = $db->getPage('licenses');
|
||||
$content = $dbPage ? $dbPage['content'] : '';
|
||||
$pageTitle = $dbPage ? $dbPage['title'] : 'Licences';
|
||||
} catch (Exception $e) {
|
||||
error_log("Error loading licence page: " . $e->getMessage());
|
||||
$content = '';
|
||||
$pageTitle = 'Licences';
|
||||
}
|
||||
|
||||
$pd = new Parsedown();
|
||||
$pd->setSafeMode(true);
|
||||
$html = $pd->text($content);
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'html' => $html,
|
||||
'pageTitle' => $pageTitle . ' – Posterg',
|
||||
'metaDescription' => "Informations sur les licences d'utilisation des mémoires publiés sur Posterg, le répertoire des TFE de l'erg.",
|
||||
'currentNav' => 'licence',
|
||||
'extraCss' => ['/assets/css/apropos.css'],
|
||||
'bodyClass' => 'apropos-body',
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/src/Controllers/LiveReloadController.php
Normal file
57
app/src/Controllers/LiveReloadController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* Live-reload endpoint for PHP built-in development server.
|
||||
* Polls file mtimes across source directories and returns
|
||||
* whether anything changed since last check.
|
||||
*
|
||||
* Usage (from browser): /live-reload
|
||||
*/
|
||||
class LiveReloadController {
|
||||
private array $watchDirs;
|
||||
private array $watchExts = ['php', 'css', 'js', 'html'];
|
||||
private string $stateFile;
|
||||
|
||||
public function __construct(string $appRoot) {
|
||||
$this->watchDirs = [
|
||||
$appRoot . '/public',
|
||||
$appRoot . '/src',
|
||||
$appRoot . '/config',
|
||||
$appRoot . '/templates',
|
||||
];
|
||||
$this->stateFile = sys_get_temp_dir() . '/posterg-live-reload.txt';
|
||||
}
|
||||
|
||||
public function handle(): array {
|
||||
return ['json' => true, 'body' => $this->poll()];
|
||||
}
|
||||
|
||||
private function poll(): array {
|
||||
$hash = '';
|
||||
foreach ($this->watchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($it as $file) {
|
||||
if (in_array($file->getExtension(), $this->watchExts, true)) {
|
||||
$hash .= $file->getMTime() . '|' . $file->getPathname() . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fingerprint = md5($hash);
|
||||
$prev = file_exists($this->stateFile) ? file_get_contents($this->stateFile) : null;
|
||||
|
||||
if ($prev === null) {
|
||||
file_put_contents($this->stateFile, $fingerprint);
|
||||
$changed = false;
|
||||
} else {
|
||||
$changed = $fingerprint !== $prev;
|
||||
if ($changed) {
|
||||
file_put_contents($this->stateFile, $fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
return ['changed' => $changed];
|
||||
}
|
||||
}
|
||||
114
app/src/Controllers/MediaController.php
Normal file
114
app/src/Controllers/MediaController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* MediaController
|
||||
*
|
||||
* Serves uploaded files stored outside the webroot (STORAGE_ROOT).
|
||||
* This is the sole access point for thesis files, covers, and annexes — they
|
||||
* are never exposed as direct filesystem paths from the web server.
|
||||
*
|
||||
* Security:
|
||||
* - Strict character whitelist on the path parameter (no path traversal)
|
||||
* - realpath() jail: resolved path must stay inside STORAGE_ROOT
|
||||
* - MIME type verified against an allow-list before serving
|
||||
* - Access-type gate for thesis files (blocks 'Interdit' access_type_id=3)
|
||||
*/
|
||||
class MediaController
|
||||
{
|
||||
/**
|
||||
* Handle a media request. Reads $_GET['path'], validates, and streams the file.
|
||||
* Sends appropriate headers and exit() — no return value.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$requestedPath = $_GET['path'] ?? '';
|
||||
|
||||
// 1. Validate path characters
|
||||
if (!preg_match('#^[a-zA-Z0-9/_\-.]+$#', $requestedPath) || $requestedPath === '') {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Resolve path + storage jail
|
||||
$storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : '/var/www/posterg/storage';
|
||||
$fullPath = $storageRoot . '/' . $requestedPath;
|
||||
|
||||
$realStorage = realpath($storageRoot);
|
||||
$realFull = realpath($fullPath);
|
||||
|
||||
if (
|
||||
$realFull === false
|
||||
|| $realStorage === false
|
||||
|| strpos($realFull, $realStorage . '/') !== 0
|
||||
) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_file($realFull)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Visibility gate for thesis files
|
||||
if (preg_match('#^theses/#', $requestedPath)) {
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
try {
|
||||
$mediaDb = Database::getInstance();
|
||||
$accessTypeId = $mediaDb->getFileVisibility($requestedPath);
|
||||
if ($accessTypeId !== null && $accessTypeId === 3) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("MediaController visibility check error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify MIME type
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($realFull);
|
||||
|
||||
$allowedMimes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
'video/mp4',
|
||||
'application/zip',
|
||||
'text/vtt', // WebVTT caption sidecar files
|
||||
];
|
||||
|
||||
// finfo may return 'text/plain' for WebVTT files on some systems;
|
||||
// re-classify by extension so we don't block them.
|
||||
if ($mimeType === 'text/plain' && strtolower(pathinfo($realFull, PATHINFO_EXTENSION)) === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
|
||||
if (!in_array($mimeType, $allowedMimes, true)) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Send response headers
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . filesize($realFull));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$ext = strtolower(pathinfo($realFull, PATHINFO_EXTENSION));
|
||||
|
||||
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true)) {
|
||||
header('Cache-Control: public, max-age=604800');
|
||||
} elseif ($ext === 'pdf') {
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('Content-Disposition: inline');
|
||||
} elseif ($ext === 'vtt') {
|
||||
header('Content-Type: text/vtt; charset=utf-8');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
} else {
|
||||
header('Cache-Control: private, no-store');
|
||||
}
|
||||
|
||||
// 6. Stream file
|
||||
readfile($realFull);
|
||||
}
|
||||
}
|
||||
319
app/src/Controllers/SearchController.php
Normal file
319
app/src/Controllers/SearchController.php
Normal file
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
/**
|
||||
* SearchController
|
||||
*
|
||||
* Handles all data-fetching logic for the public search and répertoire pages.
|
||||
*
|
||||
* Entry points:
|
||||
* - public/search.php calls handleSearch() — text-query results
|
||||
* - public/repertoire.php calls handleRepertoire() — filter index + HTMX swaps
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Rate-limit enforcement (returns early HTTP 429 response when needed)
|
||||
* - GET parameter sanitisation and validation
|
||||
* - Database queries (search + index listings)
|
||||
* - OG / meta tag assembly
|
||||
* - HTMX partial response for repertoire filter swaps
|
||||
*
|
||||
* The class has NO output side-effects; all template rendering stays in the
|
||||
* respective public/*.php files so the view layer remains easy to inspect.
|
||||
* Exception: renderRepertoirePartial() exits early for HTMX requests.
|
||||
*/
|
||||
class SearchController
|
||||
{
|
||||
private const RATE_LIMIT_MAX = 30;
|
||||
private const RATE_LIMIT_WINDOW = 60; // seconds
|
||||
private const ITEMS_PER_PAGE = 30;
|
||||
|
||||
private Database $db;
|
||||
private RateLimit $rateLimit;
|
||||
|
||||
public function __construct(Database $db, RateLimit $rateLimit)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->rateLimit = $rateLimit;
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience factory: builds dependencies, checks rate limit (sends 429
|
||||
* and exits if exceeded), then returns a ready-to-use controller instance.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/RateLimit.php';
|
||||
|
||||
$rateLimit = new RateLimit(self::RATE_LIMIT_MAX, self::RATE_LIMIT_WINDOW);
|
||||
|
||||
if (!$rateLimit->check()) {
|
||||
self::sendRateLimitResponse($rateLimit);
|
||||
}
|
||||
|
||||
$rateLimit->sendHeaders();
|
||||
|
||||
// Probabilistic cleanup (1-in-100 requests) to prune stale entries
|
||||
if (rand(1, 100) === 1) {
|
||||
$rateLimit->cleanup();
|
||||
}
|
||||
|
||||
return new self(Database::getInstance(), $rateLimit);
|
||||
}
|
||||
|
||||
// ── Entry points ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle the search results page (public/search.php).
|
||||
* Requires a ?query= parameter; always returns search-result view variables.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handleSearch(): array
|
||||
{
|
||||
$searchParams = $this->collectSearchParams();
|
||||
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
|
||||
$validationError = null;
|
||||
|
||||
$results = [];
|
||||
$totalItems = 0;
|
||||
$totalPages = 0;
|
||||
$years = [];
|
||||
$orientations = [];
|
||||
$apPrograms = [];
|
||||
|
||||
try {
|
||||
$results = $this->db->searchTheses($searchParams, self::ITEMS_PER_PAGE, $offset);
|
||||
$totalItems = $this->db->countSearchResults($searchParams);
|
||||
$totalPages = (int) ceil($totalItems / self::ITEMS_PER_PAGE);
|
||||
$years = $this->db->getAvailableYears();
|
||||
$orientations = $this->db->getAllOrientations();
|
||||
$apPrograms = $this->db->getAllAPPrograms();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$validationError = $e->getMessage();
|
||||
} catch (Exception $e) {
|
||||
error_log('SearchController: ' . $e->getMessage());
|
||||
$validationError = 'Une erreur est survenue.';
|
||||
}
|
||||
|
||||
// Preserve all active params, strip 'page' (pagination partial adds it)
|
||||
$baseParams = array_diff_key($_GET, ['page' => '']);
|
||||
|
||||
$query = $_GET['query'] ?? '';
|
||||
|
||||
return [
|
||||
'searchParams' => $searchParams,
|
||||
'page' => $page,
|
||||
'totalItems' => $totalItems,
|
||||
'totalPages' => $totalPages,
|
||||
'results' => $results,
|
||||
'validationError' => $validationError,
|
||||
'baseParams' => $baseParams,
|
||||
|
||||
// Filter dropdowns
|
||||
'years' => $years,
|
||||
'orientations' => $orientations,
|
||||
'apPrograms' => $apPrograms,
|
||||
|
||||
// Page meta
|
||||
'searchBarValue' => $query,
|
||||
'pageTitle' => $query !== '' ? 'Recherche : ' . $query . ' – Posterg' : 'Recherche – Posterg',
|
||||
'metaDescription' => "Résultats de recherche dans le répertoire des TFE de l'erg.",
|
||||
'ogTags' => [
|
||||
'type' => 'website',
|
||||
'title' => 'Recherche – Posterg',
|
||||
'description' => "Résultats de recherche dans le répertoire des TFE de l'erg.",
|
||||
'url' => 'https://posterg.erg.be/search.php',
|
||||
'site_name' => 'Posterg – ERG',
|
||||
],
|
||||
'currentNav' => 'repertoire',
|
||||
'extraCss' => ['/assets/css/search.css'],
|
||||
'bodyClass' => 'search-body',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the répertoire index page (public/repertoire.php).
|
||||
* Serves the filter-column index; HTMX partial swaps are handled here too.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handleRepertoire(): array
|
||||
{
|
||||
$isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']);
|
||||
$activeFilters = $this->collectFilterParams();
|
||||
$repData = null;
|
||||
$validationError = null;
|
||||
|
||||
try {
|
||||
$repData = $this->db->getRepertoireFilterData($activeFilters);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$validationError = $e->getMessage();
|
||||
} catch (Exception $e) {
|
||||
error_log('SearchController: ' . $e->getMessage());
|
||||
$validationError = 'Une erreur est survenue.';
|
||||
}
|
||||
|
||||
// HTMX partial: render just the index div and exit
|
||||
if ($isHtmx && $repData !== null) {
|
||||
$this->renderRepertoirePartial($repData, $activeFilters);
|
||||
}
|
||||
|
||||
return [
|
||||
'repData' => $repData,
|
||||
'activeFilters' => $activeFilters,
|
||||
'isHtmx' => $isHtmx,
|
||||
'validationError' => $validationError,
|
||||
|
||||
// Page meta
|
||||
'searchBarValue' => '',
|
||||
'pageTitle' => 'Répertoire – Posterg',
|
||||
'metaDescription' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.",
|
||||
'ogTags' => [
|
||||
'type' => 'website',
|
||||
'title' => 'Répertoire – Posterg',
|
||||
'description' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.",
|
||||
'url' => 'https://posterg.erg.be/repertoire.php',
|
||||
'site_name' => 'Posterg – ERG',
|
||||
],
|
||||
'currentNav' => 'repertoire',
|
||||
'extraCss' => ['/assets/css/search.css'],
|
||||
'bodyClass' => 'search-body',
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the repertoire index partial and exit (for HTMX swaps).
|
||||
* Never returns.
|
||||
*/
|
||||
private function renderRepertoirePartial(array $repData, array $activeFilters): never
|
||||
{
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
$isHtmx = true;
|
||||
include APP_ROOT . '/templates/partials/repertoire-index.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and sanitise repertoire filter params from $_GET.
|
||||
* Params: fy[] (years), ap[] (AP programs), or[] (orientations),
|
||||
* fi[] (finality types), kw[] (keywords)
|
||||
*
|
||||
* @return array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]}
|
||||
*/
|
||||
private function collectFilterParams(): array
|
||||
{
|
||||
$sanitiseStrings = function(mixed $raw, int $maxLen = 100): array {
|
||||
if (!is_array($raw)) return [];
|
||||
$out = [];
|
||||
foreach ($raw as $v) {
|
||||
$v = trim((string)$v);
|
||||
if ($v !== '' && strlen($v) <= $maxLen) {
|
||||
$out[] = $v;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
};
|
||||
|
||||
$years = [];
|
||||
if (!empty($_GET['fy']) && is_array($_GET['fy'])) {
|
||||
foreach ($_GET['fy'] as $y) {
|
||||
$y = (int)$y;
|
||||
if ($y >= 1900 && $y <= 2100) $years[] = $y;
|
||||
}
|
||||
$years = array_values(array_unique($years));
|
||||
}
|
||||
|
||||
return [
|
||||
'years' => $years,
|
||||
'ap' => $sanitiseStrings($_GET['ap'] ?? []),
|
||||
'or' => $sanitiseStrings($_GET['or'] ?? []),
|
||||
'fi' => $sanitiseStrings($_GET['fi'] ?? []),
|
||||
'kw' => $sanitiseStrings($_GET['kw'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise and collect valid text search parameters from $_GET.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function collectSearchParams(): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
if (!empty($_GET['query'])) {
|
||||
$params['query'] = trim((string) $_GET['query']);
|
||||
}
|
||||
if (!empty($_GET['year'])) {
|
||||
$params['year'] = (int) $_GET['year'];
|
||||
}
|
||||
if (!empty($_GET['orientation'])) {
|
||||
$params['orientation'] = (string) $_GET['orientation'];
|
||||
}
|
||||
if (!empty($_GET['ap_program'])) {
|
||||
$params['ap_program'] = (string) $_GET['ap_program'];
|
||||
}
|
||||
if (!empty($_GET['keyword'])) {
|
||||
$params['keyword'] = (string) $_GET['keyword'];
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
// ── Rate-limit response ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a 429 response and exit. Never returns.
|
||||
*/
|
||||
private static function sendRateLimitResponse(RateLimit $rateLimit): never
|
||||
{
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $rateLimit->getResetTime());
|
||||
$retrySeconds = (int) $rateLimit->getResetTime();
|
||||
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Trop de requêtes – Posterg</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #0d0d0d;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.box { max-width: 520px; text-align: center; }
|
||||
.box__logo {
|
||||
font-size: 1.1rem; font-weight: 700;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
color: #fff; margin-bottom: 2.5rem;
|
||||
}
|
||||
.box__title { font-size: 1.6rem; font-weight: 300; margin-bottom: 1rem; }
|
||||
.box__text { font-size: .95rem; color: #999; line-height: 1.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="box__logo">POSTERG</div>
|
||||
<h1 class="box__title">Trop de requêtes</h1>
|
||||
<p class="box__text">Vous avez effectué trop de recherches en peu de temps.<br>
|
||||
Réessayez dans {$retrySeconds} secondes.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
456
app/src/Controllers/SystemController.php
Normal file
456
app/src/Controllers/SystemController.php
Normal file
@@ -0,0 +1,456 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SystemController
|
||||
*
|
||||
* Centralises all data-fetching for the admin system page and its
|
||||
* fetch()-based tab-panel fragment endpoint.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - System status checks (nginx, php-fpm, HTTP ping, database, storage,
|
||||
* maintenance mode) with SystemCache TTL caching
|
||||
* - PHP environment info (1-hour TTL)
|
||||
* - Disk usage info (5-minute TTL)
|
||||
* - Log file reading (tail, meta)
|
||||
* - Nginx config file reading
|
||||
* - Log/nginx line classifiers used by both system.php and system-fragment.php
|
||||
*
|
||||
* Both system.php (full page) and system-fragment.php (AJAX panel) delegate
|
||||
* here so helpers are never duplicated.
|
||||
*/
|
||||
class SystemController
|
||||
{
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
public const LOG_FILES = [
|
||||
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/posterg_access.log'],
|
||||
'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/posterg_error.log'],
|
||||
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'],
|
||||
];
|
||||
|
||||
public const ALLOWED_LINES = [50, 100, 200, 500];
|
||||
|
||||
/** Live deployed nginx config path. */
|
||||
public const NGINX_CONFIG_LIVE = '/etc/nginx/sites-available/posterg';
|
||||
/** Local reference copy used as fallback in dev. */
|
||||
public const NGINX_CONFIG_LOCAL = APP_ROOT . '/nginx/posterg.conf';
|
||||
|
||||
// ── TTLs ──────────────────────────────────────────────────────────────────
|
||||
private const TTL_STATUS = 120; // 2 minutes
|
||||
private const TTL_PHP = 3600; // 1 hour
|
||||
private const TTL_DISK = 300; // 5 minutes
|
||||
|
||||
private Database $db;
|
||||
private SystemCache $cache;
|
||||
|
||||
public function __construct(Database $db, SystemCache $cache)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
// ── Cache invalidation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force-bust all cached sections (called on ?refresh=1).
|
||||
*/
|
||||
public function invalidateAll(): void
|
||||
{
|
||||
$this->cache->invalidate('system_status');
|
||||
$this->cache->invalidate('disk_info');
|
||||
$this->cache->invalidate('php_info');
|
||||
}
|
||||
|
||||
// ── Status data ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return system status checks array, from cache when fresh.
|
||||
*
|
||||
* @return array{checks: array, cached: bool, cacheAge: ?int}
|
||||
*/
|
||||
public function getStatusData(): array
|
||||
{
|
||||
$cacheAge = $this->cache->ageSeconds('system_status');
|
||||
$cached = $this->cache->get('system_status', self::TTL_STATUS);
|
||||
|
||||
if ($cached !== null) {
|
||||
return ['checks' => $cached, 'cached' => true, 'cacheAge' => $cacheAge];
|
||||
}
|
||||
|
||||
$checks = $this->runStatusChecks();
|
||||
$this->cache->set('system_status', $checks);
|
||||
|
||||
return ['checks' => $checks, 'cached' => false, 'cacheAge' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return PHP environment info, from cache when fresh.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getPhpInfo(): array
|
||||
{
|
||||
$cached = $this->cache->get('php_info', self::TTL_PHP);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$info = [
|
||||
'version' => PHP_VERSION,
|
||||
'sapi' => PHP_SAPI,
|
||||
'memory_limit' => ini_get('memory_limit'),
|
||||
'upload_max' => ini_get('upload_max_filesize'),
|
||||
'post_max' => ini_get('post_max_size'),
|
||||
'max_exec' => ini_get('max_execution_time') . 's',
|
||||
];
|
||||
$this->cache->set('php_info', $info);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return disk usage info, from cache when fresh.
|
||||
*
|
||||
* @return array{total: int, free: int, used: int, pct: int}
|
||||
*/
|
||||
public function getDiskInfo(): array
|
||||
{
|
||||
$cached = $this->cache->get('disk_info', self::TTL_DISK);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$total = (int) disk_total_space(APP_ROOT);
|
||||
$free = (int) disk_free_space(APP_ROOT);
|
||||
$used = $total - $free;
|
||||
$pct = $total > 0 ? (int) round($used / $total * 100) : 0;
|
||||
|
||||
$info = ['total' => $total, 'free' => $free, 'used' => $used, 'pct' => $pct];
|
||||
$this->cache->set('disk_info', $info);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
// ── Log tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and return data for a log tab.
|
||||
*
|
||||
* @return array{lines: ?array, error: ?string, meta: ?array}
|
||||
*/
|
||||
public function getLogData(string $tab, int $n): array
|
||||
{
|
||||
$logPath = self::LOG_FILES[$tab]['path'];
|
||||
$error = null;
|
||||
$lines = $this->readLogTail($logPath, $n, $error);
|
||||
$meta = null;
|
||||
|
||||
if (file_exists($logPath)) {
|
||||
$sz = filesize($logPath);
|
||||
$meta = [
|
||||
'size' => $sz > 1048576
|
||||
? number_format($sz / 1048576, 2) . ' MB'
|
||||
: number_format($sz / 1024, 1) . ' KB',
|
||||
'mtime' => date('d/m/Y H:i:s', filemtime($logPath)),
|
||||
'path' => $logPath,
|
||||
];
|
||||
}
|
||||
|
||||
return ['lines' => $lines, 'error' => $error, 'meta' => $meta];
|
||||
}
|
||||
|
||||
// ── Nginx config tab ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and return data for the nginx config tab.
|
||||
*
|
||||
* @return array{lines: ?array, source: ?string, meta: ?array, error: ?string}
|
||||
*/
|
||||
public function getNginxConfigData(): array
|
||||
{
|
||||
$livePath = self::NGINX_CONFIG_LIVE;
|
||||
$localPath = self::NGINX_CONFIG_LOCAL;
|
||||
|
||||
foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) {
|
||||
if (file_exists($path) && is_readable($path)) {
|
||||
$raw = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if ($raw !== false) {
|
||||
$sz = filesize($path);
|
||||
$meta = [
|
||||
'path' => $path,
|
||||
'size' => $sz > 1048576
|
||||
? number_format($sz / 1048576, 2) . ' MB'
|
||||
: number_format($sz / 1024, 1) . ' KB',
|
||||
'mtime' => date('d/m/Y H:i:s', filemtime($path)),
|
||||
];
|
||||
return ['lines' => $raw, 'source' => $src, 'meta' => $meta, 'error' => null];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$error = file_exists($livePath)
|
||||
? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath)
|
||||
: "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ").";
|
||||
|
||||
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
|
||||
}
|
||||
|
||||
// ── Line classifiers (used by both system.php and system-fragment.php) ────
|
||||
|
||||
/**
|
||||
* Return the CSS class for a log line.
|
||||
*/
|
||||
public static function logLineClass(string $line): string
|
||||
{
|
||||
if (preg_match('/\[(crit|emerg|alert)\]/', $line)) return 'log-crit';
|
||||
if (preg_match('/\[error\]/', $line)) return 'log-error';
|
||||
if (preg_match('/\[warn\]/', $line)) return 'log-warn';
|
||||
if (preg_match('/\[notice\]/', $line)) return 'log-notice';
|
||||
if (preg_match('/" [45]\d\d /', $line)) return 'log-error';
|
||||
if (preg_match('/" 3\d\d /', $line)) return 'log-notice';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the CSS class for a nginx config line.
|
||||
*/
|
||||
public static function nginxLineClass(string $line): string
|
||||
{
|
||||
$trimmed = ltrim($line);
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '#')) return 'nginx-comment';
|
||||
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) return 'nginx-block';
|
||||
return 'nginx-directive';
|
||||
}
|
||||
|
||||
// ── View helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Human-readable byte string (GB / MB / KB).
|
||||
*/
|
||||
public static function humanBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes > 1073741824) return number_format($bytes / 1073741824, 1) . ' GB';
|
||||
if ($bytes > 1048576) return number_format($bytes / 1048576, 1) . ' MB';
|
||||
return number_format($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
|
||||
/**
|
||||
* French status label with leading symbol.
|
||||
*/
|
||||
public static function statusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active' => '● En ligne',
|
||||
'inactive' => '○ Inactif',
|
||||
'failed' => '✕ Erreur',
|
||||
'warn' => '⚠ Attention',
|
||||
default => '? Inconnu',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class for a status value.
|
||||
*/
|
||||
public static function statusClass(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active' => 'status-ok',
|
||||
'inactive' => 'status-warn',
|
||||
'warn' => 'status-warn',
|
||||
'failed' => 'status-err',
|
||||
default => 'status-unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS colour string for a disk-usage percentage.
|
||||
*/
|
||||
public static function diskColor(int $pct): string
|
||||
{
|
||||
if ($pct > 85) return '#e05555';
|
||||
if ($pct > 70) return '#ffc107';
|
||||
return '#4caf50';
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute all six status checks and return the checks array.
|
||||
*/
|
||||
private function runStatusChecks(): array
|
||||
{
|
||||
$checks = [];
|
||||
|
||||
// nginx
|
||||
$nginxStatus = $this->systemdStatus('nginx');
|
||||
$nginxVersion = $this->safeExec('nginx -v 2>&1 | head -1');
|
||||
$checks['nginx'] = [
|
||||
'label' => 'nginx',
|
||||
'status' => $nginxStatus,
|
||||
'detail' => $nginxVersion,
|
||||
];
|
||||
|
||||
// php-fpm — probe running PHP version's unit first, then fall back
|
||||
$phpFpmStatus = null;
|
||||
$phpFpmUnit = null;
|
||||
$phpMajMin = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
|
||||
$fpmCandidates = array_unique([
|
||||
'php' . $phpMajMin . '-fpm',
|
||||
'php8.4-fpm', 'php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm',
|
||||
]);
|
||||
foreach ($fpmCandidates as $unit) {
|
||||
$s = $this->systemdStatus($unit);
|
||||
if ($s !== null && $s !== 'unknown') {
|
||||
$phpFpmStatus = $s;
|
||||
$phpFpmUnit = $unit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$checks['php_fpm'] = [
|
||||
'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''),
|
||||
'status' => $phpFpmStatus,
|
||||
'detail' => null,
|
||||
];
|
||||
|
||||
// Site HTTP ping
|
||||
$siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/';
|
||||
$httpResult = $this->localHttpCheck($siteUrl);
|
||||
$checks['site_http'] = [
|
||||
'label' => 'Site HTTP',
|
||||
'status' => $httpResult !== null ? ($httpResult[0] < 500 ? 'active' : 'failed') : null,
|
||||
'detail' => $httpResult !== null
|
||||
? "HTTP {$httpResult[0]} — {$httpResult[1]} ms"
|
||||
: 'curl indisponible',
|
||||
];
|
||||
|
||||
// Database
|
||||
$dbPath = $this->db->getDatabasePath();
|
||||
$dbExists = file_exists($dbPath);
|
||||
$dbWritable = $dbExists && is_writable($dbPath);
|
||||
$dbSizeBytes = $dbExists ? filesize($dbPath) : null;
|
||||
$dbSizeHuman = $dbSizeBytes !== null
|
||||
? ($dbSizeBytes > 1048576
|
||||
? number_format($dbSizeBytes / 1048576, 1) . ' MB'
|
||||
: number_format($dbSizeBytes / 1024, 1) . ' KB')
|
||||
: 'N/A';
|
||||
$dbRowCount = null;
|
||||
if ($dbExists) {
|
||||
try {
|
||||
$dbRowCount = $this->db->getThesisCount();
|
||||
} catch (Throwable) {}
|
||||
}
|
||||
$checks['database'] = [
|
||||
'label' => 'Base de données SQLite',
|
||||
'status' => $dbExists ? ($dbWritable ? 'active' : 'inactive') : 'failed',
|
||||
'detail' => $dbExists
|
||||
? ($dbRowCount !== null ? "$dbRowCount thèses — $dbSizeHuman" : "Lecture impossible — $dbSizeHuman")
|
||||
: 'Fichier introuvable',
|
||||
];
|
||||
|
||||
// Storage directory
|
||||
$storageDir = APP_ROOT . '/storage';
|
||||
$storageWritable = is_dir($storageDir) && is_writable($storageDir);
|
||||
$bannersDir = $storageDir . '/banners';
|
||||
$coversDir = $storageDir . '/covers';
|
||||
$checks['storage'] = [
|
||||
'label' => 'Répertoire storage',
|
||||
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
|
||||
'detail' => $storageWritable
|
||||
? implode(' · ', array_filter([
|
||||
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
|
||||
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
|
||||
]))
|
||||
: 'Non accessible en écriture',
|
||||
];
|
||||
|
||||
// Maintenance mode
|
||||
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
|
||||
$checks['maintenance'] = [
|
||||
'label' => 'Mode maintenance',
|
||||
'status' => $maintenanceOn ? 'warn' : 'active',
|
||||
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé',
|
||||
];
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the tail of a log file, newest-first. Returns null on error.
|
||||
*/
|
||||
private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
|
||||
{
|
||||
$errorMsg = null;
|
||||
|
||||
if (!function_exists('exec')) {
|
||||
$errorMsg = "exec() est désactivé sur ce serveur.";
|
||||
return null;
|
||||
}
|
||||
if (!file_exists($logPath)) {
|
||||
$errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath);
|
||||
return null;
|
||||
}
|
||||
if (!is_readable($logPath)) {
|
||||
$errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$rc = 0;
|
||||
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
|
||||
|
||||
if ($rc !== 0) {
|
||||
$errorMsg = "Erreur lors de la lecture du fichier journal.";
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_reverse($output); // newest first
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command safely, returning trimmed stdout or null on failure.
|
||||
*/
|
||||
private function safeExec(string $cmd): ?string
|
||||
{
|
||||
if (!function_exists('exec')) return null;
|
||||
$output = [];
|
||||
$rc = 0;
|
||||
exec($cmd . ' 2>/dev/null', $output, $rc);
|
||||
return $rc === 0 ? trim(implode("\n", $output)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query systemd for a unit's active state.
|
||||
*/
|
||||
private function systemdStatus(string $unit): ?string
|
||||
{
|
||||
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
|
||||
if ($raw === null) return null;
|
||||
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
|
||||
? $raw : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a lightweight HEAD request to $url and return [httpCode, ms].
|
||||
* Returns null if curl is unavailable.
|
||||
*/
|
||||
private function localHttpCheck(string $url): ?array
|
||||
{
|
||||
if (!function_exists('curl_init')) return null;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => 0,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
]);
|
||||
$start = microtime(true);
|
||||
curl_exec($ch);
|
||||
$ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
return $code > 0 ? [$code, $ms] : null;
|
||||
}
|
||||
}
|
||||
247
app/src/Controllers/TfeController.php
Normal file
247
app/src/Controllers/TfeController.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* TfeController
|
||||
*
|
||||
* Handles all data-fetching and view-variable assembly for the public TFE
|
||||
* detail page (public/tfe.php).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Validate the `id` GET parameter and load the thesis record
|
||||
* - Enforce publication visibility (redirect to index on 404)
|
||||
* - Resolve the OG image (banner → first image file)
|
||||
* - Build the complete OG / Twitter Card tag array
|
||||
* - Assemble the meta description from the synopsis
|
||||
* - Collect WebVTT caption file paths for video pairing
|
||||
* - Return a flat array of view variables ready for template extraction
|
||||
*
|
||||
* The class has NO output side-effects; all template rendering stays in
|
||||
* public/tfe.php so the view layer remains easy to inspect and modify.
|
||||
*/
|
||||
class TfeController
|
||||
{
|
||||
private const BASE_URL = 'https://posterg.erg.be';
|
||||
private const META_MAX_LEN = 160;
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience factory: loads the Database singleton and returns a ready
|
||||
* controller instance.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
|
||||
return new self(Database::getInstance());
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process the current request.
|
||||
*
|
||||
* On success returns an array of view variables.
|
||||
* On failure (missing id, thesis not found) sends a redirect and exits.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(): array
|
||||
{
|
||||
$thesisId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||
|
||||
if ($thesisId <= 0) {
|
||||
$this->redirectHome();
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->db->getThesisById($thesisId);
|
||||
} catch (Exception $e) {
|
||||
error_log('TfeController: ' . $e->getMessage());
|
||||
$this->redirectHome();
|
||||
}
|
||||
|
||||
if (empty($data)) {
|
||||
$this->redirectHome();
|
||||
}
|
||||
|
||||
// Access type (1 = open, 2 = restricted, 3 = forbidden)
|
||||
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
|
||||
$isInterdit = ($accessTypeId === 3);
|
||||
|
||||
// Caption (WebVTT) files — N-th VTT is paired with the N-th <video>
|
||||
$captionFiles = $this->collectCaptionPaths($data['files'] ?? []);
|
||||
|
||||
// Jury members with interne/externe split
|
||||
$jury = $this->db->getThesisJury($thesisId);
|
||||
$juryByRole = $this->splitJuryByRole($jury);
|
||||
|
||||
// Page meta
|
||||
$metaDescription = $this->buildMetaDescription($data['synopsis'] ?? '');
|
||||
$ogTags = $this->buildOgTags($data, $thesisId, $metaDescription);
|
||||
$pageTitle = $data['title']
|
||||
. (!empty($data['authors']) ? ' – ' . $data['authors'] : '')
|
||||
. ' – Posterg';
|
||||
|
||||
return [
|
||||
// Core data
|
||||
'thesisId' => $thesisId,
|
||||
'data' => $data,
|
||||
'accessTypeId' => $accessTypeId,
|
||||
'isInterdit' => $isInterdit,
|
||||
'captionFiles' => $captionFiles,
|
||||
'juryPresidents' => $juryByRole['presidents'],
|
||||
'promoteursInternes' => $juryByRole['internes'],
|
||||
'promoteursExternes' => $juryByRole['externes'],
|
||||
'juryLecteurs' => $juryByRole['lecteurs'],
|
||||
|
||||
// Page meta
|
||||
'pageTitle' => $pageTitle,
|
||||
'metaDescription' => $metaDescription,
|
||||
'ogTags' => $ogTags,
|
||||
|
||||
// Layout
|
||||
'currentNav' => '',
|
||||
'extraCss' => ['/assets/css/tfe.css'],
|
||||
'bodyClass' => 'tfe-body',
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a ~160-character meta description from the thesis synopsis.
|
||||
*/
|
||||
private function buildMetaDescription(string $synopsis): string
|
||||
{
|
||||
$plain = strip_tags($synopsis);
|
||||
|
||||
if (empty($plain)) {
|
||||
return 'Mémoire de fin d\'études – Posterg, répertoire des TFE de l\'erg.';
|
||||
}
|
||||
|
||||
return strlen($plain) > self::META_MAX_LEN
|
||||
? substr($plain, 0, self::META_MAX_LEN - 3) . '…'
|
||||
: $plain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OG image URL: banner_path → first image file → empty string.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $files
|
||||
*/
|
||||
private function resolveOgImage(array $files, ?string $bannerPath): string
|
||||
{
|
||||
if (!empty($bannerPath)) {
|
||||
return self::BASE_URL . '/media.php?path=' . rawurlencode($bannerPath);
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
|
||||
return self::BASE_URL . '/media.php?path=' . rawurlencode($file['file_path']);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete $ogTags array consumed by templates/head.php.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildOgTags(array $data, int $thesisId, string $metaDescription): array
|
||||
{
|
||||
$ogImage = $this->resolveOgImage($data['files'] ?? [], $data['banner_path'] ?? null);
|
||||
$title = $data['title'] . (!empty($data['authors']) ? ' – ' . $data['authors'] : '');
|
||||
$imageAlt = $data['title'] . (!empty($data['authors']) ? ' par ' . $data['authors'] : '');
|
||||
|
||||
return [
|
||||
'type' => 'article',
|
||||
'title' => $title,
|
||||
'description' => $metaDescription,
|
||||
'url' => self::BASE_URL . '/tfe.php?id=' . $thesisId,
|
||||
'image' => $ogImage,
|
||||
'image_alt' => $imageAlt,
|
||||
'site_name' => 'Posterg – ERG',
|
||||
'article_author' => $data['authors'] ?? '',
|
||||
'article_published_time' => !empty($data['year']) ? $data['year'] . '-01-01' : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split jury members by role and internal/external flag.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $jury
|
||||
* @return array{presidents: list<string>, internes: list<string>, externes: list<string>, lecteurs: list<string>}
|
||||
*/
|
||||
private function splitJuryByRole(array $jury): array
|
||||
{
|
||||
$result = ['presidents' => [], 'internes' => [], 'externes' => [], 'lecteurs' => []];
|
||||
|
||||
foreach ($jury as $member) {
|
||||
$name = $member['name'] ?? '';
|
||||
if ($name === '') continue;
|
||||
|
||||
switch ($member['role']) {
|
||||
case 'president':
|
||||
$result['presidents'][] = $name;
|
||||
break;
|
||||
case 'promoteur':
|
||||
if ((int)$member['is_external'] === 1) {
|
||||
$result['externes'][] = $name;
|
||||
} else {
|
||||
$result['internes'][] = $name;
|
||||
}
|
||||
break;
|
||||
case 'lecteur':
|
||||
$result['lecteurs'][] = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an ordered list of VTT caption file paths from the files array.
|
||||
* The N-th entry corresponds to the N-th <video> element in document order.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $files
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectCaptionPaths(array $files): array
|
||||
{
|
||||
$captions = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$mime = $file['mime_type'] ?? '';
|
||||
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
|
||||
|
||||
if ($mime === 'text/vtt' || $ext === 'vtt') {
|
||||
$captions[] = $file['file_path'];
|
||||
}
|
||||
}
|
||||
|
||||
return $captions;
|
||||
}
|
||||
|
||||
// ── Response helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redirect to the home page and terminate. Never returns.
|
||||
*/
|
||||
private function redirectHome(): never
|
||||
{
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
444
app/src/Controllers/ThesisCreateController.php
Normal file
444
app/src/Controllers/ThesisCreateController.php
Normal file
@@ -0,0 +1,444 @@
|
||||
<?php
|
||||
/**
|
||||
* ThesisCreateController
|
||||
*
|
||||
* Centralises all validation, data-fetching, and persistence logic for the
|
||||
* admin "add new thesis" workflow (admin/add.php + admin/actions/formulaire.php).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Loading lookup tables for the add-form view (loadFormData)
|
||||
* - Validating and sanitising POST submissions
|
||||
* - Creating the thesis record, linking authors / jury / languages / formats /
|
||||
* tags in a single database transaction
|
||||
* - Handling cover image, banner, and multi-file uploads
|
||||
* - WCAG 3.3.1: mapping validation exception messages to autofocus field hints
|
||||
*
|
||||
* The class has NO output side-effects; all redirects, flash writes, session
|
||||
* mutations, and template rendering stay in the thin dispatcher files so the
|
||||
* view layer remains easy to inspect and modify.
|
||||
*/
|
||||
class ThesisCreateController
|
||||
{
|
||||
/** Maximum allowed file size for thesis files (bytes). */
|
||||
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
/** MIME types accepted for thesis files. */
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'application/pdf',
|
||||
'video/mp4', 'application/zip', 'text/vtt',
|
||||
];
|
||||
|
||||
/** File extensions accepted for thesis files. */
|
||||
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience factory — instantiates Database and returns a ready
|
||||
* controller instance.
|
||||
*/
|
||||
public static function make(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
|
||||
return new self(new Database());
|
||||
}
|
||||
|
||||
// ── Read / view data ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load all lookup tables required to render the add-thesis form.
|
||||
*
|
||||
* Returns a flat array of view variables:
|
||||
* - 'orientations' – orientation lookup rows
|
||||
* - 'apPrograms' – AP program lookup rows
|
||||
* - 'finalityTypes' – finality type lookup rows
|
||||
* - 'languages' – language lookup rows
|
||||
* - 'formatTypes' – format type lookup rows
|
||||
* - 'licenseTypes' – license type lookup rows
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception on DB error.
|
||||
*/
|
||||
public function loadFormData(): array
|
||||
{
|
||||
return [
|
||||
'orientations' => $this->db->getAllOrientations(),
|
||||
'apPrograms' => $this->db->getAllAPPrograms(),
|
||||
'finalityTypes' => $this->db->getAllFinalityTypes(),
|
||||
'languages' => $this->db->getAllLanguages(),
|
||||
'formatTypes' => $this->db->getAllFormatTypes(),
|
||||
'licenseTypes' => $this->db->getAllLicenseTypes(),
|
||||
'enabledAccessTypes' => $this->db->getEnabledFormAccessTypes(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Write / action ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and persist a new-thesis POST submission.
|
||||
*
|
||||
* On success, returns the new thesis ID so the caller can redirect to
|
||||
* thanks.php?id=<n>. On validation or DB failure, throws an Exception
|
||||
* (caller must flash the message and redirect back to the form).
|
||||
*
|
||||
* Execution order:
|
||||
* 1. Validate + sanitise POST fields
|
||||
* 2. Find/create author record
|
||||
* 3. INSERT thesis row + link author (inside transaction)
|
||||
* 4. Link jury, languages, formats, tags (inside transaction)
|
||||
* 5. COMMIT
|
||||
* 6. Handle file uploads: cover, banner, thesis files (outside transaction)
|
||||
*
|
||||
* @param array $post Sanitised $_POST array.
|
||||
* @param array $files $_FILES array.
|
||||
* @return int The newly created thesis ID.
|
||||
* @throws Exception On validation or DB error.
|
||||
*/
|
||||
public function submit(array $post, array $files): int
|
||||
{
|
||||
// ── 1. Validate + sanitise ────────────────────────────────────────────
|
||||
$data = $this->validateAndSanitise($post);
|
||||
|
||||
// ── 2. Find / create author ───────────────────────────────────────────
|
||||
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']);
|
||||
error_log("ThesisCreateController: author ID $authorId");
|
||||
|
||||
// ── 3–4. DB writes in a transaction ───────────────────────────────────
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$thesisId = $this->db->createThesis([
|
||||
'year' => $data['annee'],
|
||||
'orientation_id' => $data['orientationId'],
|
||||
'ap_program_id' => $data['apProgramId'],
|
||||
'finality_id' => $data['finalityId'],
|
||||
'title' => $data['titre'],
|
||||
'subtitle' => $data['subtitle'],
|
||||
'synopsis' => $data['synopsis'],
|
||||
'file_size_info' => $data['durationInfo'],
|
||||
'baiu_link' => $data['lien'],
|
||||
'license_id' => $data['licenseId'],
|
||||
'access_type_id' => $data['accessTypeId'],
|
||||
'author_id' => $authorId,
|
||||
]);
|
||||
|
||||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier)");
|
||||
|
||||
$this->db->setThesisJury($thesisId, $data['juryMembers']);
|
||||
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
|
||||
$this->db->setThesisFormats($thesisId, $data['formatIds']);
|
||||
$this->db->setThesisTags($thesisId, $data['keywords']);
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
|
||||
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
|
||||
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null);
|
||||
|
||||
return $thesisId;
|
||||
}
|
||||
|
||||
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a validation exception message to the name of the field that should
|
||||
* receive autofocus when the form is re-rendered after an error.
|
||||
*
|
||||
* Returns null when no field mapping is found.
|
||||
*/
|
||||
public static function autofocusFieldForError(string $message): ?string
|
||||
{
|
||||
if (str_contains($message, 'Nom/Prénom/Pseudo')) return 'auteurice';
|
||||
if (str_contains($message, 'Titre du mémoire')) return 'titre';
|
||||
if (str_contains($message, 'Synopsis')) return 'synopsis';
|
||||
if (str_contains($message, 'Année invalide')) return 'année';
|
||||
if (str_contains($message, 'orientation')) return 'orientation';
|
||||
if (str_contains($message, 'Atelier Pratique')) return 'ap';
|
||||
if (str_contains($message, 'finalité')) return 'finality';
|
||||
if (str_contains($message, 'langue')) return 'languages';
|
||||
if (str_contains($message, 'mots-clés')) return 'tag';
|
||||
if (str_contains($message, 'Lien URL')) return 'lien';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Private: validation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and sanitise all POST fields for a new thesis submission.
|
||||
*
|
||||
* Returns a flat associative array of clean values.
|
||||
*
|
||||
* @param array $post Raw $_POST.
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception on validation failure.
|
||||
*/
|
||||
private function validateAndSanitise(array $post): array
|
||||
{
|
||||
$auteurName = $this->validateRequired(
|
||||
$this->sanitiseString($post['auteurice'] ?? ''),
|
||||
'Nom/Prénom/Pseudo'
|
||||
);
|
||||
|
||||
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
|
||||
$showContact = !empty($post['contact_public']) ? true : false;
|
||||
|
||||
$annee = filter_var($post['année'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($annee === false || $annee < 2000 || $annee > ((int) date('Y') + 1)) {
|
||||
throw new Exception('Année invalide. Veuillez entrer une année valide.');
|
||||
}
|
||||
|
||||
$orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($orientationId === false) {
|
||||
throw new Exception('Veuillez sélectionner une orientation.');
|
||||
}
|
||||
|
||||
$apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($apProgramId === false) {
|
||||
throw new Exception('Veuillez sélectionner un Atelier Pratique.');
|
||||
}
|
||||
|
||||
$finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($finalityId === false) {
|
||||
throw new Exception('Veuillez sélectionner une finalité.');
|
||||
}
|
||||
|
||||
$titre = $this->validateRequired($this->sanitiseString($post['titre'] ?? ''), 'Titre du mémoire');
|
||||
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
|
||||
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
|
||||
|
||||
$durationInfo = $this->sanitiseString($post['duration_info'] ?? '');
|
||||
|
||||
// Jury members
|
||||
$juryMembers = [];
|
||||
if (!empty(trim($post['jury_president'] ?? ''))) {
|
||||
$juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0];
|
||||
}
|
||||
if (!empty(trim($post['jury_promoteur'] ?? ''))) {
|
||||
$juryMembers[] = [
|
||||
'name' => trim($post['jury_promoteur']),
|
||||
'role' => 'promoteur',
|
||||
'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
|
||||
$name = trim($name);
|
||||
if ($name !== '') {
|
||||
$juryMembers[] = [
|
||||
'name' => $name,
|
||||
'role' => 'lecteur',
|
||||
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Keywords (max 10)
|
||||
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
|
||||
$keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : [];
|
||||
if (count($keywords) > 10) {
|
||||
throw new Exception('Maximum 10 mots-clés autorisés.');
|
||||
}
|
||||
|
||||
// Languages (at least one required)
|
||||
$languageIds = isset($post['languages']) && is_array($post['languages'])
|
||||
? array_map('intval', $post['languages'])
|
||||
: [];
|
||||
if (empty($languageIds)) {
|
||||
throw new Exception('Veuillez sélectionner au moins une langue.');
|
||||
}
|
||||
|
||||
// Formats (optional)
|
||||
$formatIds = isset($post['formats']) && is_array($post['formats'])
|
||||
? array_map('intval', $post['formats'])
|
||||
: [];
|
||||
|
||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
|
||||
// Access type — must be one of the enabled types; default 2 (Interne)
|
||||
$accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($accessTypeId === false || $accessTypeId <= 0) {
|
||||
$accessTypeId = 2; // Interne
|
||||
}
|
||||
|
||||
// External link (optional)
|
||||
$lien = '';
|
||||
if (!empty($post['lien'])) {
|
||||
$lien = filter_var($post['lien'], FILTER_VALIDATE_URL);
|
||||
if ($lien === false) {
|
||||
throw new Exception('Lien URL invalide.');
|
||||
}
|
||||
}
|
||||
|
||||
return compact(
|
||||
'auteurName', 'mail', 'showContact', 'annee', 'orientationId', 'apProgramId',
|
||||
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
|
||||
'juryMembers', 'keywords', 'languageIds', 'formatIds',
|
||||
'licenseId', 'lien', 'accessTypeId'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private: file uploads ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process an optional cover image upload and record it in thesis_files.
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $upload Single-file $_FILES entry (may be null or have UPLOAD_ERR_NO_FILE).
|
||||
*/
|
||||
private function handleCoverUpload(int $thesisId, ?array $upload): void
|
||||
{
|
||||
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($upload['tmp_name']);
|
||||
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($mimeType, ['image/jpeg', 'image/png'], true)
|
||||
|| !in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
|
||||
error_log("ThesisCreateController: invalid cover MIME $mimeType, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
$coverDir = STORAGE_ROOT . '/covers/';
|
||||
if (!is_dir($coverDir)) {
|
||||
mkdir($coverDir, 0755, true);
|
||||
}
|
||||
|
||||
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$targetPath = $coverDir . $safeName;
|
||||
|
||||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||||
error_log("ThesisCreateController: failed to move cover to $targetPath");
|
||||
return;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
$relPath = 'covers/' . $safeName;
|
||||
|
||||
$this->db->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType);
|
||||
error_log("ThesisCreateController: cover uploaded → $safeName");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple thesis-file uploads (PDFs, images, videos, ZIPs, VTTs).
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param int $year Used for the storage sub-directory path.
|
||||
* @param string $identifier Thesis identifier slug (e.g. "2024-003").
|
||||
* @param array|null $uploads Multi-file $_FILES entry (may be null).
|
||||
*/
|
||||
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads): void
|
||||
{
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$identifier}/";
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$count = count($uploads['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
|
||||
error_log("ThesisCreateController: upload error code {$uploads['error'][$i]} for {$uploads['name'][$i]}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
|
||||
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
|
||||
|
||||
// finfo may return 'text/plain' for WebVTT on some systems.
|
||||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|
||||
|| !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
error_log("ThesisCreateController: invalid file type {$uploads['name'][$i]} ($mimeType), skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($uploads['size'][$i] > self::MAX_FILE_SIZE) {
|
||||
error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$targetPath = $uploadDir . $safeName;
|
||||
|
||||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||||
error_log("ThesisCreateController: failed to move file {$uploads['name'][$i]}");
|
||||
continue;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
|
||||
$fileType = 'other';
|
||||
if ($ext === 'vtt') {
|
||||
$fileType = 'caption';
|
||||
} elseif (stripos($uploads['name'][$i], 'annex') !== false) {
|
||||
$fileType = 'annex';
|
||||
} elseif ($ext === 'pdf') {
|
||||
$fileType = 'main';
|
||||
}
|
||||
|
||||
$relPath = "theses/{$year}/{$identifier}/" . $safeName;
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$relPath,
|
||||
basename($uploads['name'][$i]),
|
||||
$uploads['size'][$i],
|
||||
$mimeType
|
||||
);
|
||||
error_log("ThesisCreateController: file uploaded → $safeName ($fileType)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: input helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trim and strip HTML tags from a string value.
|
||||
* htmlspecialchars is applied at render time, not here.
|
||||
*/
|
||||
private function sanitiseString(string $input): string
|
||||
{
|
||||
return strip_tags(trim($input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a string value is non-empty.
|
||||
*
|
||||
* @throws Exception if $value is empty.
|
||||
*/
|
||||
private function validateRequired(string $value, string $fieldName): string
|
||||
{
|
||||
if ($value === '') {
|
||||
throw new Exception("Le champ '$fieldName' est requis.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
293
app/src/Controllers/ThesisEditController.php
Normal file
293
app/src/Controllers/ThesisEditController.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
* ThesisEditController
|
||||
*
|
||||
* Centralises all data-fetching and mutation logic for the admin thesis-edit
|
||||
* workflow (admin/edit.php + admin/actions/edit.php).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Loading thesis data and lookup tables for the edit form view
|
||||
* - Validating and persisting POST submissions (thesis metadata, authors,
|
||||
* jury, languages, formats, tags, banner)
|
||||
* - WCAG 3.3.1: mapping validation exceptions to autofocus field hints
|
||||
*
|
||||
* The class has NO output side-effects; all redirects, flash writes, and
|
||||
* template rendering stay in the thin dispatcher files so the view layer
|
||||
* remains easy to inspect and modify.
|
||||
*/
|
||||
class ThesisEditController
|
||||
{
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience factory — instantiates Database and returns a ready
|
||||
* controller. Accepts an optional existing Database instance so callers
|
||||
* that already hold one (e.g. during testing) can avoid a second
|
||||
* connection.
|
||||
*/
|
||||
public static function create(?Database $db = null): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
|
||||
return new self($db ?? Database::getInstance());
|
||||
}
|
||||
|
||||
// ── Read / view data ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load all data required to render the edit form.
|
||||
*
|
||||
* Returns a flat array of view variables:
|
||||
* - 'thesis' – thesis row (from getThesis)
|
||||
* - 'currentLanguages' – int[]
|
||||
* - 'currentFormats' – int[]
|
||||
* - 'jury' – jury rows
|
||||
* - 'orientations' – lookup rows
|
||||
* - 'apPrograms' – lookup rows
|
||||
* - 'finalityTypes' – lookup rows
|
||||
* - 'languages' – lookup rows
|
||||
* - 'formatTypes' – lookup rows
|
||||
* - 'licenseTypes' – lookup rows
|
||||
* - 'accessTypes' – lookup rows
|
||||
* - 'currentLicenseId' – int|null
|
||||
* - 'currentAccessTypeId'– int|null
|
||||
* - 'currentContextNote' – string
|
||||
* - 'pageTitle' – string
|
||||
*
|
||||
* @throws Exception if the thesis is not found or a DB error occurs.
|
||||
*/
|
||||
public function load(int $thesisId): array
|
||||
{
|
||||
if ($thesisId <= 0) {
|
||||
throw new InvalidArgumentException("ID invalide");
|
||||
}
|
||||
|
||||
$thesis = $this->db->getThesis($thesisId);
|
||||
if (!$thesis) {
|
||||
throw new RuntimeException("TFE non trouvé");
|
||||
}
|
||||
|
||||
$currentLanguages = $this->db->getThesisLanguageIds($thesisId);
|
||||
$currentFormats = $this->db->getThesisFormatIds($thesisId);
|
||||
$jury = $this->db->getThesisJury($thesisId);
|
||||
|
||||
$orientations = $this->db->getAllOrientations();
|
||||
$apPrograms = $this->db->getAllAPPrograms();
|
||||
$finalityTypes = $this->db->getAllFinalityTypes();
|
||||
$languages = $this->db->getAllLanguages();
|
||||
$formatTypes = $this->db->getAllFormatTypes();
|
||||
$licenseTypes = $this->db->getAllLicenseTypes();
|
||||
$accessTypes = $this->db->getAccessTypes();
|
||||
|
||||
$rawRow = $this->db->getThesisRawFields($thesisId);
|
||||
$currentLicenseId = $rawRow['license_id'] ?? null;
|
||||
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
|
||||
$currentContextNote = $rawRow['context_note'] ?? '';
|
||||
|
||||
// Author contact info (from view)
|
||||
$currentAuthorEmail = $thesis['author_email'] ?? '';
|
||||
$currentAuthorShowContact = (bool)($thesis['author_show_contact'] ?? false);
|
||||
|
||||
return [
|
||||
'thesis' => $thesis,
|
||||
'currentLanguages' => $currentLanguages,
|
||||
'currentFormats' => $currentFormats,
|
||||
'jury' => $jury,
|
||||
'orientations' => $orientations,
|
||||
'apPrograms' => $apPrograms,
|
||||
'finalityTypes' => $finalityTypes,
|
||||
'languages' => $languages,
|
||||
'formatTypes' => $formatTypes,
|
||||
'licenseTypes' => $licenseTypes,
|
||||
'accessTypes' => $accessTypes,
|
||||
'currentLicenseId' => $currentLicenseId,
|
||||
'currentAccessTypeId' => $currentAccessTypeId,
|
||||
'currentContextNote' => $currentContextNote,
|
||||
'currentAuthorEmail' => $currentAuthorEmail,
|
||||
'currentAuthorShowContact' => $currentAuthorShowContact,
|
||||
'pageTitle' => 'Éditer TFE - ' . htmlspecialchars($thesis['title']),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Write / action ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and persist a thesis-edit POST submission.
|
||||
*
|
||||
* Runs the full update inside a transaction:
|
||||
* 1. Thesis metadata (title, subtitle, year, orientation, ap, finality,
|
||||
* synopsis, context_note, file_size_info, baiu_link, license_id,
|
||||
* access_type_id, is_published)
|
||||
* 2. Authors (setThesisAuthors)
|
||||
* 3. Jury (setThesisJury)
|
||||
* 4. Languages (setThesisLanguages)
|
||||
* 5. Formats (setThesisFormats)
|
||||
* 6. Tags (setThesisTags)
|
||||
* Then handles banner upload/removal outside the transaction.
|
||||
*
|
||||
* @param int $thesisId Validated thesis ID (> 0).
|
||||
* @param array $post Sanitised $_POST array.
|
||||
* @param array $files $_FILES array (expects 'banner' key).
|
||||
*
|
||||
* @throws Exception on validation or DB error (caller must rollback if
|
||||
* the transaction is still open, but this method rolls
|
||||
* back internally before re-throwing).
|
||||
*/
|
||||
public function save(int $thesisId, array $post, array $files): void
|
||||
{
|
||||
if ($thesisId <= 0) {
|
||||
throw new InvalidArgumentException("ID de TFE invalide.");
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
// ── 1. Thesis metadata ────────────────────────────────────────────
|
||||
$this->db->updateThesis($thesisId, [
|
||||
'title' => trim($post['titre'] ?? ''),
|
||||
'subtitle' => trim($post['subtitle'] ?? ''),
|
||||
'year' => intval($post['année'] ?? 0),
|
||||
'orientation_id' => intval($post['orientation'] ?? 0),
|
||||
'ap_program_id' => intval($post['ap'] ?? 0),
|
||||
'finality_id' => intval($post['finality'] ?? 0),
|
||||
'synopsis' => trim($post['synopsis'] ?? ''),
|
||||
'context_note' => trim($post['context_note'] ?? ''),
|
||||
'file_size_info' => trim($post['duration_info'] ?? ''),
|
||||
'baiu_link' => trim($post['lien'] ?? ''),
|
||||
'license_id' => filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
|
||||
'access_type_id' => filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
|
||||
'is_published' => isset($post['is_published']),
|
||||
]);
|
||||
|
||||
// ── 2. Authors ────────────────────────────────────────────────────
|
||||
$authorsRaw = trim($post['auteurice'] ?? '');
|
||||
$showContact = !empty($post['contact_public']);
|
||||
$authorEntries = [];
|
||||
if ($authorsRaw !== '') {
|
||||
foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) {
|
||||
if ($name !== '') {
|
||||
$authorEntries[] = [
|
||||
'name' => $name,
|
||||
'email' => $i === 0 ? ($post['mail'] ?? null) : null,
|
||||
'show_contact' => $i === 0 ? $showContact : false,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||
|
||||
// ── 3. Jury ───────────────────────────────────────────────────────
|
||||
$juryMembers = $this->collectJuryMembers($post);
|
||||
$this->db->setThesisJury($thesisId, $juryMembers);
|
||||
|
||||
// ── 4. Languages ──────────────────────────────────────────────────
|
||||
$this->db->setThesisLanguages(
|
||||
$thesisId,
|
||||
isset($post['languages']) && is_array($post['languages'])
|
||||
? $post['languages']
|
||||
: []
|
||||
);
|
||||
|
||||
// ── 5. Formats ────────────────────────────────────────────────────
|
||||
$this->db->setThesisFormats(
|
||||
$thesisId,
|
||||
isset($post['formats']) && is_array($post['formats'])
|
||||
? $post['formats']
|
||||
: []
|
||||
);
|
||||
|
||||
// ── 6. Tags ───────────────────────────────────────────────────────
|
||||
$keywordsRaw = trim($post['tag'] ?? '');
|
||||
$keywords = $keywordsRaw !== ''
|
||||
? array_map('trim', explode(',', $keywordsRaw))
|
||||
: [];
|
||||
$this->db->setThesisTags($thesisId, $keywords);
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// ── Banner (outside transaction — filesystem op) ──────────────────────
|
||||
if (isset($post['remove_banner'])) {
|
||||
$currentBannerPath = $this->db->getThesisBannerPath($thesisId);
|
||||
if ($currentBannerPath && defined('STORAGE_ROOT')) {
|
||||
$absPath = STORAGE_ROOT . '/' . $currentBannerPath;
|
||||
if (file_exists($absPath)) {
|
||||
unlink($absPath);
|
||||
}
|
||||
}
|
||||
$this->db->setBannerPath($thesisId, null);
|
||||
} else {
|
||||
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a validation exception message to the name of the field that should
|
||||
* receive autofocus when the form is re-rendered.
|
||||
*
|
||||
* Returns null when no field mapping is found.
|
||||
*/
|
||||
public static function autofocusFieldForError(string $message): ?string
|
||||
{
|
||||
if (str_contains($message, 'titre') || str_contains($message, 'Titre')) return 'titre';
|
||||
if (str_contains($message, 'année') || str_contains($message, 'Année')) return 'année';
|
||||
if (str_contains($message, 'synopsis') || str_contains($message, 'Synopsis')) return 'synopsis';
|
||||
if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) return 'auteurice';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the jury-members array from POST data.
|
||||
*
|
||||
* @param array $post Raw $_POST.
|
||||
* @return array<int, array{name: string, role: string, is_external: int}>
|
||||
*/
|
||||
private function collectJuryMembers(array $post): array
|
||||
{
|
||||
$members = [];
|
||||
|
||||
if (!empty(trim($post['jury_president'] ?? ''))) {
|
||||
$members[] = [
|
||||
'name' => trim($post['jury_president']),
|
||||
'role' => 'president',
|
||||
'is_external' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty(trim($post['jury_promoteur'] ?? ''))) {
|
||||
$members[] = [
|
||||
'name' => trim($post['jury_promoteur']),
|
||||
'role' => 'promoteur',
|
||||
'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
|
||||
$name = trim($name);
|
||||
if ($name !== '') {
|
||||
$members[] = [
|
||||
'name' => $name,
|
||||
'role' => 'lecteur',
|
||||
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $members;
|
||||
}
|
||||
}
|
||||
1848
app/src/Database.php
Normal file
1848
app/src/Database.php
Normal file
File diff suppressed because it is too large
Load Diff
151
app/src/Dispatcher.php
Normal file
151
app/src/Dispatcher.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
/**
|
||||
* Front-controller Dispatcher
|
||||
*
|
||||
* Routes all public-page requests through a single entry point.
|
||||
* Admin panel (/admin/*) and static assets bypass the dispatcher.
|
||||
*
|
||||
* Routes:
|
||||
* / → HomeController → home view
|
||||
* /search.php → SearchController → search view
|
||||
* /repertoire → SearchController → repertoire view
|
||||
* /tfe/<id> → TfeController → tfe view
|
||||
* /apropos → AboutController → about view
|
||||
* /licence → LicenceController → licence view
|
||||
* /media.php → MediaController (direct output)
|
||||
* /live-reload → LiveReloadController (direct output)
|
||||
* /partage/<slug> → share-link flow
|
||||
* /maintenance.php → static maintenance page
|
||||
*/
|
||||
class Dispatcher {
|
||||
private const ROUTES = [
|
||||
'' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
||||
'/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
||||
'/index.php' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
||||
'/search.php' => ['controller' => 'SearchController', 'action' => 'handle', 'view' => 'public/search'],
|
||||
'/repertoire' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'],
|
||||
'/repertoire.php' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'],
|
||||
'/tfe.php' => ['controller' => 'TfeController', 'action' => 'handle', 'view' => 'public/tfe'],
|
||||
'/apropos' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
|
||||
'/apropos.php' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
|
||||
'/licence' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
|
||||
'/licence.php' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
|
||||
];
|
||||
|
||||
private string $path;
|
||||
private array $queryParams;
|
||||
|
||||
public function __construct() {
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$this->path = $uri;
|
||||
$this->queryParams = $_GET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the URI to a route, instantiate the controller,
|
||||
* execute the action, and render the view.
|
||||
*/
|
||||
public function dispatch(): void {
|
||||
// 1. Direct-response endpoints (render their own output)
|
||||
$direct = $this->matchDirect();
|
||||
if ($direct) {
|
||||
$direct();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Routed pages (controller + view)
|
||||
$route = $this->matchRoute();
|
||||
if (!$route) {
|
||||
http_response_code(404);
|
||||
echo '<h1>404 — Page non trouvée</h1>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Load controller
|
||||
$ctrlClass = $route['controller'];
|
||||
require_once APP_ROOT . '/src/Controllers/' . $ctrlClass . '.php';
|
||||
|
||||
$controller = $ctrlClass::create();
|
||||
$vars = $controller->{$route['action']}();
|
||||
|
||||
// 4. Render view
|
||||
$this->render($route['view'], $vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match endpoints that render their own response (no view layer).
|
||||
*/
|
||||
private function matchDirect(): ?callable {
|
||||
$path = $this->path;
|
||||
|
||||
// /live-reload
|
||||
if ($path === '/live-reload' || $path === '/live-reload.php') {
|
||||
return function() {
|
||||
require_once APP_ROOT . '/src/Controllers/LiveReloadController.php';
|
||||
$controller = new LiveReloadController(APP_ROOT);
|
||||
$result = $controller->handle();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result['body']);
|
||||
};
|
||||
}
|
||||
|
||||
// /media.php
|
||||
if ($path === '/media' || $path === '/media.php') {
|
||||
return function() {
|
||||
require_once APP_ROOT . '/src/Controllers/MediaController.php';
|
||||
$controller = new MediaController();
|
||||
$controller->handle();
|
||||
};
|
||||
}
|
||||
|
||||
// /maintenance.php
|
||||
if ($path === '/maintenance' || $path === '/maintenance.php') {
|
||||
return function() {
|
||||
require APP_ROOT . '/public/maintenance.php';
|
||||
};
|
||||
}
|
||||
|
||||
// /partage/*
|
||||
if (preg_match('#^/partage(/.*)?$#', $path)) {
|
||||
return function() {
|
||||
require APP_ROOT . '/public/partage/index.php';
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the current path against the static route table.
|
||||
* Supports exact match and prefix-based (for /tfe?id=).
|
||||
*/
|
||||
private function matchRoute(): ?array {
|
||||
$path = $this->path;
|
||||
|
||||
// Exact match first
|
||||
if (isset(self::ROUTES[$path])) {
|
||||
return self::ROUTES[$path];
|
||||
}
|
||||
|
||||
// /tfe?id= pattern (TFeController handles the id param internally)
|
||||
if (preg_match('#^/tfe$#', $path) && isset($_GET['id'])) {
|
||||
return self::ROUTES['/tfe.php'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a view template, passing controller data through extract().
|
||||
*/
|
||||
private function render(string $view, array $vars): void {
|
||||
$viewPath = APP_ROOT . '/templates/' . $view . '.php';
|
||||
if (!file_exists($viewPath)) {
|
||||
http_response_code(500);
|
||||
echo "View not found: {$viewPath}";
|
||||
return;
|
||||
}
|
||||
extract($vars);
|
||||
include $viewPath;
|
||||
}
|
||||
}
|
||||
1995
app/src/Parsedown.php
Normal file
1995
app/src/Parsedown.php
Normal file
File diff suppressed because it is too large
Load Diff
193
app/src/RateLimit.php
Normal file
193
app/src/RateLimit.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Simple file-based rate limiter
|
||||
* Prevents abuse by limiting requests per IP address
|
||||
*/
|
||||
class RateLimit {
|
||||
private $cacheDir;
|
||||
private $maxRequests;
|
||||
private $timeWindow;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param int $maxRequests Maximum requests allowed in time window
|
||||
* @param int $timeWindow Time window in seconds
|
||||
* @param string $cacheDir Directory to store rate limit data
|
||||
*/
|
||||
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null) {
|
||||
$this->maxRequests = $maxRequests;
|
||||
$this->timeWindow = $timeWindow;
|
||||
$this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit';
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
@mkdir($this->cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier (IP address)
|
||||
* @return string Client identifier
|
||||
*/
|
||||
private function getClientIdentifier(): string {
|
||||
// Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP
|
||||
// are fully attacker-controlled request headers and must never be
|
||||
// trusted for rate-limiting purposes (an attacker can rotate them
|
||||
// freely to bypass the limiter). Nginx-level rate limiting also
|
||||
// uses $binary_remote_addr for the same reason. If this app is
|
||||
// ever placed behind a trusted reverse-proxy, IP extraction should
|
||||
// be handled at the nginx level, not here.
|
||||
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache file path for a client
|
||||
* @param string $identifier Client identifier
|
||||
* @return string File path
|
||||
*/
|
||||
private function getCacheFile($identifier) {
|
||||
return $this->cacheDir . '/' . md5($identifier) . '.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and record a hit for an arbitrary string key.
|
||||
* Useful when the caller wants a compound key (e.g. slug + IP).
|
||||
*
|
||||
* @return bool True if allowed, false if rate limit exceeded
|
||||
*/
|
||||
public function checkKey(string $key): bool {
|
||||
$file = $this->getCacheFile($key);
|
||||
|
||||
$data = [];
|
||||
if (file_exists($file)) {
|
||||
$data = json_decode(file_get_contents($file), true) ?? [];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$data = array_values(array_filter($data, fn($ts) => ($now - $ts) < $this->timeWindow));
|
||||
|
||||
if (count($data) >= $this->maxRequests) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data[] = $now;
|
||||
if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) {
|
||||
file_put_contents($file, json_encode($data));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client has exceeded rate limit
|
||||
* @return bool True if allowed, false if rate limit exceeded
|
||||
*/
|
||||
public function check() {
|
||||
$identifier = $this->getClientIdentifier();
|
||||
$file = $this->getCacheFile($identifier);
|
||||
|
||||
// Load existing request timestamps
|
||||
$data = [];
|
||||
if (file_exists($file)) {
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true) ?? [];
|
||||
}
|
||||
|
||||
// Clean old entries outside time window
|
||||
$now = time();
|
||||
$data = array_filter($data, function($timestamp) use ($now) {
|
||||
return ($now - $timestamp) < $this->timeWindow;
|
||||
});
|
||||
|
||||
// Check if limit exceeded
|
||||
if (count($data) >= $this->maxRequests) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add new request timestamp
|
||||
$data[] = $now;
|
||||
|
||||
// Save updated data (silently skip if directory is not writable)
|
||||
if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) {
|
||||
file_put_contents($file, json_encode($data));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining requests for current client
|
||||
* @return int Number of requests remaining
|
||||
*/
|
||||
public function getRemaining() {
|
||||
$identifier = $this->getClientIdentifier();
|
||||
$file = $this->getCacheFile($identifier);
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return $this->maxRequests;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true) ?? [];
|
||||
|
||||
// Clean old entries
|
||||
$now = time();
|
||||
$data = array_filter($data, function($timestamp) use ($now) {
|
||||
return ($now - $timestamp) < $this->timeWindow;
|
||||
});
|
||||
|
||||
return max(0, $this->maxRequests - count($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until rate limit resets
|
||||
* @return int Seconds until reset
|
||||
*/
|
||||
public function getResetTime() {
|
||||
$identifier = $this->getClientIdentifier();
|
||||
$file = $this->getCacheFile($identifier);
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true) ?? [];
|
||||
|
||||
if (empty($data)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find oldest timestamp
|
||||
$oldest = min($data);
|
||||
$resetTime = $oldest + $this->timeWindow - time();
|
||||
|
||||
return max(0, $resetTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache files (run periodically)
|
||||
* Removes files that haven't been modified in 24 hours
|
||||
*/
|
||||
public function cleanup() {
|
||||
$files = glob($this->cacheDir . '/*.json');
|
||||
$cutoff = time() - 86400; // 24 hours
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) < $cutoff) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send rate limit headers
|
||||
* Provides information about rate limits to clients
|
||||
*/
|
||||
public function sendHeaders() {
|
||||
header('X-RateLimit-Limit: ' . $this->maxRequests);
|
||||
header('X-RateLimit-Remaining: ' . $this->getRemaining());
|
||||
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));
|
||||
}
|
||||
}
|
||||
201
app/src/ShareLink.php
Normal file
201
app/src/ShareLink.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
/**
|
||||
* ShareLink — model for student-access share links.
|
||||
*
|
||||
* Share links enable students to submit TFEs via unique URLs without
|
||||
* requiring admin authentication. Each link has a unique slug, optional
|
||||
* password, activity flag, optional expiration, and usage count.
|
||||
*/
|
||||
class ShareLink
|
||||
{
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public static function make(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
return new self(new Database());
|
||||
}
|
||||
|
||||
// ── Slug generation ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a unique slug in the format YYYYMMDD-<random>.
|
||||
* The random portion uses 8 base32 chars (~40 bits of entropy).
|
||||
*/
|
||||
public static function generateSlug(): string
|
||||
{
|
||||
$date = date('Ymd');
|
||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$random = '';
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$random .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
return $date . '-' . $random;
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new share link.
|
||||
*
|
||||
* @param int $createdBy Admin user ID
|
||||
* @param string|null $password Plain-text password (will be hashed), null = no password
|
||||
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
|
||||
* @return array The created link row
|
||||
*/
|
||||
public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null): array
|
||||
{
|
||||
$slug = self::generateSlug();
|
||||
$passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
|
||||
|
||||
$stmt = $this->db->getConnection()->prepare(
|
||||
"INSERT INTO share_links (slug, password_hash, is_active, created_by, expires_at)
|
||||
VALUES (?, ?, 1, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$slug, $passwordHash, $createdBy, $expiresAt]);
|
||||
|
||||
return $this->findBySlug($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a share link by its slug.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function findBySlug(string $slug): ?array
|
||||
{
|
||||
$stmt = $this->db->getConnection()->prepare(
|
||||
"SELECT * FROM share_links WHERE slug = ?"
|
||||
);
|
||||
$stmt->execute([$slug]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a share link by its ID.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->getConnection()->prepare(
|
||||
"SELECT * FROM share_links WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all share links, ordered by creation date descending.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function listAll(): array
|
||||
{
|
||||
$stmt = $this->db->getConnection()->query(
|
||||
"SELECT * FROM share_links ORDER BY created_at DESC"
|
||||
);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active status of a share link.
|
||||
*/
|
||||
public function toggleActive(int $id): void
|
||||
{
|
||||
$this->db->getConnection()->prepare(
|
||||
"UPDATE share_links SET is_active = NOT is_active WHERE id = ?"
|
||||
)->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the password for a share link.
|
||||
*
|
||||
* @param string|null $password Plain-text password, or null to clear
|
||||
*/
|
||||
public function setPassword(int $id, ?string $password): void
|
||||
{
|
||||
$hash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
|
||||
$this->db->getConnection()->prepare(
|
||||
"UPDATE share_links SET password_hash = ? WHERE id = ?"
|
||||
)->execute([$hash, $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a share link.
|
||||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->db->getConnection()->prepare(
|
||||
"DELETE FROM share_links WHERE id = ?"
|
||||
)->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the usage count for a share link.
|
||||
*/
|
||||
public function incrementUsage(int $id): void
|
||||
{
|
||||
$this->db->getConnection()->prepare(
|
||||
"UPDATE share_links SET usage_count = usage_count + 1 WHERE id = ?"
|
||||
)->execute([$id]);
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate whether a share link is usable.
|
||||
*
|
||||
* Returns an array:
|
||||
* ['valid' => true] if the link is active and not expired
|
||||
* ['valid' => false, 'reason' => 'disabled'] if deactivated
|
||||
* ['valid' => false, 'reason' => 'expired'] if past expiration
|
||||
* ['valid' => false, 'reason' => 'not_found'] if slug doesn't exist
|
||||
* ['valid' => false, 'reason' => 'needs_password', 'link' => array] if password required
|
||||
*/
|
||||
public function validateLink(?string $slug): array
|
||||
{
|
||||
if ($slug === null || $slug === '') {
|
||||
return ['valid' => false, 'reason' => 'not_found'];
|
||||
}
|
||||
|
||||
$link = $this->findBySlug($slug);
|
||||
if ($link === null) {
|
||||
return ['valid' => false, 'reason' => 'not_found'];
|
||||
}
|
||||
|
||||
if (!$link['is_active']) {
|
||||
return ['valid' => false, 'reason' => 'disabled', 'link' => $link];
|
||||
}
|
||||
|
||||
if ($link['expires_at'] !== null && strtotime($link['expires_at']) < time()) {
|
||||
return ['valid' => false, 'reason' => 'expired', 'link' => $link];
|
||||
}
|
||||
|
||||
if ($link['password_hash'] !== null) {
|
||||
return ['valid' => false, 'reason' => 'needs_password', 'link' => $link];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'link' => $link];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the password against a share link.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyPassword(array $link, string $password): bool
|
||||
{
|
||||
if ($link['password_hash'] === null) {
|
||||
return true; // No password set
|
||||
}
|
||||
return password_verify($password, $link['password_hash']);
|
||||
}
|
||||
}
|
||||
181
app/src/SmtpRelay.php
Normal file
181
app/src/SmtpRelay.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SMTP Relay — credentials stored in the DB, sending via PHP's built-in mail
|
||||
* wrappers (SMTP transport layer is wired later).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. CRUD on the singleton smtp_settings row.
|
||||
* 2. Build MIME messages.
|
||||
* 3. Send via `mail()` now; swap transport later (e.g. PHPMailer / Symfony Mailer).
|
||||
*/
|
||||
class SmtpRelay {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DB operations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch current SMTP settings from the DB.
|
||||
*
|
||||
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string}
|
||||
*/
|
||||
public static function getSettings(Database $db): array {
|
||||
$stmt = $db->getPDO()->query(
|
||||
"SELECT host, port, encryption, username, password, from_email, from_name
|
||||
FROM v_smtp_active LIMIT 1"
|
||||
);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row ?: [
|
||||
'host' => '',
|
||||
'port' => 587,
|
||||
'encryption' => 'tls',
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'from_email' => '',
|
||||
'from_name' => 'Post-ERG',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert SMTP settings.
|
||||
*
|
||||
* @param array $data Associative array with keys: host, port, encryption,
|
||||
* username, password, from_email, from_name.
|
||||
* Keys not present are left unchanged.
|
||||
*/
|
||||
public static function updateSettings(Database $db, array $data): void {
|
||||
// Read existing so we can merge partial updates
|
||||
$current = self::getSettings($db);
|
||||
$merged = array_merge($current, $data);
|
||||
|
||||
// Sanitize
|
||||
$port = max(1, min(65535, (int)$merged['port']));
|
||||
$encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
|
||||
? $merged['encryption'] : 'tls';
|
||||
|
||||
$stmt = $db->getPDO()->prepare(
|
||||
"UPDATE smtp_settings
|
||||
SET host = :host,
|
||||
port = :port,
|
||||
encryption = :encryption,
|
||||
username = :username,
|
||||
password = :password,
|
||||
from_email = :from_email,
|
||||
from_name = :from_name,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1"
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':host' => trim($merged['host']),
|
||||
':port' => $port,
|
||||
':encryption' => $encryption,
|
||||
':username' => trim($merged['username']),
|
||||
':password' => $merged['password'], // keep as-is
|
||||
':from_email' => trim($merged['from_email']),
|
||||
':from_name' => trim($merged['from_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the SMTP relay is fully configured.
|
||||
*/
|
||||
public static function isConfigured(Database $db): bool {
|
||||
$s = self::getSettings($db);
|
||||
return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send helpers (transport wired later — stub implementation now)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send an e-mail using the stored SMTP credentials.
|
||||
*
|
||||
* Currently uses PHP's `mail()` as a passthrough so the rest of the
|
||||
* application can call `SmtpRelay::send(…)` everywhere.
|
||||
* The actual SMTP transport layer will be wired in a later iteration
|
||||
* (e.g. replace this body with PHPMailer / Symfony Mailer).
|
||||
*
|
||||
* @param string $to Recipient e-mail address
|
||||
* @param string $subject Subject line
|
||||
* @param string $body HTML body
|
||||
* @param string $plain Plain-text alternative (optional)
|
||||
* @return bool True on send request acceptance; false on failure
|
||||
*/
|
||||
public static function send(
|
||||
Database $db,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $body,
|
||||
string $plain = ''
|
||||
): bool {
|
||||
$settings = self::getSettings($db);
|
||||
if ($settings['from_email'] === '') {
|
||||
error_log('[SmtpRelay] send() aborted — no from_email configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build MIME multipart headers
|
||||
$boundary = 'posterg_' . md5((string) random_int(0, PHP_INT_MAX) . microtime(true));
|
||||
$headers = "From: {$settings['from_name']} <{$settings['from_email']}>\r\n";
|
||||
$headers .= "Reply-To: {$settings['from_email']}\r\n";
|
||||
$headers .= "MIME-Version: 1.0\r\n";
|
||||
|
||||
if ($plain !== '') {
|
||||
$headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n";
|
||||
$message = "--{$boundary}\r\n";
|
||||
$message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
|
||||
$message .= self::htmlToPlain($body) . "\r\n\r\n";
|
||||
$message .= "--{$boundary}\r\n";
|
||||
$message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
|
||||
$message .= $body . "\r\n\r\n";
|
||||
$message .= "--{$boundary}--";
|
||||
} else {
|
||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$message = $body;
|
||||
}
|
||||
|
||||
// TODO: replace with real SMTP transport (PHPMailer / Symfony Mailer)
|
||||
// The stored credentials ($settings) will be passed to the mailer then.
|
||||
$ok = mail($to, $subject, $message, $headers);
|
||||
|
||||
if (!$ok) {
|
||||
error_log("[SmtpRelay] mail() returned false for {$to}");
|
||||
}
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue (persist) an e-mail for deferred sending.
|
||||
*
|
||||
* Stub — will create a `mail_queue` table in a future migration.
|
||||
*/
|
||||
public static function queue(
|
||||
Database $db,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $body,
|
||||
string $plain = ''
|
||||
): void {
|
||||
// TODO: INSERT INTO mail_queue …
|
||||
// Placeholder so callers exist now and wire up later.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Strip HTML tags to produce a rough plain-text fallback.
|
||||
*/
|
||||
private static function htmlToPlain(string $html): string {
|
||||
$text = strip_tags($html);
|
||||
// Collapse multiple whitespace lines
|
||||
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
||||
return trim($text);
|
||||
}
|
||||
}
|
||||
111
app/src/SystemCache.php
Normal file
111
app/src/SystemCache.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SystemCache — thin TTL cache for admin system page checks.
|
||||
*
|
||||
* Stores JSON-encoded data blobs in the `system_cache` SQLite table.
|
||||
* The table has a single schema:
|
||||
* key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||
*
|
||||
* Usage:
|
||||
* $cache = new SystemCache($pdo);
|
||||
*
|
||||
* // Read (returns array or null if stale/missing)
|
||||
* $data = $cache->get('system_status', 120);
|
||||
*
|
||||
* // Write
|
||||
* $cache->set('system_status', $myArray);
|
||||
*
|
||||
* // Check freshness without reading value
|
||||
* if ($cache->isStale('disk_info', 300)) { ... }
|
||||
*
|
||||
* // Force-invalidate a key (e.g. on ?refresh=1)
|
||||
* $cache->invalidate('system_status');
|
||||
*/
|
||||
class SystemCache
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return cached data for $key if it is no older than $maxAgeSec seconds.
|
||||
* Returns null when the entry is missing or stale.
|
||||
*/
|
||||
public function get(string $key, int $maxAgeSec = 60): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT value, updated_at FROM system_cache WHERE key = ?'
|
||||
);
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((time() - (int)$row['updated_at']) > $maxAgeSec) {
|
||||
return null; // stale
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$row['value'], true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert $data (JSON-encoded) for $key with current timestamp.
|
||||
*/
|
||||
public function set(string $key, array $data): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO system_cache (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at'
|
||||
);
|
||||
$stmt->execute([$key, json_encode($data), time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the entry is missing or older than $maxAgeSec.
|
||||
*/
|
||||
public function isStale(string $key, int $maxAgeSec = 60): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT updated_at FROM system_cache WHERE key = ?'
|
||||
);
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (time() - (int)$row['updated_at']) > $maxAgeSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the age of the cached entry in seconds, or null if missing.
|
||||
*/
|
||||
public function ageSeconds(string $key): ?int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT updated_at FROM system_cache WHERE key = ?'
|
||||
);
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? (time() - (int)$row['updated_at']) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached entry, forcing the next get() to re-compute.
|
||||
*/
|
||||
public function invalidate(string $key): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('DELETE FROM system_cache WHERE key = ?');
|
||||
$stmt->execute([$key]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user