feat: prevent duplicate TFE submissions with logging and user feedback

- Add DuplicateThesisException (typed, carries existing thesis metadata)
- Add Database::findDuplicateThesis(): matches on year + author + normalised
  title (exact, prefix, Levenshtein ≤10% of longer string)
- ThesisCreateController::submit() runs duplicate check before any DB write
  and throws DuplicateThesisException on match
- AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines
  log for audit purposes
- App::flash/consumeFlash extended to support 'warning' flash type
- admin/actions/formulaire.php: catches DuplicateThesisException, logs it,
  flashes an HTML warning toast with a clickable link to the existing thesis,
  and repopulates the form fields
- partage/index.php: same catch block; surfaces a plain-text flash-warning
  banner on the student form with identifier, title, and year of the match;
  form is repopulated via session
- toast.php: renders toast--warning variant
- admin.css: .toast--warning + link colour rules
- form.css: .flash-warning style for the partage form
This commit is contained in:
Pontoporeia
2026-05-04 16:29:31 +02:00
parent 0a05f3911c
commit a2cba6d3c0
35 changed files with 1726 additions and 1302 deletions

View File

@@ -1,13 +1,19 @@
<?php
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
class AboutController {
class AboutController
{
private string $defaultContent = "Ce site XAMXAM 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 static function create(): self
{
return new self();
}
public function handle(): array {
public function handle(): array
{
try {
$db = Database::getInstance();
$aboutPage = $db->getPage('about');
@@ -18,9 +24,9 @@ class AboutController {
$contacts = $db->getAproposContent('contacts');
$credits = $db->getAproposContent('credits');
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
$credits = is_array($credits) && !empty($credits) ? $credits : null;
$credits = is_array($credits) && !empty($credits) ? $credits : null;
} catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage());
error_log('Error loading about page: ' . $e->getMessage());
$rawContent = $this->defaultContent;
$contacts = null;
$credits = null;

View File

@@ -1,4 +1,5 @@
<?php
/**
* ExportController
*

View File

@@ -1,4 +1,5 @@
<?php
/**
* FileAccessController
*

View File

@@ -1,4 +1,5 @@
<?php
/**
* HomeController
*
@@ -35,7 +36,7 @@ class HomeController
*/
public static function create(): self
{
require_once APP_ROOT . "/src/Database.php";
require_once APP_ROOT . '/src/Database.php';
return new self(Database::getInstance());
}
@@ -49,8 +50,8 @@ class HomeController
*/
public function handle(): array
{
$page = isset($_GET["page"]) ? max(1, (int) $_GET["page"]) : 1;
$year = isset($_GET["year"]) ? (int) $_GET["year"] : null;
$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;
@@ -71,11 +72,11 @@ class HomeController
if ($year !== null) {
$itemsToLoad = $this->db->searchTheses(
["year" => $year],
['year' => $year],
self::ITEMS_PER_PAGE,
$offset,
);
$totalItems = $this->db->countSearchResults(["year" => $year]);
$totalItems = $this->db->countSearchResults(['year' => $year]);
} elseif ($isDefaultView) {
$latestYear = $this->db->getLatestPublishedYear();
$itemsToLoad = $this->db->getLatestYearTheses(
@@ -95,16 +96,16 @@ class HomeController
$needCover = array_column(
array_filter(
$itemsToLoad,
static fn($t) => empty($t["banner_path"]),
static fn ($t) => empty($t['banner_path']),
),
"id",
'id',
);
if (!empty($needCover)) {
$coverMap = $this->db->getCoverPathsForTheses($needCover);
}
}
} catch (Exception $e) {
error_log("HomeController: " . $e->getMessage());
error_log('HomeController: ' . $e->getMessage());
// Return safe empty state; view will show "Aucun mémoire trouvé"
$isDefaultView = false;
}
@@ -117,40 +118,40 @@ class HomeController
$totalPages = 0;
}
$baseParams = array_filter(["year" => $year]);
$baseParams = array_filter(['year' => $year]);
return [
// Pagination / filter state
"page" => $page,
"year" => $year,
"isDefaultView" => $isDefaultView,
"totalItems" => $totalItems,
"totalPages" => $totalPages,
"baseParams" => $baseParams,
'page' => $page,
'year' => $year,
'isDefaultView' => $isDefaultView,
'totalItems' => $totalItems,
'totalPages' => $totalPages,
'baseParams' => $baseParams,
// Thesis data
"itemsToLoad" => $itemsToLoad,
"latestYear" => $latestYear,
"availableYears" => $availableYears,
"coverMap" => $coverMap,
'itemsToLoad' => $itemsToLoad,
'latestYear' => $latestYear,
'availableYears' => $availableYears,
'coverMap' => $coverMap,
// Page meta
"pageTitle" => 'XAMXAM Mémoires de l\'ERG',
"metaDescription" =>
'pageTitle' => 'XAMXAM Mémoires de l\'ERG',
'metaDescription' =>
'XAMXAM 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" => 'XAMXAM Mémoires de l\'ERG',
"description" =>
'ogTags' => [
'type' => 'website',
'title' => 'XAMXAM Mémoires de l\'ERG',
'description' =>
'XAMXAM répertorie et valorise les mémoires de fin d\'études (TFE) de l\'erg École de Recherches Graphiques de Bruxelles.',
"url" => "https://xamxam.erg.be/",
"site_name" => "XAMXAM ERG",
'url' => 'https://xamxam.erg.be/',
'site_name' => 'XAMXAM ERG',
],
// Layout
"currentNav" => "",
"extraCss" => ["/assets/css/public.css"],
"bodyClass" => "home-body",
'currentNav' => '',
'extraCss' => ['/assets/css/public.css'],
'bodyClass' => 'home-body',
];
}
}

View File

@@ -1,20 +1,24 @@
<?php
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
class LicenceController {
public static function create(): self {
class LicenceController
{
public static function create(): self
{
return new self();
}
public function handle(): array {
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());
error_log('Error loading licence page: ' . $e->getMessage());
$content = '';
$pageTitle = 'Licences';
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Live-reload endpoint for PHP built-in development server.
* Polls file mtimes across source directories and returns
@@ -6,12 +7,14 @@
*
* Usage (from browser): /live-reload
*/
class LiveReloadController {
class LiveReloadController
{
private array $watchDirs;
private array $watchExts = ['php', 'css', 'js', 'html'];
private string $stateFile;
public function __construct(string $appRoot) {
public function __construct(string $appRoot)
{
$this->watchDirs = [
$appRoot . '/public',
$appRoot . '/src',
@@ -21,14 +24,18 @@ class LiveReloadController {
$this->stateFile = sys_get_temp_dir() . '/xamxam-live-reload.txt';
}
public function handle(): array {
public function handle(): array
{
return ['json' => true, 'body' => $this->poll()];
}
private function poll(): array {
private function poll(): array
{
$hash = '';
foreach ($this->watchDirs as $dir) {
if (!is_dir($dir)) continue;
if (!is_dir($dir)) {
continue;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);

View File

@@ -1,4 +1,5 @@
<?php
/**
* MediaController
*
@@ -60,7 +61,7 @@ class MediaController
exit;
}
} catch (\Throwable $e) {
error_log("MediaController visibility check error: " . $e->getMessage());
error_log('MediaController visibility check error: ' . $e->getMessage());
}
}
@@ -119,7 +120,9 @@ class MediaController
header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
header('Cache-Control: public, max-age=604800');
if (!$forceDownload) header('Content-Disposition: inline');
if (!$forceDownload) {
header('Content-Disposition: inline');
}
} elseif ($ext === 'pdf') {
header('Cache-Control: public, max-age=86400');
header('Content-Disposition: ' . ($forceDownload ? 'attachment' : 'inline'));
@@ -163,10 +166,15 @@ class MediaController
}
[, $range] = explode('=', $range, 2);
[$start, $end] = array_pad(explode('-', $range, 2), 2, '');
$start = ($start === '') ? 0 : (int)$start;
$end = ($end === '') ? $size - 1 : (int)$end;
if ($end >= $size) $end = $size - 1;
if ($start > $end) { http_response_code(416); exit; }
$start = ($start === '') ? 0 : (int)$start;
$end = ($end === '') ? $size - 1 : (int)$end;
if ($end >= $size) {
$end = $size - 1;
}
if ($start > $end) {
http_response_code(416);
exit;
}
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
@@ -176,12 +184,17 @@ class MediaController
}
$fp = fopen($path, 'rb');
if ($fp === false) { http_response_code(500); exit; }
if ($fp === false) {
http_response_code(500);
exit;
}
fseek($fp, $start);
$remaining = $end - $start + 1;
while ($remaining > 0 && !feof($fp)) {
$chunk = fread($fp, min(8192, $remaining));
if ($chunk === false) break;
if ($chunk === false) {
break;
}
echo $chunk;
$remaining -= strlen($chunk);
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* SearchController
*
@@ -42,8 +43,8 @@ class SearchController
*/
public static function create(): self
{
require_once APP_ROOT . "/src/Database.php";
require_once APP_ROOT . "/src/RateLimit.php";
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/RateLimit.php';
$rateLimit = new RateLimit(
self::RATE_LIMIT_MAX,
@@ -75,7 +76,7 @@ class SearchController
public function handleSearch(): array
{
$searchParams = $this->collectSearchParams();
$page = isset($_GET["page"]) ? max(1, (int) $_GET["page"]) : 1;
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
$validationError = null;
@@ -100,48 +101,48 @@ class SearchController
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
error_log("SearchController: " . $e->getMessage());
$validationError = "Une erreur est survenue.";
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" => ""]);
$baseParams = array_diff_key($_GET, ['page' => '']);
$query = $_GET["query"] ?? "";
$query = $_GET['query'] ?? '';
return [
"searchParams" => $searchParams,
"page" => $page,
"totalItems" => $totalItems,
"totalPages" => $totalPages,
"results" => $results,
"validationError" => $validationError,
"baseParams" => $baseParams,
'searchParams' => $searchParams,
'page' => $page,
'totalItems' => $totalItems,
'totalPages' => $totalPages,
'results' => $results,
'validationError' => $validationError,
'baseParams' => $baseParams,
// Filter dropdowns
"years" => $years,
"orientations" => $orientations,
"apPrograms" => $apPrograms,
'years' => $years,
'orientations' => $orientations,
'apPrograms' => $apPrograms,
// Page meta
"searchBarValue" => $query,
"pageTitle" =>
$query !== ""
? "Recherche : " . $query . " XAMXAM"
: "Recherche XAMXAM",
"metaDescription" =>
'searchBarValue' => $query,
'pageTitle' =>
$query !== ''
? 'Recherche : ' . $query . ' XAMXAM'
: 'Recherche XAMXAM',
'metaDescription' =>
"Résultats de recherche dans le répertoire des TFE de l'erg.",
"ogTags" => [
"type" => "website",
"title" => "Recherche XAMXAM",
"description" =>
'ogTags' => [
'type' => 'website',
'title' => 'Recherche XAMXAM',
'description' =>
"Résultats de recherche dans le répertoire des TFE de l'erg.",
"url" => "https://xamxam.erg.be/search",
"site_name" => "XAMXAM ERG",
'url' => 'https://xamxam.erg.be/search',
'site_name' => 'XAMXAM ERG',
],
"currentNav" => "repertoire",
"extraCss" => ["/assets/css/repertoire.css"],
"bodyClass" => "search-body",
'currentNav' => 'repertoire',
'extraCss' => ['/assets/css/repertoire.css'],
'bodyClass' => 'search-body',
];
}
@@ -153,7 +154,7 @@ class SearchController
*/
public function handleRepertoire(): array
{
$isHtmx = !empty($_SERVER["HTTP_HX_REQUEST"]);
$isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']);
$activeFilters = $this->collectFilterParams();
$repData = null;
$validationError = null;
@@ -163,8 +164,8 @@ class SearchController
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
error_log("SearchController: " . $e->getMessage());
$validationError = "Une erreur est survenue.";
error_log('SearchController: ' . $e->getMessage());
$validationError = 'Une erreur est survenue.';
}
// HTMX partial: render just the index div and exit
@@ -173,27 +174,27 @@ class SearchController
}
return [
"repData" => $repData,
"activeFilters" => $activeFilters,
"isHtmx" => $isHtmx,
"validationError" => $validationError,
'repData' => $repData,
'activeFilters' => $activeFilters,
'isHtmx' => $isHtmx,
'validationError' => $validationError,
// Page meta
"searchBarValue" => "",
"pageTitle" => "Répertoire XAMXAM",
"metaDescription" =>
'searchBarValue' => '',
'pageTitle' => 'Répertoire XAMXAM',
'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 XAMXAM",
"description" =>
'ogTags' => [
'type' => 'website',
'title' => 'Répertoire XAMXAM',
'description' =>
"Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg École de Recherches Graphiques de Bruxelles.",
"url" => "https://xamxam.erg.be/repertoire",
"site_name" => "XAMXAM ERG",
'url' => 'https://xamxam.erg.be/repertoire',
'site_name' => 'XAMXAM ERG',
],
"currentNav" => "repertoire",
"extraCss" => ["/assets/css/repertoire.css"],
"bodyClass" => "search-body",
'currentNav' => 'repertoire',
'extraCss' => ['/assets/css/repertoire.css'],
'bodyClass' => 'search-body',
];
}
@@ -207,7 +208,8 @@ class SearchController
* HTMX endpoint: returns a popover snippet for a student name.
* Renders directly and exits.
*/
public function handleStudentPreview(): never {
public function handleStudentPreview(): never
{
$name = trim($_GET['name'] ?? '');
header('Content-Type: text/html; charset=UTF-8');
@@ -232,8 +234,8 @@ class SearchController
array $repData,
array $activeFilters,
): never {
header("Content-Type: text/html; charset=UTF-8");
include APP_ROOT . "/templates/partials/repertoire-index.php";
header('Content-Type: text/html; charset=UTF-8');
include APP_ROOT . '/templates/partials/repertoire-index.php';
exit();
}
@@ -253,7 +255,7 @@ class SearchController
$out = [];
foreach ($raw as $v) {
$v = trim((string) $v);
if ($v !== "" && strlen($v) <= $maxLen) {
if ($v !== '' && strlen($v) <= $maxLen) {
$out[] = $v;
}
}
@@ -261,8 +263,8 @@ class SearchController
};
$years = [];
if (!empty($_GET["fy"]) && is_array($_GET["fy"])) {
foreach ($_GET["fy"] as $y) {
if (!empty($_GET['fy']) && is_array($_GET['fy'])) {
foreach ($_GET['fy'] as $y) {
$y = (int) $y;
if ($y >= 1900 && $y <= 2100) {
$years[] = $y;
@@ -272,11 +274,11 @@ class SearchController
}
return [
"years" => $years,
"ap" => $sanitiseStrings($_GET["ap"] ?? []),
"or" => $sanitiseStrings($_GET["or"] ?? []),
"fi" => $sanitiseStrings($_GET["fi"] ?? []),
"kw" => $sanitiseStrings($_GET["kw"] ?? []),
'years' => $years,
'ap' => $sanitiseStrings($_GET['ap'] ?? []),
'or' => $sanitiseStrings($_GET['or'] ?? []),
'fi' => $sanitiseStrings($_GET['fi'] ?? []),
'kw' => $sanitiseStrings($_GET['kw'] ?? []),
];
}
@@ -289,20 +291,20 @@ class SearchController
{
$params = [];
if (!empty($_GET["query"])) {
$params["query"] = trim((string) $_GET["query"]);
if (!empty($_GET['query'])) {
$params['query'] = trim((string) $_GET['query']);
}
if (!empty($_GET["year"])) {
$params["year"] = (int) $_GET["year"];
if (!empty($_GET['year'])) {
$params['year'] = (int) $_GET['year'];
}
if (!empty($_GET["orientation"])) {
$params["orientation"] = (string) $_GET["orientation"];
if (!empty($_GET['orientation'])) {
$params['orientation'] = (string) $_GET['orientation'];
}
if (!empty($_GET["ap_program"])) {
$params["ap_program"] = (string) $_GET["ap_program"];
if (!empty($_GET['ap_program'])) {
$params['ap_program'] = (string) $_GET['ap_program'];
}
if (!empty($_GET["keyword"])) {
$params["keyword"] = (string) $_GET["keyword"];
if (!empty($_GET['keyword'])) {
$params['keyword'] = (string) $_GET['keyword'];
}
return $params;
@@ -316,48 +318,48 @@ class SearchController
private static function sendRateLimitResponse(RateLimit $rateLimit): never
{
http_response_code(429);
header("Retry-After: " . $rateLimit->getResetTime());
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 XAMXAM</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">XAMXAM</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;
<!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 XAMXAM</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">XAMXAM</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();
}
}

View File

@@ -189,8 +189,8 @@ class SystemController
}
$error = file_exists($livePath)
? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath)
: "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ").";
? '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];
}
@@ -202,12 +202,24 @@ class SystemController
*/
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';
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 '';
}
@@ -217,8 +229,12 @@ class SystemController
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';
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';
}
@@ -229,8 +245,12 @@ class SystemController
*/
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';
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';
}
@@ -267,8 +287,12 @@ class SystemController
*/
public static function diskColor(int $pct): string
{
if ($pct > 85) return '#e05555';
if ($pct > 70) return '#ffc107';
if ($pct > 85) {
return '#e05555';
}
if ($pct > 70) {
return '#ffc107';
}
return '#4caf50';
}
@@ -337,7 +361,8 @@ class SystemController
if ($dbExists) {
try {
$dbRowCount = $this->db->getThesisCount();
} catch (Throwable) {}
} catch (Throwable) {
}
}
$checks['database'] = [
'label' => 'Base de données SQLite',
@@ -358,7 +383,7 @@ class SystemController
'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,
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
]))
: 'Non accessible en écriture',
];
@@ -382,15 +407,15 @@ class SystemController
$errorMsg = null;
if (!function_exists('exec')) {
$errorMsg = "exec() est désactivé sur ce serveur.";
$errorMsg = 'exec() est désactivé sur ce serveur.';
return null;
}
if (!file_exists($logPath)) {
$errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath);
$errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
return null;
}
if (!is_readable($logPath)) {
$errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
$errorMsg = 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($logPath);
return null;
}
@@ -399,7 +424,7 @@ class SystemController
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) {
$errorMsg = "Erreur lors de la lecture du fichier journal.";
$errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null;
}
@@ -411,7 +436,9 @@ class SystemController
*/
private function safeExec(string $cmd): ?string
{
if (!function_exists('exec')) return null;
if (!function_exists('exec')) {
return null;
}
$output = [];
$rc = 0;
exec($cmd . ' 2>/dev/null', $output, $rc);
@@ -424,7 +451,9 @@ class SystemController
private function systemdStatus(string $unit): ?string
{
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
if ($raw === null) return null;
if ($raw === null) {
return null;
}
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
? $raw : 'unknown';
}
@@ -435,7 +464,9 @@ class SystemController
*/
private function localHttpCheck(string $url): ?array
{
if (!function_exists('curl_init')) return null;
if (!function_exists('curl_init')) {
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,

View File

@@ -1,4 +1,5 @@
<?php
/**
* TfeController
*
@@ -74,11 +75,11 @@ class TfeController
// Access type (1 = open, 2 = restricted, 3 = forbidden)
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3);
// Check if restricted files feature is enabled and user has access
$restrictedEnabled = $this->db->isRestrictedFilesEnabled();
$hasRestrictedAccess = false;
if ($restrictedEnabled && $accessTypeId === 2) {
// Check for cookie-based access
$cookieToken = $_COOKIE['tfe_access_' . $thesisId] ?? null;
@@ -86,7 +87,7 @@ class TfeController
$hasRestrictedAccess = $this->db->hasValidCookieAccess($cookieToken, $thesisId);
}
}
// If access is restricted and user doesn't have valid access, hide files
$shouldHideFiles = ($restrictedEnabled && $accessTypeId === 2 && !$hasRestrictedAccess);
@@ -209,7 +210,9 @@ class TfeController
foreach ($jury as $member) {
$name = $member['name'] ?? '';
if ($name === '') continue;
if ($name === '') {
continue;
}
switch ($member['role']) {
case 'president':

View File

@@ -1,4 +1,5 @@
<?php
/**
* ThesisCreateController
*
@@ -143,6 +144,19 @@ class ThesisCreateController
// ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post);
// ── 1b. Duplicate detection ───────────────────────────────────────────
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$duplicate = $this->db->findDuplicateThesis($data['titre'], $data['auteurName'], $data['annee']);
if ($duplicate !== null) {
throw new DuplicateThesisException(
$duplicate['id'],
$duplicate['identifier'],
$duplicate['title'],
$duplicate['author'],
$duplicate['year']
);
}
// ── 2. Find / create author ───────────────────────────────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']);
error_log("ThesisCreateController: author ID $authorId");
@@ -200,17 +214,39 @@ class ThesisCreateController
*/
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';
if (str_contains($message, 'e-mail de confirmation')) return 'confirmation_email';
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';
}
if (str_contains($message, 'e-mail de confirmation')) {
return 'confirmation_email';
}
return null;
}
@@ -335,10 +371,26 @@ class ThesisCreateController
}
return compact(
'auteurName', 'mail', 'showContact', 'confirmationEmail', 'annee', 'orientationId', 'apProgramId',
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
'juryMembers', 'keywords', 'languageIds', 'formatIds',
'licenseId', 'lien', 'accessTypeId', 'objet'
'auteurName',
'mail',
'showContact',
'confirmationEmail',
'annee',
'orientationId',
'apProgramId',
'finalityId',
'titre',
'subtitle',
'synopsis',
'durationInfo',
'juryMembers',
'keywords',
'languageIds',
'formatIds',
'licenseId',
'lien',
'accessTypeId',
'objet'
);
}
@@ -496,11 +548,21 @@ class ThesisCreateController
*/
private function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') return 'caption';
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) return 'audio';
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) return 'video';
if ($mimeType === 'application/pdf' || $ext === 'pdf') return 'main';
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) return 'image';
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 'audio';
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 'video';
}
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 'main';
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 'image';
}
return 'other';
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* ThesisEditController
*
@@ -68,12 +69,12 @@ class ThesisEditController
public function load(int $thesisId): array
{
if ($thesisId <= 0) {
throw new InvalidArgumentException("ID invalide");
throw new InvalidArgumentException('ID invalide');
}
$thesis = $this->db->getThesis($thesisId);
if (!$thesis) {
throw new RuntimeException("TFE non trouvé");
throw new RuntimeException('TFE non trouvé');
}
$currentLanguages = $this->db->getThesisLanguageIds($thesisId);
@@ -157,7 +158,7 @@ class ThesisEditController
public function save(int $thesisId, array $post, array $files): void
{
if ($thesisId <= 0) {
throw new InvalidArgumentException("ID de TFE invalide.");
throw new InvalidArgumentException('ID de TFE invalide.');
}
$this->db->beginTransaction();
@@ -253,7 +254,9 @@ class ThesisEditController
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) @unlink($abs);
if (file_exists($abs)) {
@unlink($abs);
}
}
break;
}
@@ -267,11 +270,15 @@ class ThesisEditController
? array_map('intval', $post['delete_files'])
: [];
foreach ($deleteIds as $fileId) {
if ($fileId <= 0) continue;
if ($fileId <= 0) {
continue;
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) @unlink($abs);
if (file_exists($abs)) {
@unlink($abs);
}
}
}
@@ -284,7 +291,9 @@ class ThesisEditController
if (!empty($post['file_label']) && is_array($post['file_label'])) {
foreach ($post['file_label'] as $fileId => $label) {
$fileId = (int)$fileId;
if ($fileId <= 0) continue;
if ($fileId <= 0) {
continue;
}
$this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null);
}
}
@@ -360,7 +369,9 @@ class ThesisEditController
$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] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
continue;
@@ -409,8 +420,12 @@ class ThesisEditController
$relPath = "theses/{$year}/{$folderName}/" . $candidate;
$this->db->insertThesisFile(
$thesisId, $fileType, $relPath,
basename($originalName), $uploads['size'][$i], $mimeType,
$thesisId,
$fileType,
$relPath,
basename($originalName),
$uploads['size'][$i],
$mimeType,
$label !== '' ? $label : null,
$sortOrder
);
@@ -423,11 +438,21 @@ class ThesisEditController
*/
private function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') return 'caption';
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) return 'audio';
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) return 'video';
if ($mimeType === 'application/pdf' || $ext === 'pdf') return 'main';
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) return 'image';
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 'audio';
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 'video';
}
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 'main';
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 'image';
}
return 'other';
}
@@ -437,8 +462,8 @@ class ThesisEditController
{
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $authorName) : $authorName;
$accents = [
'à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c',
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
];
$n = strtr($n, $accents);
$slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_'));
@@ -451,11 +476,13 @@ class ThesisEditController
$name = pathinfo($filename, PATHINFO_FILENAME);
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name;
$accents = [
'à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c',
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
];
$n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_');
if ($n === '') $n = 'file';
if ($n === '') {
$n = 'file';
}
return $ext !== '' ? $n . '.' . strtolower($ext) : $n;
}
@@ -480,10 +507,18 @@ class ThesisEditController
*/
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';
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;
}