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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user