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

35
TODO.md
View File

@@ -1,24 +1,13 @@
# TODO # XAMXAM TODO
- [x] Refactor `justfile` to reduce redundancy and merge similar recipes ## Duplicate TFE submission prevention
- [x] Merge `deploy-*` recipes into a single `deploy-script` recipe - [x] `DuplicateThesisException` — typed exception carrying existing thesis metadata
- [x] Remove rarely used recipes (`show id`, `setup-dirs`) - [x] `Database::findDuplicateThesis()` — year + author + normalised-title matching (exact, prefix, Levenshtein ≤10%)
- [x] Simplify `test-*` recipes - [x] `ThesisCreateController::submit()` — calls duplicate check before any DB write, throws `DuplicateThesisException`
- [x] Remove redundant `default` recipe - [x] `AppLogger::logDuplicate()` — dedicated log action (`status: duplicate`) for audit trail
- [x] Preserve all critical functionality - [x] `App::flash/consumeFlash` — extended to support `warning` type alongside `error`/`success`
- [x] Enhance `serve` recipe to automatically open the browser - [x] `admin/actions/formulaire.php` — catches `DuplicateThesisException` separately; logs it; flashes HTML warning with link to existing thesis; repopulates form
- [x] Keep `serve` recipe in the foreground (browser open backgrounded, PHP server blocks) - [x] `partage/index.php` — same catch block; plain-text warning (no admin link) surfaced on the student form via `flash-warning` banner; form repopulated
- [x] Add `psalm` recipe (auto-inits config on first run, then analyses) - [x] `toast.php` — renders `toast--warning` block
- [x] Fix all genuine Psalm errors (InvalidOperand, UnusedVariable, InvalidReturnType, NullableReturnStatement, InvalidArrayOffset, UnusedForeachValue, RedundantFunctionCall) - [x] `admin.css``.toast--warning` style + link colour
- [x] Generate psalm-baseline.xml to suppress false positives (UndefinedConstant, PossiblyUnused*, UnusedClass) - [x] `form.css``.flash-warning` style (partage form)
- [x] Add `lint-biome` recipe; fix all JS errors and warnings (arrow functions, template literals, noRedundantUseStrict, noUnusedVariables, useIterableCallbackReturn)
- [x] Replace Psalm with PHPStan + PHP-CS-Fixer
- [x] Remove vimeo/psalm and all its deps from vendor/
- [x] Install phpstan.phar (2.1.54) and php-cs-fixer.phar (3.95.1) in vendor/bin/
- [x] Create phpstan.neon (level 5, bootstraps app/bootstrap.php, scanFiles Parsedown)
- [x] Generate phpstan-baseline.neon (10 pre-existing errors baselined)
- [x] Create .php-cs-fixer.dist.php (PSR-12 + PHP80Migration, targets app/src + app/tests)
- [x] Replace `psalm` justfile recipe with `phpstan`, `cs-check`, `cs-fix`
- [x] Remove psalm.xml, psalm-baseline.xml
- [x] Remove @psalm-suppress annotations from SmtpRelay.php and RateLimit.php
- [x] Add .phpstan.result.cache and .php-cs-fixer.cache to .gitignore

View File

@@ -24,6 +24,7 @@ error_log('FILES array: ' . print_r($_FILES, true));
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php'; require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/AppLogger.php'; require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$logger = new AppLogger(); $logger = new AppLogger();
$authorName = $_POST['auteurice'] ?? 'unknown'; $authorName = $_POST['auteurice'] ?? 'unknown';
@@ -41,6 +42,24 @@ try {
header('Location: ' . $redirect); header('Location: ' . $redirect);
exit(); exit();
} catch (DuplicateThesisException $e) {
$logger->logDuplicate('admin', $authorName, $e->existingThesisId, $e->existingIdentifier);
error_log('ThesisCreateController duplicate: ' . $e->getMessage());
// Build a warning with a clickable link to the existing thesis.
$existingUrl = htmlspecialchars('/admin/edit.php?id=' . $e->existingThesisId);
$existingRef = htmlspecialchars($e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')');
$warningHtml = 'Doublon détecté : un TFE très similaire existe déjà. '
. '<a href="' . $existingUrl . '" style="color:inherit;text-decoration:underline">' . $existingRef . '</a>'
. ' Vérifiez avant de soumettre à nouveau.';
App::flash('warning', $warningHtml);
$_SESSION['form_data'] = $_POST;
header('Location: ../add.php');
exit();
} catch (Exception $e) { } catch (Exception $e) {
$logger->logError('admin', $e->getMessage(), [ $logger->logError('admin', $e->getMessage(), [
'author' => $authorName, 'author' => $authorName,

View File

@@ -182,6 +182,17 @@
color: var(--text-primary); color: var(--text-primary);
} }
.toast--warning {
background: var(--bg-secondary);
border-color: var(--warning);
color: var(--text-primary);
}
.toast--warning a {
color: inherit;
text-decoration: underline;
}
@keyframes toast-enter { @keyframes toast-enter {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }

View File

@@ -335,6 +335,7 @@ label:has(+ div > input:required)::after {
/* ── Flash messages ─────────────────────────────────────────────────────── */ /* ── Flash messages ─────────────────────────────────────────────────────── */
.flash-error, .flash-error,
.flash-warning,
.flash-success { .flash-success {
padding: var(--space-xs) var(--space-s); padding: var(--space-xs) var(--space-s);
border-radius: 4px; border-radius: 4px;
@@ -355,6 +356,12 @@ label:has(+ div > input:required)::after {
color: var(--text-primary); color: var(--text-primary);
} }
.flash-warning {
background: var(--warning-muted-bg, rgba(251,202,81,.12));
border-color: var(--warning-muted-border, rgba(251,202,81,.35));
color: var(--text-primary);
}
/* ── Share link badge ───────────────────────────────────────────────────── */ /* ── Share link badge ───────────────────────────────────────────────────── */
.share-badge { .share-badge {
display: inline-block; display: inline-block;

View File

@@ -259,12 +259,16 @@ function renderShareLinkForm(string $slug, array $link): void
<?php <?php
// Show flash messages from error redirect // Show flash messages from error redirect
$flashError = $_SESSION['_flash_error'] ?? null; $flashError = $_SESSION['_flash_error'] ?? null;
$flashWarning = $_SESSION['_flash_warning'] ?? null;
$flashSuccess = $_SESSION['_flash_success'] ?? null; $flashSuccess = $_SESSION['_flash_success'] ?? null;
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']); unset($_SESSION['_flash_error'], $_SESSION['_flash_warning'], $_SESSION['_flash_success']);
?> ?>
<?php if ($flashError): ?> <?php if ($flashError): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div> <div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($flashWarning): ?>
<div class="flash-warning" role="alert"><?= htmlspecialchars($flashWarning) ?></div>
<?php endif; ?>
<?php if ($flashSuccess): ?> <?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div> <div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
<?php endif; ?> <?php endif; ?>
@@ -434,6 +438,7 @@ function handleShareLinkSubmission(string $slug): void
require_once APP_ROOT . '/src/SmtpRelay.php'; require_once APP_ROOT . '/src/SmtpRelay.php';
require_once APP_ROOT . '/src/StudentEmail.php'; require_once APP_ROOT . '/src/StudentEmail.php';
require_once APP_ROOT . '/src/AppLogger.php'; require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$logger = new AppLogger(); $logger = new AppLogger();
$authorName = $_POST['auteurice'] ?? 'unknown'; $authorName = $_POST['auteurice'] ?? 'unknown';
@@ -474,6 +479,24 @@ function handleShareLinkSubmission(string $slug): void
// Redirect to thanks page // Redirect to thanks page
header('Location: /partage/recapitulatif?id=' . urlencode((string)$thesisId)); header('Location: /partage/recapitulatif?id=' . urlencode((string)$thesisId));
exit(); exit();
} catch (DuplicateThesisException $e) {
$logger->logDuplicate('partage', $authorName, $e->existingThesisId, $e->existingIdentifier, [
'share_slug' => $slug,
]);
error_log('Share link duplicate submission: ' . $e->getMessage());
// Repopulate the form and surface a clear warning to the student.
$_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré ('
. htmlspecialchars($e->existingIdentifier . ' — ' . $e->existingTitle . ', ' . $e->existingYear)
. '). Si vous pensez quil sagit dune erreur, veuillez contacter léquipe.';
$_SESSION['form_data_share_' . $slug] = $_POST;
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
header('Location: /partage/' . urlencode($slug));
exit();
} catch (Exception $e) { } catch (Exception $e) {
$logger->logError('partage', $e->getMessage(), [ $logger->logError('partage', $e->getMessage(), [
'share_slug' => $slug, 'share_slug' => $slug,

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Minimal PHP session guard for the admin panel. * Minimal PHP session guard for the admin panel.
* *
@@ -161,8 +162,13 @@ class AdminAuth
if (ini_get('session.use_cookies')) { if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params(); $p = session_get_cookie_params();
setcookie( setcookie(
session_name(), '', time() - 86400, session_name(),
$p['path'], $p['domain'], $p['secure'], $p['httponly'] '',
time() - 86400,
$p['path'],
$p['domain'],
$p['secure'],
$p['httponly']
); );
} }
session_destroy(); session_destroy();

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Thin application helper — centralises bootstrap, auth gating, CSRF lifecycle, * Thin application helper — centralises bootstrap, auth gating, CSRF lifecycle,
* flash messages, redirect, and template rendering. * flash messages, redirect, and template rendering.
@@ -79,7 +80,7 @@ class App
/** /**
* Store a flash message in the session. * Store a flash message in the session.
* *
* @param 'success'|'error' $type * @param 'success'|'error'|'warning' $type
*/ */
public static function flash(string $type, string $message): void public static function flash(string $type, string $message): void
{ {
@@ -109,17 +110,18 @@ class App
/** /**
* Consume and return flash messages, then clear them from the session. * Consume and return flash messages, then clear them from the session.
* *
* @return array{error: ?string, success: ?string} * @return array{error: ?string, success: ?string, warning: ?string}
*/ */
public static function consumeFlash(): array public static function consumeFlash(): array
{ {
$error = $_SESSION['_flash_error'] ?? null; $error = $_SESSION['_flash_error'] ?? null;
$success = $_SESSION['_flash_success'] ?? null; $success = $_SESSION['_flash_success'] ?? null;
$warning = $_SESSION['_flash_warning'] ?? null;
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']); unset($_SESSION['_flash_error'], $_SESSION['_flash_success'], $_SESSION['_flash_warning']);
// Note: autofocus is consumed separately via consumeAutofocus(). // Note: autofocus is consumed separately via consumeAutofocus().
return ['error' => $error, 'success' => $success]; return ['error' => $error, 'success' => $success, 'warning' => $warning];
} }
// ── Redirect ────────────────────────────────────────────────────────────── // ── Redirect ──────────────────────────────────────────────────────────────

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Structured application logger for form submissions. * Structured application logger for form submissions.
* *
@@ -43,6 +44,32 @@ class AppLogger
], $extras)); ], $extras));
} }
/**
* Log a duplicate-submission attempt.
*
* @param string $source 'admin' or 'partage'
* @param string $authorName Author name from the incoming form
* @param int $existingThesisId ID of the matched existing thesis
* @param string $existingIdentifier Identifier of the matched thesis (e.g. "2025-003")
* @param array $extras Additional context (e.g. share_slug)
*/
public function logDuplicate(
string $source,
string $authorName,
int $existingThesisId,
string $existingIdentifier,
array $extras = []
): void {
$this->write(array_merge([
'source' => $source,
'action' => 'submit',
'status' => 'duplicate',
'author' => $authorName,
'existing_thesis_id' => $existingThesisId,
'existing_identifier' => $existingIdentifier,
], $extras));
}
/** /**
* Log a failed thesis submission. * Log a failed thesis submission.
* *

View File

@@ -1,13 +1,19 @@
<?php <?php
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.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."; 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 { try {
$db = Database::getInstance(); $db = Database::getInstance();
$aboutPage = $db->getPage('about'); $aboutPage = $db->getPage('about');
@@ -20,7 +26,7 @@ class AboutController {
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null; $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) { } catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage()); error_log('Error loading about page: ' . $e->getMessage());
$rawContent = $this->defaultContent; $rawContent = $this->defaultContent;
$contacts = null; $contacts = null;
$credits = null; $credits = null;

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,24 @@
<?php <?php
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php'; require_once APP_ROOT . '/src/Parsedown.php';
class LicenceController { class LicenceController
public static function create(): self { {
public static function create(): self
{
return new self(); return new self();
} }
public function handle(): array { public function handle(): array
{
try { try {
$db = Database::getInstance(); $db = Database::getInstance();
$dbPage = $db->getPage('licenses'); $dbPage = $db->getPage('licenses');
$content = $dbPage ? $dbPage['content'] : ''; $content = $dbPage ? $dbPage['content'] : '';
$pageTitle = $dbPage ? $dbPage['title'] : 'Licences'; $pageTitle = $dbPage ? $dbPage['title'] : 'Licences';
} catch (Exception $e) { } catch (Exception $e) {
error_log("Error loading licence page: " . $e->getMessage()); error_log('Error loading licence page: ' . $e->getMessage());
$content = ''; $content = '';
$pageTitle = 'Licences'; $pageTitle = 'Licences';
} }

View File

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

View File

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

View File

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

View File

@@ -189,8 +189,8 @@ class SystemController
} }
$error = file_exists($livePath) $error = file_exists($livePath)
? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath) ? 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($livePath)
: "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ")."; : 'Config live introuvable (' . htmlspecialchars($livePath) . ') et config locale introuvable (' . htmlspecialchars($localPath) . ').';
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error]; return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
} }
@@ -202,12 +202,24 @@ class SystemController
*/ */
public static function logLineClass(string $line): string public static function logLineClass(string $line): string
{ {
if (preg_match('/\[(crit|emerg|alert)\]/', $line)) return 'log-crit'; if (preg_match('/\[(crit|emerg|alert)\]/', $line)) {
if (preg_match('/\[error\]/', $line)) return 'log-error'; return 'log-crit';
if (preg_match('/\[warn\]/', $line)) return 'log-warn'; }
if (preg_match('/\[notice\]/', $line)) return 'log-notice'; if (preg_match('/\[error\]/', $line)) {
if (preg_match('/" [45]\d\d /', $line)) return 'log-error'; return 'log-error';
if (preg_match('/" 3\d\d /', $line)) return 'log-notice'; }
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 '';
} }
@@ -217,8 +229,12 @@ class SystemController
public static function nginxLineClass(string $line): string public static function nginxLineClass(string $line): string
{ {
$trimmed = ltrim($line); $trimmed = ltrim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) return 'nginx-comment'; if ($trimmed === '' || str_starts_with($trimmed, '#')) {
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) return 'nginx-block'; return 'nginx-comment';
}
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) {
return 'nginx-block';
}
return 'nginx-directive'; return 'nginx-directive';
} }
@@ -229,8 +245,12 @@ class SystemController
*/ */
public static function humanBytes(int $bytes): string public static function humanBytes(int $bytes): string
{ {
if ($bytes > 1073741824) return number_format($bytes / 1073741824, 1) . ' GB'; if ($bytes > 1073741824) {
if ($bytes > 1048576) return number_format($bytes / 1048576, 1) . ' MB'; return number_format($bytes / 1073741824, 1) . ' GB';
}
if ($bytes > 1048576) {
return number_format($bytes / 1048576, 1) . ' MB';
}
return number_format($bytes / 1024, 1) . ' KB'; return number_format($bytes / 1024, 1) . ' KB';
} }
@@ -267,8 +287,12 @@ class SystemController
*/ */
public static function diskColor(int $pct): string public static function diskColor(int $pct): string
{ {
if ($pct > 85) return '#e05555'; if ($pct > 85) {
if ($pct > 70) return '#ffc107'; return '#e05555';
}
if ($pct > 70) {
return '#ffc107';
}
return '#4caf50'; return '#4caf50';
} }
@@ -337,7 +361,8 @@ class SystemController
if ($dbExists) { if ($dbExists) {
try { try {
$dbRowCount = $this->db->getThesisCount(); $dbRowCount = $this->db->getThesisCount();
} catch (Throwable) {} } catch (Throwable) {
}
} }
$checks['database'] = [ $checks['database'] = [
'label' => 'Base de données SQLite', 'label' => 'Base de données SQLite',
@@ -382,15 +407,15 @@ class SystemController
$errorMsg = null; $errorMsg = null;
if (!function_exists('exec')) { if (!function_exists('exec')) {
$errorMsg = "exec() est désactivé sur ce serveur."; $errorMsg = 'exec() est désactivé sur ce serveur.';
return null; return null;
} }
if (!file_exists($logPath)) { if (!file_exists($logPath)) {
$errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath); $errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
return null; return null;
} }
if (!is_readable($logPath)) { if (!is_readable($logPath)) {
$errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath); $errorMsg = 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($logPath);
return null; return null;
} }
@@ -399,7 +424,7 @@ class SystemController
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc); exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) { if ($rc !== 0) {
$errorMsg = "Erreur lors de la lecture du fichier journal."; $errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null; return null;
} }
@@ -411,7 +436,9 @@ class SystemController
*/ */
private function safeExec(string $cmd): ?string private function safeExec(string $cmd): ?string
{ {
if (!function_exists('exec')) return null; if (!function_exists('exec')) {
return null;
}
$output = []; $output = [];
$rc = 0; $rc = 0;
exec($cmd . ' 2>/dev/null', $output, $rc); exec($cmd . ' 2>/dev/null', $output, $rc);
@@ -424,7 +451,9 @@ class SystemController
private function systemdStatus(string $unit): ?string private function systemdStatus(string $unit): ?string
{ {
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit)); $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) return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
? $raw : 'unknown'; ? $raw : 'unknown';
} }
@@ -435,7 +464,9 @@ class SystemController
*/ */
private function localHttpCheck(string $url): ?array private function localHttpCheck(string $url): ?array
{ {
if (!function_exists('curl_init')) return null; if (!function_exists('curl_init')) {
return null;
}
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* TfeController * TfeController
* *
@@ -209,7 +210,9 @@ class TfeController
foreach ($jury as $member) { foreach ($jury as $member) {
$name = $member['name'] ?? ''; $name = $member['name'] ?? '';
if ($name === '') continue; if ($name === '') {
continue;
}
switch ($member['role']) { switch ($member['role']) {
case 'president': case 'president':

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* ThesisCreateController * ThesisCreateController
* *
@@ -143,6 +144,19 @@ class ThesisCreateController
// ── 1. Validate + sanitise ──────────────────────────────────────────── // ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post); $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 ─────────────────────────────────────────── // ── 2. Find / create author ───────────────────────────────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']); $authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']);
error_log("ThesisCreateController: author ID $authorId"); error_log("ThesisCreateController: author ID $authorId");
@@ -200,17 +214,39 @@ class ThesisCreateController
*/ */
public static function autofocusFieldForError(string $message): ?string public static function autofocusFieldForError(string $message): ?string
{ {
if (str_contains($message, 'Nom/Prénom/Pseudo')) return 'auteurice'; if (str_contains($message, 'Nom/Prénom/Pseudo')) {
if (str_contains($message, 'Titre du mémoire')) return 'titre'; return 'auteurice';
if (str_contains($message, 'Synopsis')) return 'synopsis'; }
if (str_contains($message, 'Année invalide')) return 'année'; if (str_contains($message, 'Titre du mémoire')) {
if (str_contains($message, 'orientation')) return 'orientation'; return 'titre';
if (str_contains($message, 'Atelier Pratique')) return 'ap'; }
if (str_contains($message, 'finalité')) return 'finality'; if (str_contains($message, 'Synopsis')) {
if (str_contains($message, 'langue')) return 'languages'; return 'synopsis';
if (str_contains($message, 'mots-clés')) return 'tag'; }
if (str_contains($message, 'Lien URL')) return 'lien'; if (str_contains($message, 'Année invalide')) {
if (str_contains($message, 'e-mail de confirmation')) return 'confirmation_email'; 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; return null;
} }
@@ -335,10 +371,26 @@ class ThesisCreateController
} }
return compact( return compact(
'auteurName', 'mail', 'showContact', 'confirmationEmail', 'annee', 'orientationId', 'apProgramId', 'auteurName',
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo', 'mail',
'juryMembers', 'keywords', 'languageIds', 'formatIds', 'showContact',
'licenseId', 'lien', 'accessTypeId', 'objet' '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 private function detectFileType(string $mimeType, string $ext, string $originalName): string
{ {
if ($ext === 'vtt' || $mimeType === 'text/vtt') return 'caption'; if ($ext === 'vtt' || $mimeType === 'text/vtt') {
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) return 'audio'; return 'caption';
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, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) return 'image'; 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'; return 'other';
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* ThesisEditController * ThesisEditController
* *
@@ -68,12 +69,12 @@ class ThesisEditController
public function load(int $thesisId): array public function load(int $thesisId): array
{ {
if ($thesisId <= 0) { if ($thesisId <= 0) {
throw new InvalidArgumentException("ID invalide"); throw new InvalidArgumentException('ID invalide');
} }
$thesis = $this->db->getThesis($thesisId); $thesis = $this->db->getThesis($thesisId);
if (!$thesis) { if (!$thesis) {
throw new RuntimeException("TFE non trouvé"); throw new RuntimeException('TFE non trouvé');
} }
$currentLanguages = $this->db->getThesisLanguageIds($thesisId); $currentLanguages = $this->db->getThesisLanguageIds($thesisId);
@@ -157,7 +158,7 @@ class ThesisEditController
public function save(int $thesisId, array $post, array $files): void public function save(int $thesisId, array $post, array $files): void
{ {
if ($thesisId <= 0) { if ($thesisId <= 0) {
throw new InvalidArgumentException("ID de TFE invalide."); throw new InvalidArgumentException('ID de TFE invalide.');
} }
$this->db->beginTransaction(); $this->db->beginTransaction();
@@ -253,7 +254,9 @@ class ThesisEditController
$this->db->deleteThesisFile((int)$f['id'], $thesisId); $this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path']; $abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) @unlink($abs); if (file_exists($abs)) {
@unlink($abs);
}
} }
break; break;
} }
@@ -267,11 +270,15 @@ class ThesisEditController
? array_map('intval', $post['delete_files']) ? array_map('intval', $post['delete_files'])
: []; : [];
foreach ($deleteIds as $fileId) { foreach ($deleteIds as $fileId) {
if ($fileId <= 0) continue; if ($fileId <= 0) {
continue;
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId); $filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) { if ($filePath && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $filePath; $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'])) { if (!empty($post['file_label']) && is_array($post['file_label'])) {
foreach ($post['file_label'] as $fileId => $label) { foreach ($post['file_label'] as $fileId => $label) {
$fileId = (int)$fileId; $fileId = (int)$fileId;
if ($fileId <= 0) continue; if ($fileId <= 0) {
continue;
}
$this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null); $this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null);
} }
} }
@@ -360,7 +369,9 @@ class ThesisEditController
$count = count($uploads['name']); $count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) { 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) { if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}"); error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
continue; continue;
@@ -409,8 +420,12 @@ class ThesisEditController
$relPath = "theses/{$year}/{$folderName}/" . $candidate; $relPath = "theses/{$year}/{$folderName}/" . $candidate;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, $fileType, $relPath, $thesisId,
basename($originalName), $uploads['size'][$i], $mimeType, $fileType,
$relPath,
basename($originalName),
$uploads['size'][$i],
$mimeType,
$label !== '' ? $label : null, $label !== '' ? $label : null,
$sortOrder $sortOrder
); );
@@ -423,11 +438,21 @@ class ThesisEditController
*/ */
private function detectFileType(string $mimeType, string $ext, string $originalName): string private function detectFileType(string $mimeType, string $ext, string $originalName): string
{ {
if ($ext === 'vtt' || $mimeType === 'text/vtt') return 'caption'; if ($ext === 'vtt' || $mimeType === 'text/vtt') {
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) return 'audio'; return 'caption';
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, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) return 'image'; 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'; return 'other';
} }
@@ -455,7 +480,9 @@ class ThesisEditController
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c', 'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
]; ];
$n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_'); $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; return $ext !== '' ? $n . '.' . strtolower($ext) : $n;
} }
@@ -480,10 +507,18 @@ class ThesisEditController
*/ */
public static function autofocusFieldForError(string $message): ?string public static function autofocusFieldForError(string $message): ?string
{ {
if (str_contains($message, 'titre') || str_contains($message, 'Titre')) return 'titre'; if (str_contains($message, 'titre') || str_contains($message, 'Titre')) {
if (str_contains($message, 'année') || str_contains($message, 'Année')) return 'année'; return 'titre';
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, '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; return null;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Front-controller Dispatcher * Front-controller Dispatcher
* *
@@ -17,7 +18,8 @@
* /partage/<slug> → share-link flow * /partage/<slug> → share-link flow
* /maintenance.php → static maintenance page * /maintenance.php → static maintenance page
*/ */
class Dispatcher { class Dispatcher
{
private const ROUTES = [ private const ROUTES = [
'' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'], '' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
'/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'], '/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
@@ -37,7 +39,8 @@ class Dispatcher {
private string $path; private string $path;
private array $queryParams; private array $queryParams;
public function __construct() { public function __construct()
{
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$this->path = $uri; $this->path = $uri;
$this->queryParams = $_GET; $this->queryParams = $_GET;
@@ -47,7 +50,8 @@ class Dispatcher {
* Resolve the URI to a route, instantiate the controller, * Resolve the URI to a route, instantiate the controller,
* execute the action, and render the view. * execute the action, and render the view.
*/ */
public function dispatch(): void { public function dispatch(): void
{
// 1. Direct-response endpoints (render their own output) // 1. Direct-response endpoints (render their own output)
$direct = $this->matchDirect(); $direct = $this->matchDirect();
if ($direct) { if ($direct) {
@@ -81,7 +85,8 @@ class Dispatcher {
/** /**
* Match endpoints that render their own response (no view layer). * Match endpoints that render their own response (no view layer).
*/ */
private function matchDirect(): ?callable { private function matchDirect(): ?callable
{
$path = $this->path; $path = $this->path;
// /live-reload // /live-reload
@@ -157,7 +162,8 @@ class Dispatcher {
* Match the current path against the static route table. * Match the current path against the static route table.
* Supports exact match and prefix-based (for /tfe?id=). * Supports exact match and prefix-based (for /tfe?id=).
*/ */
private function matchRoute(): ?array { private function matchRoute(): ?array
{
$path = $this->path; $path = $this->path;
// Exact match first // Exact match first
@@ -177,7 +183,8 @@ class Dispatcher {
* Render a view template wrapped in the full page layout. * Render a view template wrapped in the full page layout.
* Includes head.php, header.php, the view, and footer.php. * Includes head.php, header.php, the view, and footer.php.
*/ */
private function render(string $view, array $vars): void { private function render(string $view, array $vars): void
{
$viewPath = APP_ROOT . '/templates/' . $view . '.php'; $viewPath = APP_ROOT . '/templates/' . $view . '.php';
if (!file_exists($viewPath)) { if (!file_exists($viewPath)) {
http_response_code(500); http_response_code(500);

View File

@@ -0,0 +1,25 @@
<?php
/**
* Thrown when a submission would create a thesis that is too similar to an
* already-existing record.
*
* Carries enough information for the caller to build a useful warning message
* with a link to the existing thesis.
*/
class DuplicateThesisException extends RuntimeException
{
public function __construct(
public readonly int $existingThesisId,
public readonly string $existingIdentifier,
public readonly string $existingTitle,
public readonly string $existingAuthor,
public readonly int $existingYear,
string $message = ''
) {
if ($message === '') {
$message = "Un TFE similaire existe déjà ($existingIdentifier$existingAuthor, $existingYear).";
}
parent::__construct($message);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
* Simple file-based rate limiter * Simple file-based rate limiter
* Prevents abuse by limiting requests per IP address * Prevents abuse by limiting requests per IP address
*/ */
class RateLimit { class RateLimit
{
private $cacheDir; private $cacheDir;
private $maxRequests; private $maxRequests;
private $timeWindow; private $timeWindow;
@@ -15,7 +16,8 @@ class RateLimit {
* @param int $timeWindow Time window in seconds * @param int $timeWindow Time window in seconds
* @param string $cacheDir Directory to store rate limit data * @param string $cacheDir Directory to store rate limit data
*/ */
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null) { public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null)
{
$this->maxRequests = $maxRequests; $this->maxRequests = $maxRequests;
$this->timeWindow = $timeWindow; $this->timeWindow = $timeWindow;
$this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit'; $this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit';
@@ -30,7 +32,8 @@ class RateLimit {
* Get client identifier (IP address) * Get client identifier (IP address)
* @return string Client identifier * @return string Client identifier
*/ */
private function getClientIdentifier(): string { private function getClientIdentifier(): string
{
// Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP // Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP
// are fully attacker-controlled request headers and must never be // are fully attacker-controlled request headers and must never be
// trusted for rate-limiting purposes (an attacker can rotate them // trusted for rate-limiting purposes (an attacker can rotate them
@@ -46,7 +49,8 @@ class RateLimit {
* @param string $identifier Client identifier * @param string $identifier Client identifier
* @return string File path * @return string File path
*/ */
private function getCacheFile($identifier) { private function getCacheFile($identifier)
{
return $this->cacheDir . '/' . md5($identifier) . '.json'; return $this->cacheDir . '/' . md5($identifier) . '.json';
} }
@@ -56,7 +60,8 @@ class RateLimit {
* *
* @return bool True if allowed, false if rate limit exceeded * @return bool True if allowed, false if rate limit exceeded
*/ */
public function checkKey(string $key): bool { public function checkKey(string $key): bool
{
$file = $this->getCacheFile($key); $file = $this->getCacheFile($key);
$data = []; $data = [];
@@ -83,7 +88,8 @@ class RateLimit {
* Check if client has exceeded rate limit * Check if client has exceeded rate limit
* @return bool True if allowed, false if rate limit exceeded * @return bool True if allowed, false if rate limit exceeded
*/ */
public function check() { public function check()
{
$identifier = $this->getClientIdentifier(); $identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier); $file = $this->getCacheFile($identifier);
@@ -120,7 +126,8 @@ class RateLimit {
* Get remaining requests for current client * Get remaining requests for current client
* @return int Number of requests remaining * @return int Number of requests remaining
*/ */
public function getRemaining() { public function getRemaining()
{
$identifier = $this->getClientIdentifier(); $identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier); $file = $this->getCacheFile($identifier);
@@ -144,7 +151,8 @@ class RateLimit {
* Get time until rate limit resets * Get time until rate limit resets
* @return int Seconds until reset * @return int Seconds until reset
*/ */
public function getResetTime() { public function getResetTime()
{
$identifier = $this->getClientIdentifier(); $identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier); $file = $this->getCacheFile($identifier);
@@ -170,7 +178,8 @@ class RateLimit {
* Clean up old cache files (run periodically) * Clean up old cache files (run periodically)
* Removes files that haven't been modified in 24 hours * Removes files that haven't been modified in 24 hours
*/ */
public function cleanup() { public function cleanup()
{
$files = glob($this->cacheDir . '/*.json'); $files = glob($this->cacheDir . '/*.json');
$cutoff = time() - 86400; // 24 hours $cutoff = time() - 86400; // 24 hours
@@ -185,7 +194,8 @@ class RateLimit {
* Send rate limit headers * Send rate limit headers
* Provides information about rate limits to clients * Provides information about rate limits to clients
*/ */
public function sendHeaders() { public function sendHeaders()
{
header('X-RateLimit-Limit: ' . $this->maxRequests); header('X-RateLimit-Limit: ' . $this->maxRequests);
header('X-RateLimit-Remaining: ' . $this->getRemaining()); header('X-RateLimit-Remaining: ' . $this->getRemaining());
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime())); header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* ShareLink — model for student-access share links. * ShareLink — model for student-access share links.
* *
@@ -58,8 +59,8 @@ class ShareLink
: null; : null;
$stmt = $this->db->getConnection()->prepare( $stmt = $this->db->getConnection()->prepare(
"INSERT INTO share_links (slug, objet_restriction, password_hash, is_active, created_by, expires_at) 'INSERT INTO share_links (slug, objet_restriction, password_hash, is_active, created_by, expires_at)
VALUES (?, ?, ?, 1, ?, ?)" VALUES (?, ?, ?, 1, ?, ?)'
); );
$stmt->execute([$slug, $objetRestriction, $passwordHash, $createdBy, $expiresAt]); $stmt->execute([$slug, $objetRestriction, $passwordHash, $createdBy, $expiresAt]);
@@ -74,7 +75,7 @@ class ShareLink
public function findBySlug(string $slug): ?array public function findBySlug(string $slug): ?array
{ {
$stmt = $this->db->getConnection()->prepare( $stmt = $this->db->getConnection()->prepare(
"SELECT * FROM share_links WHERE slug = ?" 'SELECT * FROM share_links WHERE slug = ?'
); );
$stmt->execute([$slug]); $stmt->execute([$slug]);
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -89,7 +90,7 @@ class ShareLink
public function findById(int $id): ?array public function findById(int $id): ?array
{ {
$stmt = $this->db->getConnection()->prepare( $stmt = $this->db->getConnection()->prepare(
"SELECT * FROM share_links WHERE id = ?" 'SELECT * FROM share_links WHERE id = ?'
); );
$stmt->execute([$id]); $stmt->execute([$id]);
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -104,7 +105,7 @@ class ShareLink
public function listAll(): array public function listAll(): array
{ {
$stmt = $this->db->getConnection()->query( $stmt = $this->db->getConnection()->query(
"SELECT * FROM share_links ORDER BY created_at DESC" 'SELECT * FROM share_links ORDER BY created_at DESC'
); );
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
@@ -115,7 +116,7 @@ class ShareLink
public function toggleActive(int $id): void public function toggleActive(int $id): void
{ {
$this->db->getConnection()->prepare( $this->db->getConnection()->prepare(
"UPDATE share_links SET is_active = NOT is_active WHERE id = ?" 'UPDATE share_links SET is_active = NOT is_active WHERE id = ?'
)->execute([$id]); )->execute([$id]);
} }
@@ -128,7 +129,7 @@ class ShareLink
{ {
$hash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null; $hash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$this->db->getConnection()->prepare( $this->db->getConnection()->prepare(
"UPDATE share_links SET password_hash = ? WHERE id = ?" 'UPDATE share_links SET password_hash = ? WHERE id = ?'
)->execute([$hash, $id]); )->execute([$hash, $id]);
} }
@@ -138,7 +139,7 @@ class ShareLink
public function delete(int $id): void public function delete(int $id): void
{ {
$this->db->getConnection()->prepare( $this->db->getConnection()->prepare(
"DELETE FROM share_links WHERE id = ?" 'DELETE FROM share_links WHERE id = ?'
)->execute([$id]); )->execute([$id]);
} }
@@ -148,7 +149,7 @@ class ShareLink
public function incrementUsage(int $id): void public function incrementUsage(int $id): void
{ {
$this->db->getConnection()->prepare( $this->db->getConnection()->prepare(
"UPDATE share_links SET usage_count = usage_count + 1 WHERE id = ?" 'UPDATE share_links SET usage_count = usage_count + 1 WHERE id = ?'
)->execute([$id]); )->execute([$id]);
} }

View File

@@ -7,7 +7,8 @@
* field values: 'smtp_host', 'smtp_port', 'smtp_encryption', * field values: 'smtp_host', 'smtp_port', 'smtp_encryption',
* 'smtp_username', 'smtp_password', or null (unknown) * 'smtp_username', 'smtp_password', or null (unknown)
*/ */
class SmtpProbeException extends \RuntimeException { class SmtpProbeException extends \RuntimeException
{
public function __construct( public function __construct(
string $message, string $message,
public readonly ?string $field = null public readonly ?string $field = null
@@ -25,7 +26,8 @@ class SmtpProbeException extends \RuntimeException {
* 421 / 450 / 451 — transient failures (try again later) * 421 / 450 / 451 — transient failures (try again later)
* 530 / 535 — authentication failure * 530 / 535 — authentication failure
*/ */
class SmtpSendException extends \RuntimeException { class SmtpSendException extends \RuntimeException
{
public function __construct( public function __construct(
string $message, string $message,
public readonly int $smtpCode = 0, public readonly int $smtpCode = 0,
@@ -51,8 +53,8 @@ class SmtpSendException extends \RuntimeException {
* 3. Send via native SMTP socket (STARTTLS / SSL / plain). * 3. Send via native SMTP socket (STARTTLS / SSL / plain).
* 4. Probe credentials without sending any message (for validation on save). * 4. Probe credentials without sending any message (for validation on save).
*/ */
class SmtpRelay { class SmtpRelay
{
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// DB operations // DB operations
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -62,10 +64,11 @@ class SmtpRelay {
* *
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string,notify_email:string} * @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string,notify_email:string}
*/ */
public static function getSettings(Database $db): array { public static function getSettings(Database $db): array
{
$stmt = $db->getPDO()->query( $stmt = $db->getPDO()->query(
"SELECT host, port, encryption, username, password, from_email, from_name, notify_email 'SELECT host, port, encryption, username, password, from_email, from_name, notify_email
FROM v_smtp_active LIMIT 1" FROM v_smtp_active LIMIT 1'
); );
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -98,7 +101,8 @@ class SmtpRelay {
* @param array $data Keys: host, port, encryption, username, password, * @param array $data Keys: host, port, encryption, username, password,
* from_email, from_name. Missing keys are left unchanged. * from_email, from_name. Missing keys are left unchanged.
*/ */
public static function updateSettings(Database $db, array $data): void { public static function updateSettings(Database $db, array $data): void
{
$current = self::getSettings($db); $current = self::getSettings($db);
$merged = array_merge($current, $data); $merged = array_merge($current, $data);
@@ -107,7 +111,7 @@ class SmtpRelay {
? $merged['encryption'] : 'tls'; ? $merged['encryption'] : 'tls';
$stmt = $db->getPDO()->prepare( $stmt = $db->getPDO()->prepare(
"UPDATE smtp_settings 'UPDATE smtp_settings
SET host = :host, SET host = :host,
port = :port, port = :port,
encryption = :encryption, encryption = :encryption,
@@ -117,7 +121,7 @@ class SmtpRelay {
from_name = :from_name, from_name = :from_name,
notify_email = :notify_email, notify_email = :notify_email,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = 1" WHERE id = 1'
); );
$stmt->execute([ $stmt->execute([
@@ -135,7 +139,8 @@ class SmtpRelay {
/** /**
* Check whether the SMTP relay is fully configured. * Check whether the SMTP relay is fully configured.
*/ */
public static function isConfigured(Database $db): bool { public static function isConfigured(Database $db): bool
{
$s = self::getSettings($db); $s = self::getSettings($db);
return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== ''; return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
} }
@@ -186,7 +191,8 @@ class SmtpRelay {
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = ''; $errno = 0;
$errstr = '';
$ctx = stream_context_create([ $ctx = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => true, 'verify_peer' => true,
@@ -196,8 +202,12 @@ class SmtpRelay {
], ],
]); ]);
$sock = @stream_socket_client( $sock = @stream_socket_client(
"{$connectHost}:{$port}", $errno, $errstr, $timeout, "{$connectHost}:{$port}",
STREAM_CLIENT_CONNECT, $ctx $errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx
); );
if ($sock === false) { if ($sock === false) {
$isNameFail = ( $isNameFail = (
@@ -234,9 +244,13 @@ class SmtpRelay {
$buf = ''; $buf = '';
while (($line = fgets($sock, 512)) !== false) { while (($line = fgets($sock, 512)) !== false) {
$buf .= $line; $buf .= $line;
if (isset($line[3]) && $line[3] === ' ') break; if (isset($line[3]) && $line[3] === ' ') {
break;
}
$meta = stream_get_meta_data($sock); $meta = stream_get_meta_data($sock);
if ($meta['timed_out']) break; if ($meta['timed_out']) {
break;
}
} }
return $buf; return $buf;
}; };
@@ -251,12 +265,12 @@ class SmtpRelay {
$meta = stream_get_meta_data($sock); $meta = stream_get_meta_data($sock);
if ($meta['timed_out']) { if ($meta['timed_out']) {
throw new SmtpProbeException( throw new SmtpProbeException(
"Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?", 'Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?',
'smtp_port' 'smtp_port'
); );
} }
throw new SmtpProbeException( throw new SmtpProbeException(
"Réponse inattendue du serveur : " . json_encode(trim($greeting)), 'Réponse inattendue du serveur : ' . json_encode(trim($greeting)),
'smtp_host' 'smtp_host'
); );
} }
@@ -265,7 +279,9 @@ class SmtpRelay {
$caps = []; $caps = [];
foreach (explode("\n", $resp) as $line) { foreach (explode("\n", $resp) as $line) {
$line = rtrim($line); $line = rtrim($line);
if (strlen($line) > 4) $caps[] = strtoupper(substr($line, 4)); if (strlen($line) > 4) {
$caps[] = strtoupper(substr($line, 4));
}
} }
return $caps; return $caps;
}; };
@@ -281,7 +297,7 @@ class SmtpRelay {
$stResp = $send('STARTTLS'); $stResp = $send('STARTTLS');
if (strncmp($stResp, '220', 3) !== 0) { if (strncmp($stResp, '220', 3) !== 0) {
throw new SmtpProbeException( throw new SmtpProbeException(
"Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou \"Aucun\".", 'Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou "Aucun".',
'smtp_encryption' 'smtp_encryption'
); );
} }
@@ -291,7 +307,7 @@ class SmtpRelay {
stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath()); stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new SmtpProbeException( throw new SmtpProbeException(
"Échec de la négociation TLS — certificat invalide ou port incorrect ?", 'Échec de la négociation TLS — certificat invalide ou port incorrect ?',
'smtp_encryption' 'smtp_encryption'
); );
} }
@@ -303,7 +319,10 @@ class SmtpRelay {
if ($s['username'] !== '') { if ($s['username'] !== '') {
$authLine = ''; $authLine = '';
foreach ($caps as $cap) { foreach ($caps as $cap) {
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; } if (strncmp($cap, 'AUTH', 4) === 0) {
$authLine = $cap;
break;
}
} }
$mechanisms = preg_split('/[\s=]+/', $authLine); $mechanisms = preg_split('/[\s=]+/', $authLine);
@@ -315,7 +334,7 @@ class SmtpRelay {
$code = substr(trim($resp), 0, 3); $code = substr(trim($resp), 0, 3);
$field = ($code === '535') ? 'smtp_password' : 'smtp_username'; $field = ($code === '535') ? 'smtp_password' : 'smtp_username';
throw new SmtpProbeException( throw new SmtpProbeException(
"Authentification refusée : " . trim($resp), 'Authentification refusée : ' . trim($resp),
$field $field
); );
} }
@@ -338,7 +357,7 @@ class SmtpRelay {
$r3 = $send(base64_encode($s['password'])); $r3 = $send(base64_encode($s['password']));
if (strncmp($r3, '235', 3) !== 0) { if (strncmp($r3, '235', 3) !== 0) {
throw new SmtpProbeException( throw new SmtpProbeException(
"Mot de passe refusé : " . trim($r3), 'Mot de passe refusé : ' . trim($r3),
'smtp_password' 'smtp_password'
); );
} }
@@ -382,7 +401,7 @@ class SmtpRelay {
$boundary = 'xamxam_' . bin2hex(random_bytes(8)); $boundary = 'xamxam_' . bin2hex(random_bytes(8));
$date = date('r'); $date = date('r');
$fromHdr = ($s['from_name'] ?? '') !== '' $fromHdr = ($s['from_name'] ?? '') !== ''
? "=?UTF-8?B?" . base64_encode($s['from_name']) . "?= <{$s['from_email']}>" ? '=?UTF-8?B?' . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
: $s['from_email']; : $s['from_email'];
$subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?='; $subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?=';
@@ -456,7 +475,8 @@ class SmtpRelay {
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = ''; $errno = 0;
$errstr = '';
$ctx = stream_context_create([ $ctx = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => true, 'verify_peer' => true,
@@ -466,8 +486,12 @@ class SmtpRelay {
], ],
]); ]);
$sock = @stream_socket_client( $sock = @stream_socket_client(
"{$connectHost}:{$port}", $errno, $errstr, $timeout, "{$connectHost}:{$port}",
STREAM_CLIENT_CONNECT, $ctx $errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx
); );
if ($sock === false) { if ($sock === false) {
throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]"); throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]");
@@ -478,9 +502,13 @@ class SmtpRelay {
$buf = ''; $buf = '';
while (($line = fgets($sock, 512)) !== false) { while (($line = fgets($sock, 512)) !== false) {
$buf .= $line; $buf .= $line;
if (isset($line[3]) && $line[3] === ' ') break; if (isset($line[3]) && $line[3] === ' ') {
break;
}
$meta = stream_get_meta_data($sock); $meta = stream_get_meta_data($sock);
if ($meta['timed_out']) break; if ($meta['timed_out']) {
break;
}
} }
return $buf; return $buf;
}; };
@@ -512,7 +540,9 @@ class SmtpRelay {
$caps = []; $caps = [];
foreach (explode("\n", $resp) as $line) { foreach (explode("\n", $resp) as $line) {
$line = rtrim($line); $line = rtrim($line);
if (strlen($line) > 4) $caps[] = strtoupper(substr($line, 4)); if (strlen($line) > 4) {
$caps[] = strtoupper(substr($line, 4));
}
} }
return $caps; return $caps;
}; };
@@ -539,7 +569,10 @@ class SmtpRelay {
if ($s['username'] !== '') { if ($s['username'] !== '') {
$authLine = ''; $authLine = '';
foreach ($caps as $cap) { foreach ($caps as $cap) {
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; } if (strncmp($cap, 'AUTH', 4) === 0) {
$authLine = $cap;
break;
}
} }
$mechanisms = preg_split('/[\s=]+/', $authLine); $mechanisms = preg_split('/[\s=]+/', $authLine);
if (in_array('PLAIN', $mechanisms, true)) { if (in_array('PLAIN', $mechanisms, true)) {
@@ -613,12 +646,15 @@ class SmtpRelay {
'/usr/local/share/certs/ca-root-nss.crt', '/usr/local/share/certs/ca-root-nss.crt',
]; ];
foreach ($candidates as $path) { foreach ($candidates as $path) {
if (file_exists($path)) return $path; if (file_exists($path)) {
return $path;
}
} }
return null; // PHP will fall back to its compiled-in bundle return null; // PHP will fall back to its compiled-in bundle
} }
private static function htmlToPlain(string $html): string { private static function htmlToPlain(string $html): string
{
$text = strip_tags($html); $text = strip_tags($html);
$text = preg_replace('/\n{3,}/', "\n\n", $text); $text = preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text); return trim($text);

View File

@@ -7,15 +7,16 @@
* The e-mail is addressed to the confirmation e-mail field provided * The e-mail is addressed to the confirmation e-mail field provided
* by the student and contains a recap of every field submitted. * by the student and contains a recap of every field submitted.
*/ */
class StudentEmail { class StudentEmail
{
/** /**
* Build the HTML body for the confirmation e-mail. * Build the HTML body for the confirmation e-mail.
* *
* @param array $thesis Thesis row (from getThesis / v_theses_full) * @param array $thesis Thesis row (from getThesis / v_theses_full)
* @return string HTML body * @return string HTML body
*/ */
private static function buildHtml(array $thesis): string { private static function buildHtml(array $thesis): string
{
$rows = ''; $rows = '';
$fields = [ $fields = [
'Identifiant' => $thesis['identifier'] ?? '', 'Identifiant' => $thesis['identifier'] ?? '',
@@ -42,7 +43,7 @@ class StudentEmail {
foreach ($fields as $label => $value) { foreach ($fields as $label => $value) {
$v = $value === '' ? '' : htmlspecialchars((string)$value); $v = $value === '' ? '' : htmlspecialchars((string)$value);
$rows .= "<tr><th style='text-align:left;padding:6px 10px;border-bottom:1px solid #eee'>" $rows .= "<tr><th style='text-align:left;padding:6px 10px;border-bottom:1px solid #eee'>"
. htmlspecialchars($label) . "</th>" . htmlspecialchars($label) . '</th>'
. "<td style='padding:6px 10px;border-bottom:1px solid #eee'>{$v}</td></tr>\n"; . "<td style='padding:6px 10px;border-bottom:1px solid #eee'>{$v}</td></tr>\n";
} }

View File

@@ -4,6 +4,12 @@
<?= htmlspecialchars($flash['error']) ?> <?= htmlspecialchars($flash['error']) ?>
</p> </p>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($flash['warning'])): ?>
<p class="toast toast--warning" role="alert">
<span class="toast__icon" aria-hidden="true">⚠</span>
<?= $flash['warning'] /* pre-sanitised HTML allowed for the duplicate link */ ?>
</p>
<?php endif; ?>
<?php if ($flash['success']): ?> <?php if ($flash['success']): ?>
<p class="toast toast--success" role="status"> <p class="toast toast--success" role="status">
<span class="toast__icon" aria-hidden="true">✓</span> <span class="toast__icon" aria-hidden="true">✓</span>

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Search Functionality Test * Search Functionality Test
* Tests search queries and results * Tests search queries and results
@@ -18,9 +19,9 @@ try {
echo "Test 1: Empty Search Query\n"; echo "Test 1: Empty Search Query\n";
$results = $db->searchTheses([]); $results = $db->searchTheses([]);
if (is_array($results)) { if (is_array($results)) {
echo "✓ PASS: Empty query handled (returned " . count($results) . " results)\n\n"; echo '✓ PASS: Empty query handled (returned ' . count($results) . " results)\n\n";
} else { } else {
throw new Exception("Invalid results for empty query"); throw new Exception('Invalid results for empty query');
} }
// Test 2: Search for specific term // Test 2: Search for specific term
@@ -30,7 +31,7 @@ try {
if (is_array($results)) { if (is_array($results)) {
echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n"; echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n";
} else { } else {
throw new Exception("Invalid search results"); throw new Exception('Invalid search results');
} }
// Test 3: Search with special characters // Test 3: Search with special characters
@@ -39,7 +40,7 @@ try {
if (is_array($results)) { if (is_array($results)) {
echo "✓ PASS: Special characters handled safely\n\n"; echo "✓ PASS: Special characters handled safely\n\n";
} else { } else {
throw new Exception("Failed to handle special characters"); throw new Exception('Failed to handle special characters');
} }
// Test 4: Tag-filter search using the new EXISTS subquery // Test 4: Tag-filter search using the new EXISTS subquery
@@ -48,11 +49,11 @@ try {
if (is_array($tagResults)) { if (is_array($tagResults)) {
echo "✓ PASS: Tag search for 'urbanisme' returned " . count($tagResults) . " result(s)\n"; echo "✓ PASS: Tag search for 'urbanisme' returned " . count($tagResults) . " result(s)\n";
foreach ($tagResults as $r) { foreach ($tagResults as $r) {
echo " - " . $r['title'] . " (" . $r['year'] . ")\n"; echo ' - ' . $r['title'] . ' (' . $r['year'] . ")\n";
} }
echo "\n"; echo "\n";
} else { } else {
throw new Exception("Tag search returned non-array"); throw new Exception('Tag search returned non-array');
} }
// Test 5: Tag search in full-text query (query touches tag subquery) // Test 5: Tag search in full-text query (query touches tag subquery)
@@ -61,7 +62,7 @@ try {
if (is_array($allResults)) { if (is_array($allResults)) {
echo "✓ PASS: Query 'narration' returned " . count($allResults) . " result(s)\n\n"; echo "✓ PASS: Query 'narration' returned " . count($allResults) . " result(s)\n\n";
} else { } else {
throw new Exception("Full-text query with tag subquery failed"); throw new Exception('Full-text query with tag subquery failed');
} }
// Test 6: countSearchResults matches searchTheses // Test 6: countSearchResults matches searchTheses
@@ -72,13 +73,13 @@ try {
if ($count === count($rows)) { if ($count === count($rows)) {
echo "✓ PASS: count=$count matches row count\n\n"; echo "✓ PASS: count=$count matches row count\n\n";
} else { } else {
throw new Exception("countSearchResults ($count) != searchTheses row count (" . count($rows) . ")"); throw new Exception("countSearchResults ($count) != searchTheses row count (" . count($rows) . ')');
} }
echo "✅ All search tests passed!\n"; echo "✅ All search tests passed!\n";
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n"; echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false; return false;
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Security Test Suite * Security Test Suite
* Tests SQL injection protection and input sanitization * Tests SQL injection protection and input sanitization
@@ -31,22 +32,22 @@ try {
try { try {
$results = $db->searchTheses(['query' => $query]); $results = $db->searchTheses(['query' => $query]);
// Should return a (possibly empty) result set without throwing // Should return a (possibly empty) result set without throwing
echo " ✓ Handled safely: " . substr($query, 0, 40) . "\n"; echo ' ✓ Handled safely: ' . substr($query, 0, 40) . "\n";
} catch (Exception $e) { } catch (Exception $e) {
// A thrown exception is also acceptable (query rejected upstream) // A thrown exception is also acceptable (query rejected upstream)
echo " ✓ Exception (safe): " . substr($query, 0, 40) . "\n"; echo ' ✓ Exception (safe): ' . substr($query, 0, 40) . "\n";
} }
} }
echo "✓ PASS: SQL injection attempts handled safely\n\n"; echo "✓ PASS: SQL injection attempts handled safely\n\n";
// Test 2: Invalid thesis ID // Test 2: Invalid thesis ID
echo "Test 2: Invalid Thesis ID\n"; echo "Test 2: Invalid Thesis ID\n";
$invalidIds = ["abc", "'; DROP TABLE theses;", "-1", "999999"]; $invalidIds = ['abc', "'; DROP TABLE theses;", '-1', '999999'];
foreach ($invalidIds as $id) { foreach ($invalidIds as $id) {
$result = $db->getThesisById($id); $result = $db->getThesisById($id);
if ($result === null || $result === false) { if ($result === null || $result === false) {
echo " ✓ Rejected: " . $id . "\n"; echo ' ✓ Rejected: ' . $id . "\n";
} else { } else {
throw new Exception("Invalid ID '$id' was not rejected"); throw new Exception("Invalid ID '$id' was not rejected");
} }
@@ -69,6 +70,6 @@ try {
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n"; echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false; return false;
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Database Connection Test * Database Connection Test
* Tests basic database connectivity and query functionality * Tests basic database connectivity and query functionality
@@ -24,16 +25,16 @@ try {
if ($count >= 0) { if ($count >= 0) {
echo "✓ PASS: Found {$count} published theses\n\n"; echo "✓ PASS: Found {$count} published theses\n\n";
} else { } else {
throw new Exception("Invalid count returned"); throw new Exception('Invalid count returned');
} }
// Test 3: Get published theses // Test 3: Get published theses
echo "Test 3: Get Published Theses\n"; echo "Test 3: Get Published Theses\n";
$theses = $db->getPublishedTheses(5, 0); $theses = $db->getPublishedTheses(5, 0);
if (is_array($theses)) { if (is_array($theses)) {
echo "✓ PASS: Retrieved " . count($theses) . " theses\n\n"; echo '✓ PASS: Retrieved ' . count($theses) . " theses\n\n";
} else { } else {
throw new Exception("Invalid theses array returned"); throw new Exception('Invalid theses array returned');
} }
// Test 4: Get single thesis (if any exist) // Test 4: Get single thesis (if any exist)
@@ -44,11 +45,11 @@ try {
if ($thesis && isset($thesis['id'])) { if ($thesis && isset($thesis['id'])) {
echo "✓ PASS: Successfully retrieved thesis #{$first['id']}\n"; echo "✓ PASS: Successfully retrieved thesis #{$first['id']}\n";
echo " Title: " . $thesis['title'] . "\n"; echo ' Title: ' . $thesis['title'] . "\n";
echo " Author(s): " . ($thesis['authors'] ?? 'N/A') . "\n"; echo ' Author(s): ' . ($thesis['authors'] ?? 'N/A') . "\n";
echo " Year: " . $thesis['year'] . "\n\n"; echo ' Year: ' . $thesis['year'] . "\n\n";
} else { } else {
throw new Exception("Failed to retrieve thesis by ID"); throw new Exception('Failed to retrieve thesis by ID');
} }
} }
@@ -69,15 +70,15 @@ try {
echo "Test 6: getUsedTags returns name column\n"; echo "Test 6: getUsedTags returns name column\n";
$tags = $db->getUsedTags(); $tags = $db->getUsedTags();
if (is_array($tags) && (empty($tags) || isset($tags[0]['name']))) { if (is_array($tags) && (empty($tags) || isset($tags[0]['name']))) {
echo "✓ PASS: getUsedTags returned " . count($tags) . " tags with 'name' column\n\n"; echo '✓ PASS: getUsedTags returned ' . count($tags) . " tags with 'name' column\n\n";
} else { } else {
throw new Exception("getUsedTags did not return expected structure: " . json_encode($tags[0] ?? [])); throw new Exception('getUsedTags did not return expected structure: ' . json_encode($tags[0] ?? []));
} }
echo "✅ All database tests passed!\n"; echo "✅ All database tests passed!\n";
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n"; echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false; return false;
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Rate Limit Test * Rate Limit Test
* Tests rate limiting functionality * Tests rate limiting functionality
@@ -19,9 +20,9 @@ try {
echo "Test 2: Check Method\n"; echo "Test 2: Check Method\n";
$allowed = $rateLimit->check(); $allowed = $rateLimit->check();
if (is_bool($allowed)) { if (is_bool($allowed)) {
echo "✓ PASS: check() returns boolean (allowed: " . ($allowed ? 'yes' : 'no') . ")\n\n"; echo '✓ PASS: check() returns boolean (allowed: ' . ($allowed ? 'yes' : 'no') . ")\n\n";
} else { } else {
throw new Exception("check() did not return boolean"); throw new Exception('check() did not return boolean');
} }
// Test 3: Headers method // Test 3: Headers method
@@ -37,7 +38,7 @@ try {
if (is_int($resetTime) && $resetTime >= 0) { if (is_int($resetTime) && $resetTime >= 0) {
echo "✓ PASS: getResetTime() returns valid value ($resetTime seconds)\n\n"; echo "✓ PASS: getResetTime() returns valid value ($resetTime seconds)\n\n";
} else { } else {
throw new Exception("Invalid reset time"); throw new Exception('Invalid reset time');
} }
// Test 5: Cleanup method // Test 5: Cleanup method
@@ -49,6 +50,6 @@ try {
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n"; echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false; return false;
} }

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/** /**
* XAMXAM Test Runner * XAMXAM Test Runner
* Runs all tests in the tests/ directory * Runs all tests in the tests/ directory
@@ -23,7 +24,7 @@ $skippedTests = 0;
foreach ($testFiles as $test) { foreach ($testFiles as $test) {
echo "┌─────────────────────────────────────────┐\n"; echo "┌─────────────────────────────────────────┐\n";
echo "" . str_pad($test['name'], 41) . "\n"; echo '│ ' . str_pad($test['name'], 41) . "\n";
echo "└─────────────────────────────────────────┘\n\n"; echo "└─────────────────────────────────────────┘\n\n";
$totalTests++; $totalTests++;
@@ -52,7 +53,7 @@ foreach ($testFiles as $test) {
} }
} catch (Exception $e) { } catch (Exception $e) {
$exitCode = 1; $exitCode = 1;
echo "❌ EXCEPTION: " . $e->getMessage() . "\n"; echo '❌ EXCEPTION: ' . $e->getMessage() . "\n";
} }
$output = ob_get_clean(); $output = ob_get_clean();
@@ -70,11 +71,11 @@ foreach ($testFiles as $test) {
echo "╔════════════════════════════════════════════╗\n"; echo "╔════════════════════════════════════════════╗\n";
echo "║ Test Summary ║\n"; echo "║ Test Summary ║\n";
echo "╠════════════════════════════════════════════╣\n"; echo "╠════════════════════════════════════════════╣\n";
echo "║ Total: " . str_pad($totalTests, 34) . "\n"; echo '║ Total: ' . str_pad($totalTests, 34) . "\n";
echo "║ Passed: " . str_pad($passedTests . "", 35) . "\n"; echo '║ Passed: ' . str_pad($passedTests . ' ✅', 35) . "\n";
echo "║ Failed: " . str_pad($failedTests . ($failedTests > 0 ? "" : ""), 35) . "\n"; echo '║ Failed: ' . str_pad($failedTests . ($failedTests > 0 ? ' ❌' : ''), 35) . "\n";
if ($skippedTests > 0) { if ($skippedTests > 0) {
echo "║ Skipped: " . str_pad($skippedTests . " ⚠️", 36) . "\n"; echo '║ Skipped: ' . str_pad($skippedTests . ' ⚠️', 36) . "\n";
} }
echo "╚════════════════════════════════════════════╝\n\n"; echo "╚════════════════════════════════════════════╝\n\n";

View File

@@ -1,3 +1,6 @@
default:
@just --list
# XAMXAM Justfile # XAMXAM Justfile
# ============================================================================ # ============================================================================