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
- [x] Merge `deploy-*` recipes into a single `deploy-script` recipe
- [x] Remove rarely used recipes (`show id`, `setup-dirs`)
- [x] Simplify `test-*` recipes
- [x] Remove redundant `default` recipe
- [x] Preserve all critical functionality
- [x] Enhance `serve` recipe to automatically open the browser
- [x] Keep `serve` recipe in the foreground (browser open backgrounded, PHP server blocks)
- [x] Add `psalm` recipe (auto-inits config on first run, then analyses)
- [x] Fix all genuine Psalm errors (InvalidOperand, UnusedVariable, InvalidReturnType, NullableReturnStatement, InvalidArrayOffset, UnusedForeachValue, RedundantFunctionCall)
- [x] Generate psalm-baseline.xml to suppress false positives (UndefinedConstant, PossiblyUnused*, UnusedClass)
- [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
## Duplicate TFE submission prevention
- [x] `DuplicateThesisException` — typed exception carrying existing thesis metadata
- [x] `Database::findDuplicateThesis()` — year + author + normalised-title matching (exact, prefix, Levenshtein ≤10%)
- [x] `ThesisCreateController::submit()` — calls duplicate check before any DB write, throws `DuplicateThesisException`
- [x] `AppLogger::logDuplicate()` — dedicated log action (`status: duplicate`) for audit trail
- [x] `App::flash/consumeFlash` — extended to support `warning` type alongside `error`/`success`
- [x] `admin/actions/formulaire.php` — catches `DuplicateThesisException` separately; logs it; flashes HTML warning with link to existing thesis; repopulates form
- [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] `toast.php` — renders `toast--warning` block
- [x] `admin.css``.toast--warning` style + link colour
- [x] `form.css``.flash-warning` style (partage form)

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/AppLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$logger = new AppLogger();
$authorName = $_POST['auteurice'] ?? 'unknown';
@@ -41,6 +42,24 @@ try {
header('Location: ' . $redirect);
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) {
$logger->logError('admin', $e->getMessage(), [
'author' => $authorName,

View File

@@ -182,6 +182,17 @@
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 {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }

View File

@@ -335,6 +335,7 @@ label:has(+ div > input:required)::after {
/* ── Flash messages ─────────────────────────────────────────────────────── */
.flash-error,
.flash-warning,
.flash-success {
padding: var(--space-xs) var(--space-s);
border-radius: 4px;
@@ -355,6 +356,12 @@ label:has(+ div > input:required)::after {
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-badge {
display: inline-block;

View File

@@ -259,12 +259,16 @@ function renderShareLinkForm(string $slug, array $link): void
<?php
// Show flash messages from error redirect
$flashError = $_SESSION['_flash_error'] ?? null;
$flashWarning = $_SESSION['_flash_warning'] ?? 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): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div>
<?php endif; ?>
<?php if ($flashWarning): ?>
<div class="flash-warning" role="alert"><?= htmlspecialchars($flashWarning) ?></div>
<?php endif; ?>
<?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
<?php endif; ?>
@@ -434,6 +438,7 @@ function handleShareLinkSubmission(string $slug): void
require_once APP_ROOT . '/src/SmtpRelay.php';
require_once APP_ROOT . '/src/StudentEmail.php';
require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$logger = new AppLogger();
$authorName = $_POST['auteurice'] ?? 'unknown';
@@ -474,6 +479,24 @@ function handleShareLinkSubmission(string $slug): void
// Redirect to thanks page
header('Location: /partage/recapitulatif?id=' . urlencode((string)$thesisId));
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) {
$logger->logError('partage', $e->getMessage(), [
'share_slug' => $slug,

View File

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

View File

@@ -1,4 +1,5 @@
<?php
/**
* Thin application helper — centralises bootstrap, auth gating, CSRF lifecycle,
* flash messages, redirect, and template rendering.
@@ -79,7 +80,7 @@ class App
/**
* 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
{
@@ -109,17 +110,18 @@ class App
/**
* 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
{
$error = $_SESSION['_flash_error'] ?? 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().
return ['error' => $error, 'success' => $success];
return ['error' => $error, 'success' => $success, 'warning' => $warning];
}
// ── Redirect ──────────────────────────────────────────────────────────────

View File

@@ -1,4 +1,5 @@
<?php
/**
* Structured application logger for form submissions.
*
@@ -43,6 +44,32 @@ class AppLogger
], $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.
*

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');
@@ -20,7 +26,7 @@ class AboutController {
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
$credits = is_array($credits) && !empty($credits) ? $credits : null;
} catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage());
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'));
@@ -165,8 +168,13 @@ class MediaController
[$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; }
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,7 +318,7 @@ 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

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',
@@ -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
*
@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
<?php
/**
* Front-controller Dispatcher
*
@@ -17,7 +18,8 @@
* /partage/<slug> → share-link flow
* /maintenance.php → static maintenance page
*/
class Dispatcher {
class Dispatcher
{
private const ROUTES = [
'' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
'/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
@@ -37,7 +39,8 @@ class Dispatcher {
private string $path;
private array $queryParams;
public function __construct() {
public function __construct()
{
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$this->path = $uri;
$this->queryParams = $_GET;
@@ -47,7 +50,8 @@ class Dispatcher {
* Resolve the URI to a route, instantiate the controller,
* execute the action, and render the view.
*/
public function dispatch(): void {
public function dispatch(): void
{
// 1. Direct-response endpoints (render their own output)
$direct = $this->matchDirect();
if ($direct) {
@@ -81,12 +85,13 @@ class Dispatcher {
/**
* Match endpoints that render their own response (no view layer).
*/
private function matchDirect(): ?callable {
private function matchDirect(): ?callable
{
$path = $this->path;
// /live-reload
if ($path === '/live-reload' || $path === '/live-reload.php') {
return function() {
return function () {
require_once APP_ROOT . '/src/Controllers/LiveReloadController.php';
$controller = new LiveReloadController(APP_ROOT);
$result = $controller->handle();
@@ -97,7 +102,7 @@ class Dispatcher {
// /media.php
if ($path === '/media' || $path === '/media.php') {
return function() {
return function () {
require_once APP_ROOT . '/src/Controllers/MediaController.php';
$controller = new MediaController();
$controller->handle();
@@ -106,14 +111,14 @@ class Dispatcher {
// /maintenance.php
if ($path === '/maintenance' || $path === '/maintenance.php') {
return function() {
return function () {
require APP_ROOT . '/public/maintenance.php';
};
}
// /repertoire/student-preview (HTMX popover)
if ($path === '/repertoire/student-preview') {
return function() {
return function () {
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/RateLimit.php';
require_once APP_ROOT . '/src/Controllers/SearchController.php';
@@ -124,28 +129,28 @@ class Dispatcher {
// /partage/retry-email (GET: show retry form, POST: resend)
if ($path === '/partage/retry-email') {
return function() {
return function () {
require APP_ROOT . '/public/partage/retry-email.php';
};
}
// /partage/*
if (preg_match('#^/partage(/.*)?$#', $path)) {
return function() {
return function () {
require APP_ROOT . '/public/partage/index.php';
};
}
// /validate-access (GET: confirmation page, POST: token redemption)
if ($path === '/validate-access' || $path === '/validate-access.php') {
return function() {
return function () {
require APP_ROOT . '/public/validate-access.php';
};
}
// /request-access (POST: submit access request)
if ($path === '/request-access' || $path === '/request-access.php') {
return function() {
return function () {
require APP_ROOT . '/public/request-access.php';
};
}
@@ -157,7 +162,8 @@ class Dispatcher {
* Match the current path against the static route table.
* Supports exact match and prefix-based (for /tfe?id=).
*/
private function matchRoute(): ?array {
private function matchRoute(): ?array
{
$path = $this->path;
// Exact match first
@@ -177,7 +183,8 @@ class Dispatcher {
* Render a view template wrapped in the full page layout.
* 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';
if (!file_exists($viewPath)) {
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
* Prevents abuse by limiting requests per IP address
*/
class RateLimit {
class RateLimit
{
private $cacheDir;
private $maxRequests;
private $timeWindow;
@@ -15,7 +16,8 @@ class RateLimit {
* @param int $timeWindow Time window in seconds
* @param string $cacheDir Directory to store rate limit data
*/
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null) {
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null)
{
$this->maxRequests = $maxRequests;
$this->timeWindow = $timeWindow;
$this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit';
@@ -30,7 +32,8 @@ class RateLimit {
* Get client identifier (IP address)
* @return string Client identifier
*/
private function getClientIdentifier(): string {
private function getClientIdentifier(): string
{
// Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP
// are fully attacker-controlled request headers and must never be
// trusted for rate-limiting purposes (an attacker can rotate them
@@ -46,7 +49,8 @@ class RateLimit {
* @param string $identifier Client identifier
* @return string File path
*/
private function getCacheFile($identifier) {
private function getCacheFile($identifier)
{
return $this->cacheDir . '/' . md5($identifier) . '.json';
}
@@ -56,7 +60,8 @@ class RateLimit {
*
* @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);
$data = [];
@@ -65,7 +70,7 @@ class RateLimit {
}
$now = time();
$data = array_values(array_filter($data, fn($ts) => ($now - $ts) < $this->timeWindow));
$data = array_values(array_filter($data, fn ($ts) => ($now - $ts) < $this->timeWindow));
if (count($data) >= $this->maxRequests) {
return false;
@@ -83,7 +88,8 @@ class RateLimit {
* Check if client has exceeded rate limit
* @return bool True if allowed, false if rate limit exceeded
*/
public function check() {
public function check()
{
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
@@ -96,7 +102,7 @@ class RateLimit {
// Clean old entries outside time window
$now = time();
$data = array_filter($data, function($timestamp) use ($now) {
$data = array_filter($data, function ($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
@@ -120,7 +126,8 @@ class RateLimit {
* Get remaining requests for current client
* @return int Number of requests remaining
*/
public function getRemaining() {
public function getRemaining()
{
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
@@ -133,7 +140,7 @@ class RateLimit {
// Clean old entries
$now = time();
$data = array_filter($data, function($timestamp) use ($now) {
$data = array_filter($data, function ($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
@@ -144,7 +151,8 @@ class RateLimit {
* Get time until rate limit resets
* @return int Seconds until reset
*/
public function getResetTime() {
public function getResetTime()
{
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
@@ -170,7 +178,8 @@ class RateLimit {
* Clean up old cache files (run periodically)
* Removes files that haven't been modified in 24 hours
*/
public function cleanup() {
public function cleanup()
{
$files = glob($this->cacheDir . '/*.json');
$cutoff = time() - 86400; // 24 hours
@@ -185,7 +194,8 @@ class RateLimit {
* Send rate limit headers
* Provides information about rate limits to clients
*/
public function sendHeaders() {
public function sendHeaders()
{
header('X-RateLimit-Limit: ' . $this->maxRequests);
header('X-RateLimit-Remaining: ' . $this->getRemaining());
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));

View File

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

View File

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

View File

@@ -7,21 +7,22 @@
* The e-mail is addressed to the confirmation e-mail field provided
* by the student and contains a recap of every field submitted.
*/
class StudentEmail {
class StudentEmail
{
/**
* Build the HTML body for the confirmation e-mail.
*
* @param array $thesis Thesis row (from getThesis / v_theses_full)
* @return string HTML body
*/
private static function buildHtml(array $thesis): string {
private static function buildHtml(array $thesis): string
{
$rows = '';
$fields = [
'Identifiant' => $thesis['identifier'] ?? '',
'Titre' => $thesis['title'] ?? '',
'Sous-titre' => $thesis['subtitle'] ?? '',
'Auteur·ice(s)'=> $thesis['authors'] ?? '',
'Auteur·ice(s)' => $thesis['authors'] ?? '',
'Année' => $thesis['year'] ?? '',
'Orientation' => $thesis['orientation'] ?? '',
'Atelier pluridisciplinaire' => $thesis['ap_program'] ?? '',
@@ -42,7 +43,7 @@ class StudentEmail {
foreach ($fields as $label => $value) {
$v = $value === '' ? '' : htmlspecialchars((string)$value);
$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";
}

View File

@@ -4,6 +4,12 @@
<?= htmlspecialchars($flash['error']) ?>
</p>
<?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']): ?>
<p class="toast toast--success" role="status">
<span class="toast__icon" aria-hidden="true">✓</span>

View File

@@ -1,4 +1,5 @@
<?php
/**
* Search Functionality Test
* Tests search queries and results
@@ -18,9 +19,9 @@ try {
echo "Test 1: Empty Search Query\n";
$results = $db->searchTheses([]);
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 {
throw new Exception("Invalid results for empty query");
throw new Exception('Invalid results for empty query');
}
// Test 2: Search for specific term
@@ -30,7 +31,7 @@ try {
if (is_array($results)) {
echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n";
} else {
throw new Exception("Invalid search results");
throw new Exception('Invalid search results');
}
// Test 3: Search with special characters
@@ -39,7 +40,7 @@ try {
if (is_array($results)) {
echo "✓ PASS: Special characters handled safely\n\n";
} 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
@@ -48,11 +49,11 @@ try {
if (is_array($tagResults)) {
echo "✓ PASS: Tag search for 'urbanisme' returned " . count($tagResults) . " result(s)\n";
foreach ($tagResults as $r) {
echo " - " . $r['title'] . " (" . $r['year'] . ")\n";
echo ' - ' . $r['title'] . ' (' . $r['year'] . ")\n";
}
echo "\n";
} 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)
@@ -61,7 +62,7 @@ try {
if (is_array($allResults)) {
echo "✓ PASS: Query 'narration' returned " . count($allResults) . " result(s)\n\n";
} 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
@@ -72,13 +73,13 @@ try {
if ($count === count($rows)) {
echo "✓ PASS: count=$count matches row count\n\n";
} 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";
return true;
} catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n";
echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false;
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Security Test Suite
* Tests SQL injection protection and input sanitization
@@ -31,22 +32,22 @@ try {
try {
$results = $db->searchTheses(['query' => $query]);
// 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) {
// 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";
// Test 2: Invalid thesis ID
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) {
$result = $db->getThesisById($id);
if ($result === null || $result === false) {
echo " ✓ Rejected: " . $id . "\n";
echo ' ✓ Rejected: ' . $id . "\n";
} else {
throw new Exception("Invalid ID '$id' was not rejected");
}
@@ -69,6 +70,6 @@ try {
return true;
} catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n";
echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false;
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Database Connection Test
* Tests basic database connectivity and query functionality
@@ -24,16 +25,16 @@ try {
if ($count >= 0) {
echo "✓ PASS: Found {$count} published theses\n\n";
} else {
throw new Exception("Invalid count returned");
throw new Exception('Invalid count returned');
}
// Test 3: Get published theses
echo "Test 3: Get Published Theses\n";
$theses = $db->getPublishedTheses(5, 0);
if (is_array($theses)) {
echo "✓ PASS: Retrieved " . count($theses) . " theses\n\n";
echo '✓ PASS: Retrieved ' . count($theses) . " theses\n\n";
} else {
throw new Exception("Invalid theses array returned");
throw new Exception('Invalid theses array returned');
}
// Test 4: Get single thesis (if any exist)
@@ -44,11 +45,11 @@ try {
if ($thesis && isset($thesis['id'])) {
echo "✓ PASS: Successfully retrieved thesis #{$first['id']}\n";
echo " Title: " . $thesis['title'] . "\n";
echo " Author(s): " . ($thesis['authors'] ?? 'N/A') . "\n";
echo " Year: " . $thesis['year'] . "\n\n";
echo ' Title: ' . $thesis['title'] . "\n";
echo ' Author(s): ' . ($thesis['authors'] ?? 'N/A') . "\n";
echo ' Year: ' . $thesis['year'] . "\n\n";
} 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";
$tags = $db->getUsedTags();
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 {
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";
return true;
} catch (Exception $e) {
echo "❌ FAIL: " . $e->getMessage() . "\n";
echo '❌ FAIL: ' . $e->getMessage() . "\n";
return false;
}

View File

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

View File

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

View File

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