From a2cba6d3c01cffb581e9cc8623b47b1eb4a9c224 Mon Sep 17 00:00:00 2001
From: Pontoporeia
Date: Mon, 4 May 2026 16:29:31 +0200
Subject: [PATCH] feat: prevent duplicate TFE submissions with logging and user
feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
TODO.md | 35 +-
app/public/admin/actions/formulaire.php | 19 +
app/public/assets/css/admin.css | 11 +
app/public/assets/css/form.css | 7 +
app/public/partage/index.php | 25 +-
app/src/AdminAuth.php | 10 +-
app/src/App.php | 10 +-
app/src/AppLogger.php | 27 +
app/src/Controllers/AboutController.php | 16 +-
app/src/Controllers/ExportController.php | 1 +
app/src/Controllers/FileAccessController.php | 1 +
app/src/Controllers/HomeController.php | 61 +-
app/src/Controllers/LicenceController.php | 12 +-
app/src/Controllers/LiveReloadController.php | 17 +-
app/src/Controllers/MediaController.php | 29 +-
app/src/Controllers/SearchController.php | 224 ++--
app/src/Controllers/SystemController.php | 77 +-
app/src/Controllers/TfeController.php | 11 +-
.../Controllers/ThesisCreateController.php | 102 +-
app/src/Controllers/ThesisEditController.php | 83 +-
app/src/Database.php | 877 +++++++++-----
app/src/Dispatcher.php | 35 +-
app/src/DuplicateThesisException.php | 25 +
app/src/Parsedown.php | 1027 +++++++----------
app/src/RateLimit.php | 36 +-
app/src/ShareLink.php | 19 +-
app/src/SmtpRelay.php | 104 +-
app/src/StudentEmail.php | 37 +-
app/templates/admin/partials/toast.php | 6 +
app/tests/Integration/SearchTest.php | 19 +-
app/tests/Security/SecurityTest.php | 13 +-
app/tests/Unit/DatabaseTest.php | 23 +-
app/tests/Unit/RateLimitTest.php | 9 +-
app/tests/run-tests.php | 17 +-
justfile | 3 +
35 files changed, 1726 insertions(+), 1302 deletions(-)
create mode 100644 app/src/DuplicateThesisException.php
diff --git a/TODO.md b/TODO.md
index 0e2779d..5dc6045 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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)
diff --git a/app/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php
index 3d1a9c7..456b719 100644
--- a/app/public/admin/actions/formulaire.php
+++ b/app/public/admin/actions/formulaire.php
@@ -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à. '
+ . '' . $existingRef . ''
+ . ' 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,
diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css
index b835a5d..2dcd250 100644
--- a/app/public/assets/css/admin.css
+++ b/app/public/assets/css/admin.css
@@ -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); }
diff --git a/app/public/assets/css/form.css b/app/public/assets/css/form.css
index d6dd2fb..9fdc77d 100644
--- a/app/public/assets/css/form.css
+++ b/app/public/assets/css/form.css
@@ -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;
diff --git a/app/public/partage/index.php b/app/public/partage/index.php
index d1c6db8..34c910a 100644
--- a/app/public/partage/index.php
+++ b/app/public/partage/index.php
@@ -259,12 +259,16 @@ function renderShareLinkForm(string $slug, array $link): void
= htmlspecialchars($flashError) ?>
+
+ = htmlspecialchars($flashWarning) ?>
+
= htmlspecialchars($flashSuccess) ?>
@@ -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 qu’il s’agit d’une 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,
diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php
index 71ce73e..7b70bd3 100644
--- a/app/src/AdminAuth.php
+++ b/app/src/AdminAuth.php
@@ -1,4 +1,5 @@
$error, 'success' => $success];
+ return ['error' => $error, 'success' => $success, 'warning' => $warning];
}
// ── Redirect ──────────────────────────────────────────────────────────────
diff --git a/app/src/AppLogger.php b/app/src/AppLogger.php
index 26a6f4a..f7e7072 100644
--- a/app/src/AppLogger.php
+++ b/app/src/AppLogger.php
@@ -1,4 +1,5 @@
write(array_merge([
+ 'source' => $source,
+ 'action' => 'submit',
+ 'status' => 'duplicate',
+ 'author' => $authorName,
+ 'existing_thesis_id' => $existingThesisId,
+ 'existing_identifier' => $existingIdentifier,
+ ], $extras));
+ }
+
/**
* Log a failed thesis submission.
*
diff --git a/app/src/Controllers/AboutController.php b/app/src/Controllers/AboutController.php
index 404bd08..293379e 100644
--- a/app/src/Controllers/AboutController.php
+++ b/app/src/Controllers/AboutController.php
@@ -1,13 +1,19 @@
getPage('about');
@@ -18,9 +24,9 @@ class AboutController {
$contacts = $db->getAproposContent('contacts');
$credits = $db->getAproposContent('credits');
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
- $credits = is_array($credits) && !empty($credits) ? $credits : null;
+ $credits = is_array($credits) && !empty($credits) ? $credits : null;
} catch (Exception $e) {
- error_log("Error loading about page: " . $e->getMessage());
+ error_log('Error loading about page: ' . $e->getMessage());
$rawContent = $this->defaultContent;
$contacts = null;
$credits = null;
diff --git a/app/src/Controllers/ExportController.php b/app/src/Controllers/ExportController.php
index 232a1b6..bb123cb 100644
--- a/app/src/Controllers/ExportController.php
+++ b/app/src/Controllers/ExportController.php
@@ -1,4 +1,5 @@
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',
];
}
}
diff --git a/app/src/Controllers/LicenceController.php b/app/src/Controllers/LicenceController.php
index 145e78c..79d9cae 100644
--- a/app/src/Controllers/LicenceController.php
+++ b/app/src/Controllers/LicenceController.php
@@ -1,20 +1,24 @@
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';
}
diff --git a/app/src/Controllers/LiveReloadController.php b/app/src/Controllers/LiveReloadController.php
index e951ba1..215b272 100644
--- a/app/src/Controllers/LiveReloadController.php
+++ b/app/src/Controllers/LiveReloadController.php
@@ -1,4 +1,5 @@
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)
);
diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php
index 9cbd1cd..2384c13 100644
--- a/app/src/Controllers/MediaController.php
+++ b/app/src/Controllers/MediaController.php
@@ -1,4 +1,5 @@
getMessage());
+ error_log('MediaController visibility check error: ' . $e->getMessage());
}
}
@@ -119,7 +120,9 @@ class MediaController
header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
header('Cache-Control: public, max-age=604800');
- if (!$forceDownload) header('Content-Disposition: inline');
+ if (!$forceDownload) {
+ header('Content-Disposition: inline');
+ }
} elseif ($ext === 'pdf') {
header('Cache-Control: public, max-age=86400');
header('Content-Disposition: ' . ($forceDownload ? 'attachment' : 'inline'));
@@ -163,10 +166,15 @@ class MediaController
}
[, $range] = explode('=', $range, 2);
[$start, $end] = array_pad(explode('-', $range, 2), 2, '');
- $start = ($start === '') ? 0 : (int)$start;
- $end = ($end === '') ? $size - 1 : (int)$end;
- if ($end >= $size) $end = $size - 1;
- if ($start > $end) { http_response_code(416); exit; }
+ $start = ($start === '') ? 0 : (int)$start;
+ $end = ($end === '') ? $size - 1 : (int)$end;
+ if ($end >= $size) {
+ $end = $size - 1;
+ }
+ if ($start > $end) {
+ http_response_code(416);
+ exit;
+ }
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
@@ -176,12 +184,17 @@ class MediaController
}
$fp = fopen($path, 'rb');
- if ($fp === false) { http_response_code(500); exit; }
+ if ($fp === false) {
+ http_response_code(500);
+ exit;
+ }
fseek($fp, $start);
$remaining = $end - $start + 1;
while ($remaining > 0 && !feof($fp)) {
$chunk = fread($fp, min(8192, $remaining));
- if ($chunk === false) break;
+ if ($chunk === false) {
+ break;
+ }
echo $chunk;
$remaining -= strlen($chunk);
}
diff --git a/app/src/Controllers/SearchController.php b/app/src/Controllers/SearchController.php
index d8dfe0e..ba91d30 100644
--- a/app/src/Controllers/SearchController.php
+++ b/app/src/Controllers/SearchController.php
@@ -1,4 +1,5 @@
collectSearchParams();
- $page = isset($_GET["page"]) ? max(1, (int) $_GET["page"]) : 1;
+ $page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
$validationError = null;
@@ -100,48 +101,48 @@ class SearchController
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
- error_log("SearchController: " . $e->getMessage());
- $validationError = "Une erreur est survenue.";
+ error_log('SearchController: ' . $e->getMessage());
+ $validationError = 'Une erreur est survenue.';
}
// Preserve all active params, strip 'page' (pagination partial adds it)
- $baseParams = array_diff_key($_GET, ["page" => ""]);
+ $baseParams = array_diff_key($_GET, ['page' => '']);
- $query = $_GET["query"] ?? "";
+ $query = $_GET['query'] ?? '';
return [
- "searchParams" => $searchParams,
- "page" => $page,
- "totalItems" => $totalItems,
- "totalPages" => $totalPages,
- "results" => $results,
- "validationError" => $validationError,
- "baseParams" => $baseParams,
+ 'searchParams' => $searchParams,
+ 'page' => $page,
+ 'totalItems' => $totalItems,
+ 'totalPages' => $totalPages,
+ 'results' => $results,
+ 'validationError' => $validationError,
+ 'baseParams' => $baseParams,
// Filter dropdowns
- "years" => $years,
- "orientations" => $orientations,
- "apPrograms" => $apPrograms,
+ 'years' => $years,
+ 'orientations' => $orientations,
+ 'apPrograms' => $apPrograms,
// Page meta
- "searchBarValue" => $query,
- "pageTitle" =>
- $query !== ""
- ? "Recherche : " . $query . " – XAMXAM"
- : "Recherche – XAMXAM",
- "metaDescription" =>
+ 'searchBarValue' => $query,
+ 'pageTitle' =>
+ $query !== ''
+ ? 'Recherche : ' . $query . ' – XAMXAM'
+ : 'Recherche – XAMXAM',
+ 'metaDescription' =>
"Résultats de recherche dans le répertoire des TFE de l'erg.",
- "ogTags" => [
- "type" => "website",
- "title" => "Recherche – XAMXAM",
- "description" =>
+ 'ogTags' => [
+ 'type' => 'website',
+ 'title' => 'Recherche – XAMXAM',
+ 'description' =>
"Résultats de recherche dans le répertoire des TFE de l'erg.",
- "url" => "https://xamxam.erg.be/search",
- "site_name" => "XAMXAM – ERG",
+ 'url' => 'https://xamxam.erg.be/search',
+ 'site_name' => 'XAMXAM – ERG',
],
- "currentNav" => "repertoire",
- "extraCss" => ["/assets/css/repertoire.css"],
- "bodyClass" => "search-body",
+ 'currentNav' => 'repertoire',
+ 'extraCss' => ['/assets/css/repertoire.css'],
+ 'bodyClass' => 'search-body',
];
}
@@ -153,7 +154,7 @@ class SearchController
*/
public function handleRepertoire(): array
{
- $isHtmx = !empty($_SERVER["HTTP_HX_REQUEST"]);
+ $isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']);
$activeFilters = $this->collectFilterParams();
$repData = null;
$validationError = null;
@@ -163,8 +164,8 @@ class SearchController
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
- error_log("SearchController: " . $e->getMessage());
- $validationError = "Une erreur est survenue.";
+ error_log('SearchController: ' . $e->getMessage());
+ $validationError = 'Une erreur est survenue.';
}
// HTMX partial: render just the index div and exit
@@ -173,27 +174,27 @@ class SearchController
}
return [
- "repData" => $repData,
- "activeFilters" => $activeFilters,
- "isHtmx" => $isHtmx,
- "validationError" => $validationError,
+ 'repData' => $repData,
+ 'activeFilters' => $activeFilters,
+ 'isHtmx' => $isHtmx,
+ 'validationError' => $validationError,
// Page meta
- "searchBarValue" => "",
- "pageTitle" => "Répertoire – XAMXAM",
- "metaDescription" =>
+ 'searchBarValue' => '',
+ 'pageTitle' => 'Répertoire – XAMXAM',
+ 'metaDescription' =>
"Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.",
- "ogTags" => [
- "type" => "website",
- "title" => "Répertoire – XAMXAM",
- "description" =>
+ 'ogTags' => [
+ 'type' => 'website',
+ 'title' => 'Répertoire – XAMXAM',
+ 'description' =>
"Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.",
- "url" => "https://xamxam.erg.be/repertoire",
- "site_name" => "XAMXAM – ERG",
+ 'url' => 'https://xamxam.erg.be/repertoire',
+ 'site_name' => 'XAMXAM – ERG',
],
- "currentNav" => "repertoire",
- "extraCss" => ["/assets/css/repertoire.css"],
- "bodyClass" => "search-body",
+ 'currentNav' => 'repertoire',
+ 'extraCss' => ['/assets/css/repertoire.css'],
+ 'bodyClass' => 'search-body',
];
}
@@ -207,7 +208,8 @@ class SearchController
* HTMX endpoint: returns a popover snippet for a student name.
* Renders directly and exits.
*/
- public function handleStudentPreview(): never {
+ public function handleStudentPreview(): never
+ {
$name = trim($_GET['name'] ?? '');
header('Content-Type: text/html; charset=UTF-8');
@@ -232,8 +234,8 @@ class SearchController
array $repData,
array $activeFilters,
): never {
- header("Content-Type: text/html; charset=UTF-8");
- include APP_ROOT . "/templates/partials/repertoire-index.php";
+ header('Content-Type: text/html; charset=UTF-8');
+ include APP_ROOT . '/templates/partials/repertoire-index.php';
exit();
}
@@ -253,7 +255,7 @@ class SearchController
$out = [];
foreach ($raw as $v) {
$v = trim((string) $v);
- if ($v !== "" && strlen($v) <= $maxLen) {
+ if ($v !== '' && strlen($v) <= $maxLen) {
$out[] = $v;
}
}
@@ -261,8 +263,8 @@ class SearchController
};
$years = [];
- if (!empty($_GET["fy"]) && is_array($_GET["fy"])) {
- foreach ($_GET["fy"] as $y) {
+ if (!empty($_GET['fy']) && is_array($_GET['fy'])) {
+ foreach ($_GET['fy'] as $y) {
$y = (int) $y;
if ($y >= 1900 && $y <= 2100) {
$years[] = $y;
@@ -272,11 +274,11 @@ class SearchController
}
return [
- "years" => $years,
- "ap" => $sanitiseStrings($_GET["ap"] ?? []),
- "or" => $sanitiseStrings($_GET["or"] ?? []),
- "fi" => $sanitiseStrings($_GET["fi"] ?? []),
- "kw" => $sanitiseStrings($_GET["kw"] ?? []),
+ 'years' => $years,
+ 'ap' => $sanitiseStrings($_GET['ap'] ?? []),
+ 'or' => $sanitiseStrings($_GET['or'] ?? []),
+ 'fi' => $sanitiseStrings($_GET['fi'] ?? []),
+ 'kw' => $sanitiseStrings($_GET['kw'] ?? []),
];
}
@@ -289,20 +291,20 @@ class SearchController
{
$params = [];
- if (!empty($_GET["query"])) {
- $params["query"] = trim((string) $_GET["query"]);
+ if (!empty($_GET['query'])) {
+ $params['query'] = trim((string) $_GET['query']);
}
- if (!empty($_GET["year"])) {
- $params["year"] = (int) $_GET["year"];
+ if (!empty($_GET['year'])) {
+ $params['year'] = (int) $_GET['year'];
}
- if (!empty($_GET["orientation"])) {
- $params["orientation"] = (string) $_GET["orientation"];
+ if (!empty($_GET['orientation'])) {
+ $params['orientation'] = (string) $_GET['orientation'];
}
- if (!empty($_GET["ap_program"])) {
- $params["ap_program"] = (string) $_GET["ap_program"];
+ if (!empty($_GET['ap_program'])) {
+ $params['ap_program'] = (string) $_GET['ap_program'];
}
- if (!empty($_GET["keyword"])) {
- $params["keyword"] = (string) $_GET["keyword"];
+ if (!empty($_GET['keyword'])) {
+ $params['keyword'] = (string) $_GET['keyword'];
}
return $params;
@@ -316,48 +318,48 @@ class SearchController
private static function sendRateLimitResponse(RateLimit $rateLimit): never
{
http_response_code(429);
- header("Retry-After: " . $rateLimit->getResetTime());
+ header('Retry-After: ' . $rateLimit->getResetTime());
$retrySeconds = (int) $rateLimit->getResetTime();
echo <<
-
-
-
-
- Trop de requêtes – XAMXAM
-
-
-
-
-
XAMXAM
-
Trop de requêtes
-
Vous avez effectué trop de recherches en peu de temps.
- Réessayez dans {$retrySeconds} secondes.
-
-
-
- HTML;
+
+
+
+
+
+ Trop de requêtes – XAMXAM
+
+
+
+
+
XAMXAM
+
Trop de requêtes
+
Vous avez effectué trop de recherches en peu de temps.
+ Réessayez dans {$retrySeconds} secondes.
+
+
+
+ HTML;
exit();
}
}
diff --git a/app/src/Controllers/SystemController.php b/app/src/Controllers/SystemController.php
index 9baaa81..381d462 100644
--- a/app/src/Controllers/SystemController.php
+++ b/app/src/Controllers/SystemController.php
@@ -189,8 +189,8 @@ class SystemController
}
$error = file_exists($livePath)
- ? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath)
- : "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ").";
+ ? 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($livePath)
+ : 'Config live introuvable (' . htmlspecialchars($livePath) . ') et config locale introuvable (' . htmlspecialchars($localPath) . ').';
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
}
@@ -202,12 +202,24 @@ class SystemController
*/
public static function logLineClass(string $line): string
{
- if (preg_match('/\[(crit|emerg|alert)\]/', $line)) return 'log-crit';
- if (preg_match('/\[error\]/', $line)) return 'log-error';
- if (preg_match('/\[warn\]/', $line)) return 'log-warn';
- if (preg_match('/\[notice\]/', $line)) return 'log-notice';
- if (preg_match('/" [45]\d\d /', $line)) return 'log-error';
- if (preg_match('/" 3\d\d /', $line)) return 'log-notice';
+ if (preg_match('/\[(crit|emerg|alert)\]/', $line)) {
+ return 'log-crit';
+ }
+ if (preg_match('/\[error\]/', $line)) {
+ return 'log-error';
+ }
+ if (preg_match('/\[warn\]/', $line)) {
+ return 'log-warn';
+ }
+ if (preg_match('/\[notice\]/', $line)) {
+ return 'log-notice';
+ }
+ if (preg_match('/" [45]\d\d /', $line)) {
+ return 'log-error';
+ }
+ if (preg_match('/" 3\d\d /', $line)) {
+ return 'log-notice';
+ }
return '';
}
@@ -217,8 +229,12 @@ class SystemController
public static function nginxLineClass(string $line): string
{
$trimmed = ltrim($line);
- if ($trimmed === '' || str_starts_with($trimmed, '#')) return 'nginx-comment';
- if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) return 'nginx-block';
+ if ($trimmed === '' || str_starts_with($trimmed, '#')) {
+ return 'nginx-comment';
+ }
+ if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) {
+ return 'nginx-block';
+ }
return 'nginx-directive';
}
@@ -229,8 +245,12 @@ class SystemController
*/
public static function humanBytes(int $bytes): string
{
- if ($bytes > 1073741824) return number_format($bytes / 1073741824, 1) . ' GB';
- if ($bytes > 1048576) return number_format($bytes / 1048576, 1) . ' MB';
+ if ($bytes > 1073741824) {
+ return number_format($bytes / 1073741824, 1) . ' GB';
+ }
+ if ($bytes > 1048576) {
+ return number_format($bytes / 1048576, 1) . ' MB';
+ }
return number_format($bytes / 1024, 1) . ' KB';
}
@@ -267,8 +287,12 @@ class SystemController
*/
public static function diskColor(int $pct): string
{
- if ($pct > 85) return '#e05555';
- if ($pct > 70) return '#ffc107';
+ if ($pct > 85) {
+ return '#e05555';
+ }
+ if ($pct > 70) {
+ return '#ffc107';
+ }
return '#4caf50';
}
@@ -337,7 +361,8 @@ class SystemController
if ($dbExists) {
try {
$dbRowCount = $this->db->getThesisCount();
- } catch (Throwable) {}
+ } catch (Throwable) {
+ }
}
$checks['database'] = [
'label' => 'Base de données SQLite',
@@ -358,7 +383,7 @@ class SystemController
'detail' => $storageWritable
? implode(' · ', array_filter([
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
- is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
+ is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
]))
: 'Non accessible en écriture',
];
@@ -382,15 +407,15 @@ class SystemController
$errorMsg = null;
if (!function_exists('exec')) {
- $errorMsg = "exec() est désactivé sur ce serveur.";
+ $errorMsg = 'exec() est désactivé sur ce serveur.';
return null;
}
if (!file_exists($logPath)) {
- $errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath);
+ $errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
return null;
}
if (!is_readable($logPath)) {
- $errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
+ $errorMsg = 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($logPath);
return null;
}
@@ -399,7 +424,7 @@ class SystemController
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) {
- $errorMsg = "Erreur lors de la lecture du fichier journal.";
+ $errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null;
}
@@ -411,7 +436,9 @@ class SystemController
*/
private function safeExec(string $cmd): ?string
{
- if (!function_exists('exec')) return null;
+ if (!function_exists('exec')) {
+ return null;
+ }
$output = [];
$rc = 0;
exec($cmd . ' 2>/dev/null', $output, $rc);
@@ -424,7 +451,9 @@ class SystemController
private function systemdStatus(string $unit): ?string
{
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
- if ($raw === null) return null;
+ if ($raw === null) {
+ return null;
+ }
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
? $raw : 'unknown';
}
@@ -435,7 +464,9 @@ class SystemController
*/
private function localHttpCheck(string $url): ?array
{
- if (!function_exists('curl_init')) return null;
+ if (!function_exists('curl_init')) {
+ return null;
+ }
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
diff --git a/app/src/Controllers/TfeController.php b/app/src/Controllers/TfeController.php
index e9908e7..293b1ad 100644
--- a/app/src/Controllers/TfeController.php
+++ b/app/src/Controllers/TfeController.php
@@ -1,4 +1,5 @@
db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3);
-
+
// Check if restricted files feature is enabled and user has access
$restrictedEnabled = $this->db->isRestrictedFilesEnabled();
$hasRestrictedAccess = false;
-
+
if ($restrictedEnabled && $accessTypeId === 2) {
// Check for cookie-based access
$cookieToken = $_COOKIE['tfe_access_' . $thesisId] ?? null;
@@ -86,7 +87,7 @@ class TfeController
$hasRestrictedAccess = $this->db->hasValidCookieAccess($cookieToken, $thesisId);
}
}
-
+
// If access is restricted and user doesn't have valid access, hide files
$shouldHideFiles = ($restrictedEnabled && $accessTypeId === 2 && !$hasRestrictedAccess);
@@ -209,7 +210,9 @@ class TfeController
foreach ($jury as $member) {
$name = $member['name'] ?? '';
- if ($name === '') continue;
+ if ($name === '') {
+ continue;
+ }
switch ($member['role']) {
case 'president':
diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php
index 4acceb8..67e3a94 100644
--- a/app/src/Controllers/ThesisCreateController.php
+++ b/app/src/Controllers/ThesisCreateController.php
@@ -1,4 +1,5 @@
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';
}
diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php
index 81552f9..72287dc 100644
--- a/app/src/Controllers/ThesisEditController.php
+++ b/app/src/Controllers/ThesisEditController.php
@@ -1,4 +1,5 @@
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;
}
diff --git a/app/src/Database.php b/app/src/Database.php
index cef09c2..c1404c4 100644
--- a/app/src/Database.php
+++ b/app/src/Database.php
@@ -5,7 +5,8 @@
* Combines functionality from both front-backend and formulaire
* Supports both singleton (front-backend) and direct instantiation (formulaire)
*/
-class Database {
+class Database
+{
private static $instance = null;
private $pdo;
private $dbPath;
@@ -14,7 +15,8 @@ class Database {
* Constructor - public to support both singleton and direct instantiation
* @param string $dbPath Optional database path override
*/
- public function __construct($dbPath = null) {
+ public function __construct($dbPath = null)
+ {
$this->dbPath = $this->determineDatabasePath($dbPath);
try {
@@ -28,8 +30,8 @@ class Database {
$this->pdo->exec('PRAGMA synchronous = NORMAL');
$this->pdo->exec('PRAGMA cache_size = -8000');
} catch (PDOException $e) {
- error_log("Database connection failed: " . $e->getMessage());
- throw new Exception("Impossible de se connecter à la base de données.");
+ error_log('Database connection failed: ' . $e->getMessage());
+ throw new Exception('Impossible de se connecter à la base de données.');
}
}
@@ -38,7 +40,8 @@ class Database {
* Priority: explicit override → APP_ROOT /storage/xamxam.db.
* APP_ROOT is defined by bootstrap.php before any controller loads Database.
*/
- private function determineDatabasePath($customPath = null): string {
+ private function determineDatabasePath($customPath = null): string
+ {
if ($customPath !== null && file_exists($customPath)) {
return $customPath;
}
@@ -51,7 +54,8 @@ class Database {
* Get singleton instance (for front-backend)
* @return Database
*/
- public static function getInstance() {
+ public static function getInstance()
+ {
if (self::$instance === null) {
self::$instance = new self();
}
@@ -62,7 +66,8 @@ class Database {
* Get PDO connection
* @return PDO
*/
- public function getConnection() {
+ public function getConnection()
+ {
return $this->pdo;
}
@@ -70,14 +75,16 @@ class Database {
* Get PDO instance (alias for formulaire compatibility)
* @return PDO
*/
- public function getPDO() {
+ public function getPDO()
+ {
return $this->pdo;
}
/**
* Return the resolved path of the database file in use.
*/
- public function getDatabasePath(): string {
+ public function getDatabasePath(): string
+ {
return $this->dbPath;
}
@@ -88,21 +95,24 @@ class Database {
/**
* Begin a transaction
*/
- public function beginTransaction() {
+ public function beginTransaction()
+ {
return $this->pdo->beginTransaction();
}
/**
* Commit a transaction
*/
- public function commit() {
+ public function commit()
+ {
return $this->pdo->commit();
}
/**
* Rollback a transaction
*/
- public function rollback() {
+ public function rollback()
+ {
return $this->pdo->rollback();
}
@@ -113,8 +123,9 @@ class Database {
/**
* Get all published theses with pagination
*/
- public function getPublishedTheses($limit = 10, $offset = 0) {
- $sql = "SELECT * FROM v_theses_public ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
+ public function getPublishedTheses($limit = 10, $offset = 0)
+ {
+ $sql = 'SELECT * FROM v_theses_public ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
@@ -126,11 +137,12 @@ class Database {
* Get theses from the latest published year, in random order (per request).
* Used for the default home page view.
*/
- public function getLatestYearTheses(int $limit = 24): array {
- $sql = "SELECT * FROM v_theses_public
+ public function getLatestYearTheses(int $limit = 24): array
+ {
+ $sql = 'SELECT * FROM v_theses_public
WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1)
ORDER BY RANDOM()
- LIMIT :limit";
+ LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
@@ -140,8 +152,9 @@ class Database {
/**
* Get the latest year that has published theses
*/
- public function getLatestPublishedYear(): ?int {
- $stmt = $this->pdo->query("SELECT MAX(year) FROM theses WHERE is_published = 1");
+ public function getLatestPublishedYear(): ?int
+ {
+ $stmt = $this->pdo->query('SELECT MAX(year) FROM theses WHERE is_published = 1');
$val = $stmt->fetchColumn();
return $val ? (int)$val : null;
}
@@ -149,8 +162,9 @@ class Database {
/**
* Count all published theses
*/
- public function countPublishedTheses() {
- $sql = "SELECT COUNT(*) as count FROM theses WHERE is_published = 1";
+ public function countPublishedTheses()
+ {
+ $sql = 'SELECT COUNT(*) as count FROM theses WHERE is_published = 1';
$stmt = $this->pdo->query($sql);
$result = $stmt->fetch();
return $result['count'];
@@ -159,8 +173,9 @@ class Database {
/**
* Get thesis by ID with all related data (for public site)
*/
- public function getThesisById($id) {
- $sql = "SELECT * FROM v_theses_full WHERE id = :id AND is_published = 1";
+ public function getThesisById($id)
+ {
+ $sql = 'SELECT * FROM v_theses_full WHERE id = :id AND is_published = 1';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
@@ -181,8 +196,9 @@ class Database {
* @param int $id Thesis ID
* @return array|null Thesis data or null if not found
*/
- public function getThesis($id) {
- $stmt = $this->pdo->prepare("SELECT * FROM v_theses_full WHERE id = ?");
+ public function getThesis($id)
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM v_theses_full WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch();
}
@@ -191,8 +207,9 @@ class Database {
* Get files associated with a thesis, ordered by sort_order then upload time.
* Covers the new sort_order column added in migration 007.
*/
- public function getThesisFiles($thesisId) {
- $sql = "SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY sort_order ASC, uploaded_at ASC";
+ public function getThesisFiles($thesisId)
+ {
+ $sql = 'SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY sort_order ASC, uploaded_at ASC';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':thesis_id', $thesisId, PDO::PARAM_INT);
$stmt->execute();
@@ -206,7 +223,8 @@ class Database {
/**
* Escape LIKE wildcards in user input to prevent wildcard injection
*/
- private function escapeLikeString($string) {
+ private function escapeLikeString($string)
+ {
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $string);
}
@@ -214,13 +232,14 @@ class Database {
* Validate and sanitize search parameters
* @throws InvalidArgumentException if validation fails
*/
- private function validateSearchParams($params) {
+ private function validateSearchParams($params)
+ {
$validated = [];
if (!empty($params['query'])) {
$query = trim($params['query']);
if (strlen($query) > 200) {
- throw new InvalidArgumentException("Search query too long (max 200 characters)");
+ throw new InvalidArgumentException('Search query too long (max 200 characters)');
}
$validated['query'] = $this->escapeLikeString($query);
}
@@ -228,7 +247,7 @@ class Database {
if (!empty($params['year'])) {
$year = intval($params['year']);
if ($year < 1900 || $year > 2100) {
- throw new InvalidArgumentException("Invalid year");
+ throw new InvalidArgumentException('Invalid year');
}
$validated['year'] = $year;
}
@@ -236,7 +255,7 @@ class Database {
if (!empty($params['orientation'])) {
$orientation = trim($params['orientation']);
if (strlen($orientation) > 100) {
- throw new InvalidArgumentException("Orientation name too long");
+ throw new InvalidArgumentException('Orientation name too long');
}
$validated['orientation'] = $this->escapeLikeString($orientation);
}
@@ -244,7 +263,7 @@ class Database {
if (!empty($params['ap_program'])) {
$ap = trim($params['ap_program']);
if (strlen($ap) > 100) {
- throw new InvalidArgumentException("AP program name too long");
+ throw new InvalidArgumentException('AP program name too long');
}
$validated['ap_program'] = $this->escapeLikeString($ap);
}
@@ -252,7 +271,7 @@ class Database {
if (!empty($params['finality'])) {
$finality = trim($params['finality']);
if (strlen($finality) > 100) {
- throw new InvalidArgumentException("Finality name too long");
+ throw new InvalidArgumentException('Finality name too long');
}
$validated['finality'] = $this->escapeLikeString($finality);
}
@@ -260,7 +279,7 @@ class Database {
if (!empty($params['keyword'])) {
$keyword = trim($params['keyword']);
if (strlen($keyword) > 100) {
- throw new InvalidArgumentException("Keyword too long");
+ throw new InvalidArgumentException('Keyword too long');
}
$validated['keyword'] = $this->escapeLikeString($keyword);
}
@@ -268,7 +287,7 @@ class Database {
if (!empty($params['format'])) {
$format = trim($params['format']);
if (strlen($format) > 100) {
- throw new InvalidArgumentException("Format name too long");
+ throw new InvalidArgumentException('Format name too long');
}
$validated['format'] = $this->escapeLikeString($format);
}
@@ -276,7 +295,7 @@ class Database {
if (!empty($params['language'])) {
$language = trim($params['language']);
if (strlen($language) > 50) {
- throw new InvalidArgumentException("Language name too long");
+ throw new InvalidArgumentException('Language name too long');
}
$validated['language'] = $this->escapeLikeString($language);
}
@@ -295,8 +314,9 @@ class Database {
* @param array $params Already-validated params (output of validateSearchParams)
* @return array{0: string[], 1: array} [$conditions, $bindings]
*/
- private function buildSearchConditions(array $params): array {
- $conditions = ["vp.is_published = 1"];
+ private function buildSearchConditions(array $params): array
+ {
+ $conditions = ['vp.is_published = 1'];
$bindings = [];
if (!empty($params['query'])) {
@@ -316,7 +336,7 @@ class Database {
}
if (!empty($params['year'])) {
- $conditions[] = "vp.year = :year";
+ $conditions[] = 'vp.year = :year';
$bindings[':year'] = $params['year'];
}
@@ -355,7 +375,7 @@ class Database {
}
if (isset($params['is_doctoral'])) {
- $conditions[] = "vp.is_doctoral = :is_doctoral";
+ $conditions[] = 'vp.is_doctoral = :is_doctoral';
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
}
@@ -365,7 +385,8 @@ class Database {
/**
* Search theses with filters (secure implementation)
*/
- public function searchTheses(array $params = [], $limit = 20, $offset = 0) {
+ public function searchTheses(array $params = [], $limit = 20, $offset = 0)
+ {
$params = $this->validateSearchParams($params);
$limit = max(1, min(100, intval($limit)));
$offset = max(0, intval($offset));
@@ -389,7 +410,8 @@ class Database {
/**
* Count search results
*/
- public function countSearchResults(array $params = []) {
+ public function countSearchResults(array $params = [])
+ {
$params = $this->validateSearchParams($params);
[$conditions, $bindings] = $this->buildSearchConditions($params);
@@ -418,9 +440,10 @@ class Database {
* it avoids the expensive v_theses_public view and only fetches
* the two columns actually needed (id, authors).
*/
- public function getAllPublishedTheses(): array {
+ public function getAllPublishedTheses(): array
+ {
$stmt = $this->pdo->query(
- "SELECT vp.* FROM v_theses_public vp ORDER BY vp.year DESC, vp.title ASC"
+ 'SELECT vp.* FROM v_theses_public vp ORDER BY vp.year DESC, vp.title ASC'
);
return $stmt->fetchAll();
}
@@ -434,16 +457,17 @@ class Database {
*
* Returns rows of [id => int, authors => "Name1,Name2"].
*/
- public function getPublishedAuthors(): array {
+ public function getPublishedAuthors(): array
+ {
$stmt = $this->pdo->query(
- "SELECT t.id,
+ 'SELECT t.id,
GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors
FROM theses t
JOIN thesis_authors ta ON ta.thesis_id = t.id
JOIN authors a ON a.id = ta.author_id
WHERE t.is_published = 1
GROUP BY t.id
- ORDER BY MIN(a.name) ASC"
+ ORDER BY MIN(a.name) ASC'
);
return $stmt->fetchAll();
}
@@ -452,15 +476,16 @@ class Database {
* Fetch all published theses for a given author name.
* Returns rows of [id => int, title => string].
*/
- public function getThesesByAuthorName(string $name): array {
+ public function getThesesByAuthorName(string $name): array
+ {
$stmt = $this->pdo->prepare(
- "SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
+ 'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
vp.orientation, vp.finality_type, vp.banner_path, vp.authors
FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_id
WHERE a.name = ?
- ORDER BY vp.year DESC, vp.title ASC"
+ ORDER BY vp.year DESC, vp.title ASC'
);
$stmt->execute([$name]);
return $stmt->fetchAll();
@@ -473,8 +498,11 @@ class Database {
* @param string[] $names
* @return array
*/
- public function getThesesForAuthors(array $names): array {
- if (empty($names)) return [];
+ public function getThesesForAuthors(array $names): array
+ {
+ if (empty($names)) {
+ return [];
+ }
$placeholders = implode(',', array_fill(0, count($names), '?'));
$stmt = $this->pdo->prepare(
@@ -497,8 +525,9 @@ class Database {
return $grouped;
}
- public function getAvailableYears() {
- $sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC";
+ public function getAvailableYears()
+ {
+ $sql = 'SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC';
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
@@ -506,36 +535,40 @@ class Database {
/**
* Get all orientations
*/
- public function getAllOrientations(): array {
- $stmt = $this->pdo->query("SELECT * FROM orientations ORDER BY name");
+ public function getAllOrientations(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM orientations ORDER BY name');
return $stmt->fetchAll();
}
/**
* Get all AP programs
*/
- public function getAllAPPrograms(): array {
- $stmt = $this->pdo->query("SELECT * FROM ap_programs ORDER BY name");
+ public function getAllAPPrograms(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM ap_programs ORDER BY name');
return $stmt->fetchAll();
}
/**
* Get all finality types
*/
- public function getAllFinalityTypes(): array {
- $stmt = $this->pdo->query("SELECT * FROM finality_types ORDER BY name");
+ public function getAllFinalityTypes(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM finality_types ORDER BY name');
return $stmt->fetchAll();
}
/**
* Get all keywords used in published theses
*/
- public function getUsedTags(): array {
- $sql = "SELECT DISTINCT tg.id, tg.name FROM tags tg
+ public function getUsedTags(): array
+ {
+ $sql = 'SELECT DISTINCT tg.id, tg.name FROM tags tg
JOIN thesis_tags tt ON tg.id = tt.tag_id
JOIN theses th ON tt.thesis_id = th.id
WHERE th.is_published = 1
- ORDER BY tg.name";
+ ORDER BY tg.name';
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
@@ -559,38 +592,47 @@ class Database {
*
* @param array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} $filters
*/
- public function getRepertoireFilterData(array $filters): array {
- $baseJoins = "
+ public function getRepertoireFilterData(array $filters): array
+ {
+ $baseJoins = '
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN finality_types ft ON t.finality_id = ft.id
- ";
+ ';
// Build WHERE + bindings excluding one dimension (for that column's own relevance)
- $buildWhere = function(string $exclude) use ($filters): array {
+ $buildWhere = function (string $exclude) use ($filters): array {
$conditions = ['t.is_published = 1'];
$bindings = [];
if ($exclude !== 'years' && !empty($filters['years'])) {
$ph = implode(',', array_fill(0, count($filters['years']), '?'));
$conditions[] = "t.year IN ($ph)";
- foreach ($filters['years'] as $v) $bindings[] = (int)$v;
+ foreach ($filters['years'] as $v) {
+ $bindings[] = (int)$v;
+ }
}
if ($exclude !== 'ap' && !empty($filters['ap'])) {
$ph = implode(',', array_fill(0, count($filters['ap']), '?'));
$conditions[] = "ap.name IN ($ph)";
- foreach ($filters['ap'] as $v) $bindings[] = (string)$v;
+ foreach ($filters['ap'] as $v) {
+ $bindings[] = (string)$v;
+ }
}
if ($exclude !== 'or' && !empty($filters['or'])) {
$ph = implode(',', array_fill(0, count($filters['or']), '?'));
$conditions[] = "o.name IN ($ph)";
- foreach ($filters['or'] as $v) $bindings[] = (string)$v;
+ foreach ($filters['or'] as $v) {
+ $bindings[] = (string)$v;
+ }
}
if ($exclude !== 'fi' && !empty($filters['fi'])) {
$ph = implode(',', array_fill(0, count($filters['fi']), '?'));
$conditions[] = "ft.name IN ($ph)";
- foreach ($filters['fi'] as $v) $bindings[] = (string)$v;
+ foreach ($filters['fi'] as $v) {
+ $bindings[] = (string)$v;
+ }
}
if ($exclude !== 'kw' && !empty($filters['kw'])) {
foreach ($filters['kw'] as $kv) {
@@ -601,7 +643,7 @@ class Database {
return [implode(' AND ', $conditions), $bindings];
};
- $exec = function(string $sql, array $b): array {
+ $exec = function (string $sql, array $b): array {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($b);
return $stmt->fetchAll();
@@ -614,26 +656,26 @@ class Database {
// Years
[$w, $b] = $buildWhere('years');
$matchedYears = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $w ORDER BY t.year DESC", $b), 'year');
- $allYears = array_column($exec("SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC", []), 'year');
- $yearsOut = array_map(fn($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears);
+ $allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC', []), 'year');
+ $yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears);
// AP programs
[$w, $b] = $buildWhere('ap');
$matchedAp = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $w AND ap.name IS NOT NULL ORDER BY ap.name", $b), 'name');
- $allAp = array_column($exec("SELECT name FROM ap_programs ORDER BY name", []), 'name');
- $apOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp);
+ $allAp = array_column($exec('SELECT name FROM ap_programs ORDER BY name', []), 'name');
+ $apOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp);
// Orientations
[$w, $b] = $buildWhere('or');
$matchedOr = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $w AND o.name IS NOT NULL ORDER BY o.name", $b), 'name');
- $allOr = array_column($exec("SELECT name FROM orientations ORDER BY name", []), 'name');
- $orOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr);
+ $allOr = array_column($exec('SELECT name FROM orientations ORDER BY name', []), 'name');
+ $orOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr);
// Finality types
[$w, $b] = $buildWhere('fi');
$matchedFi = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $w AND ft.name IS NOT NULL ORDER BY ft.name", $b), 'name');
- $allFi = array_column($exec("SELECT name FROM finality_types ORDER BY name", []), 'name');
- $fiOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi);
+ $allFi = array_column($exec('SELECT name FROM finality_types ORDER BY name', []), 'name');
+ $fiOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi);
// Keywords
[$w, $b] = $buildWhere('kw');
@@ -641,13 +683,17 @@ class Database {
"SELECT DISTINCT tg.name $baseJoins
JOIN thesis_tags tt ON tt.thesis_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
- WHERE $w ORDER BY tg.name", $b), 'name');
+ WHERE $w ORDER BY tg.name",
+ $b
+ ), 'name');
$allKw = array_column($exec(
- "SELECT DISTINCT tg.name FROM tags tg
+ 'SELECT DISTINCT tg.name FROM tags tg
JOIN thesis_tags tt ON tg.id = tt.tag_id
JOIN theses th ON tt.thesis_id = th.id
- WHERE th.is_published = 1 ORDER BY tg.name", []), 'name');
- $kwOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
+ WHERE th.is_published = 1 ORDER BY tg.name',
+ []
+ ), 'name');
+ $kwOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
// Students (output only — full intersection)
$studentsOut = [];
@@ -680,16 +726,18 @@ class Database {
/**
* Get all format types
*/
- public function getAllFormatTypes(): array {
- $stmt = $this->pdo->query("SELECT * FROM format_types ORDER BY name");
+ public function getAllFormatTypes(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM format_types ORDER BY name');
return $stmt->fetchAll();
}
/**
* Get all languages
*/
- public function getAllLanguages(): array {
- $stmt = $this->pdo->query("SELECT * FROM languages ORDER BY name");
+ public function getAllLanguages(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM languages ORDER BY name');
return $stmt->fetchAll();
}
@@ -727,7 +775,8 @@ class Database {
* Build the ORDER BY clause from sort/direction parameters.
* Returns a safe SQL fragment (never interpolates raw user input).
*/
- private function buildOrderBy(array $filters): string {
+ private function buildOrderBy(array $filters): string
+ {
$sort = $filters['sort'] ?? 'submitted_at';
$dir = isset($filters['dir']) && strtolower($filters['dir']) === 'asc' ? 'ASC' : 'DESC';
@@ -743,19 +792,20 @@ class Database {
* Count theses matching the given admin filters (no LIMIT).
* Used alongside getThesesList() to calculate total pages.
*/
- public function getThesesListCount(array $filters = []): int {
- $sql = "SELECT COUNT(DISTINCT t.id)
+ public function getThesesListCount(array $filters = []): int
+ {
+ $sql = 'SELECT COUNT(DISTINCT t.id)
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
- WHERE 1=1";
+ WHERE 1=1';
$params = [];
if (!empty($filters['search'])) {
- $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)";
+ $sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)';
$searchParam = '%' . $filters['search'] . '%';
$params[] = $searchParam;
$params[] = $searchParam;
@@ -763,17 +813,17 @@ class Database {
}
if (!empty($filters['year'])) {
- $sql .= " AND t.year = ?";
+ $sql .= ' AND t.year = ?';
$params[] = intval($filters['year']);
}
if (!empty($filters['orientation'])) {
- $sql .= " AND t.orientation_id = ?";
+ $sql .= ' AND t.orientation_id = ?';
$params[] = intval($filters['orientation']);
}
if (!empty($filters['ap'])) {
- $sql .= " AND t.ap_program_id = ?";
+ $sql .= ' AND t.ap_program_id = ?';
$params[] = intval($filters['ap']);
}
@@ -782,8 +832,9 @@ class Database {
return (int) $stmt->fetchColumn();
}
- public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array {
- $sql = "SELECT
+ public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array
+ {
+ $sql = 'SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation,
ap.name as ap_program,
@@ -797,12 +848,12 @@ class Database {
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
LEFT JOIN access_types at ON t.access_type_id = at.id
- WHERE 1=1";
+ WHERE 1=1';
$params = [];
if (!empty($filters['search'])) {
- $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)";
+ $sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)';
$searchParam = '%' . $filters['search'] . '%';
$params[] = $searchParam;
$params[] = $searchParam;
@@ -810,17 +861,17 @@ class Database {
}
if (!empty($filters['year'])) {
- $sql .= " AND t.year = ?";
+ $sql .= ' AND t.year = ?';
$params[] = intval($filters['year']);
}
if (!empty($filters['orientation'])) {
- $sql .= " AND t.orientation_id = ?";
+ $sql .= ' AND t.orientation_id = ?';
$params[] = intval($filters['orientation']);
}
if (!empty($filters['ap'])) {
- $sql .= " AND t.ap_program_id = ?";
+ $sql .= ' AND t.ap_program_id = ?';
$params[] = intval($filters['ap']);
}
@@ -828,7 +879,7 @@ class Database {
$sql .= " GROUP BY t.id {$orderBy}";
if ($limit > 0) {
- $sql .= " LIMIT :limit OFFSET :offset";
+ $sql .= ' LIMIT :limit OFFSET :offset';
}
$stmt = $this->pdo->prepare($sql);
@@ -839,7 +890,7 @@ class Database {
foreach ($params as $i => $val) {
$stmt->bindValue($i + 1, $val);
}
- $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
} else {
@@ -852,8 +903,9 @@ class Database {
/**
* Get distinct years present in the theses table (admin, includes unpublished).
*/
- public function getAllYears(): array {
- $stmt = $this->pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC");
+ public function getAllYears(): array
+ {
+ $stmt = $this->pdo->query('SELECT DISTINCT year FROM theses ORDER BY year DESC');
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
@@ -865,13 +917,14 @@ class Database {
*
* @return array{total: int, published: int, pending: int}
*/
- public function getThesesStats(): array {
+ public function getThesesStats(): array
+ {
$stmt = $this->pdo->query(
- "SELECT
+ 'SELECT
COUNT(*) AS total,
SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published,
SUM(CASE WHEN is_published = 0 THEN 1 ELSE 0 END) AS pending
- FROM theses"
+ FROM theses'
);
$row = $stmt->fetch();
return [
@@ -888,32 +941,130 @@ class Database {
/**
* Find or create an author
*/
- public function findOrCreateAuthor($name, $email = null, bool $showContact = false) {
- $stmt = $this->pdo->prepare("SELECT id FROM authors WHERE name = ?");
+ public function findOrCreateAuthor($name, $email = null, bool $showContact = false)
+ {
+ $stmt = $this->pdo->prepare('SELECT id FROM authors WHERE name = ?');
$stmt->execute([$name]);
$author = $stmt->fetch();
if ($author) {
if ($email && $email !== '') {
- $updateStmt = $this->pdo->prepare("UPDATE authors SET email = ?, show_contact = ? WHERE id = ?");
+ $updateStmt = $this->pdo->prepare('UPDATE authors SET email = ?, show_contact = ? WHERE id = ?');
$updateStmt->execute([$email, $showContact ? 1 : 0, $author['id']]);
} else {
- $updateStmt = $this->pdo->prepare("UPDATE authors SET show_contact = ? WHERE id = ?");
+ $updateStmt = $this->pdo->prepare('UPDATE authors SET show_contact = ? WHERE id = ?');
$updateStmt->execute([$showContact ? 1 : 0, $author['id']]);
}
return $author['id'];
}
- $stmt = $this->pdo->prepare("INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)");
+ $stmt = $this->pdo->prepare('INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)');
$stmt->execute([$name, $email, $showContact ? 1 : 0]);
return $this->pdo->lastInsertId();
}
+ /**
+ * Check whether an existing thesis is a likely duplicate of the data being
+ * submitted.
+ *
+ * Matching logic (all three conditions must be satisfied):
+ * - Same year
+ * - Same author name (case-insensitive, trimmed)
+ * - Normalised title similarity: after lowercasing and stripping all
+ * non-alphanumeric characters, one title must start with the other or
+ * the Levenshtein distance must be ≤ 10 % of the longer string's length.
+ *
+ * Returns an associative array with keys
+ * [id, identifier, title, author, year]
+ * or null when no duplicate is found.
+ *
+ * @param string $title Proposed title.
+ * @param string $authorName Proposed author name.
+ * @param int $year Proposed year.
+ * @return array{id:int,identifier:string,title:string,author:string,year:int}|null
+ */
+ public function findDuplicateThesis(string $title, string $authorName, int $year): ?array
+ {
+ // Fetch all theses for the same year and author (case-insensitive).
+ $stmt = $this->pdo->prepare(
+ 'SELECT t.id, t.identifier, t.title, a.name AS author, t.year
+ FROM theses t
+ JOIN thesis_authors ta ON ta.thesis_id = t.id AND ta.author_order = 1
+ JOIN authors a ON a.id = ta.author_id
+ WHERE t.year = ?
+ AND LOWER(TRIM(a.name)) = LOWER(TRIM(?))'
+ );
+ $stmt->execute([$year, $authorName]);
+ $candidates = $stmt->fetchAll();
+
+ if (empty($candidates)) {
+ return null;
+ }
+
+ $normalise = static function (string $s): string {
+ return preg_replace('/[^a-z0-9]/u', '', mb_strtolower($s));
+ };
+
+ $normNew = $normalise($title);
+
+ foreach ($candidates as $row) {
+ $normExisting = $normalise($row['title']);
+
+ // Exact match after normalisation.
+ if ($normNew === $normExisting) {
+ return [
+ 'id' => (int)$row['id'],
+ 'identifier' => $row['identifier'],
+ 'title' => $row['title'],
+ 'author' => $row['author'],
+ 'year' => (int)$row['year'],
+ ];
+ }
+
+ // Prefix match: one starts with the other (handles subtitle variations).
+ $maxLen = max(mb_strlen($normNew), mb_strlen($normExisting));
+ if ($maxLen === 0) {
+ continue;
+ }
+ $minLen = min(mb_strlen($normNew), mb_strlen($normExisting));
+ if ($minLen >= 5) { // avoid matching very short fragments
+ if (str_starts_with($normExisting, $normNew) || str_starts_with($normNew, $normExisting)) {
+ return [
+ 'id' => (int)$row['id'],
+ 'identifier' => $row['identifier'],
+ 'title' => $row['title'],
+ 'author' => $row['author'],
+ 'year' => (int)$row['year'],
+ ];
+ }
+ }
+
+ // Levenshtein distance ≤ 10 % of the longer string.
+ // levenshtein() is limited to 255 chars; use substrings for safety.
+ $a = mb_substr($normNew, 0, 255);
+ $b = mb_substr($normExisting, 0, 255);
+ $dist = levenshtein($a, $b);
+ $threshold = (int)ceil($maxLen * 0.10);
+ if ($dist <= $threshold) {
+ return [
+ 'id' => (int)$row['id'],
+ 'identifier' => $row['identifier'],
+ 'title' => $row['title'],
+ 'author' => $row['author'],
+ 'year' => (int)$row['year'],
+ ];
+ }
+ }
+
+ return null;
+ }
+
/**
* Find or create a supervisor
*/
- public function findOrCreateSupervisor($name) {
- $stmt = $this->pdo->prepare("SELECT id FROM supervisors WHERE name = ?");
+ public function findOrCreateSupervisor($name)
+ {
+ $stmt = $this->pdo->prepare('SELECT id FROM supervisors WHERE name = ?');
$stmt->execute([$name]);
$supervisor = $stmt->fetch();
@@ -921,7 +1072,7 @@ class Database {
return $supervisor['id'];
}
- $stmt = $this->pdo->prepare("INSERT INTO supervisors (name) VALUES (?)");
+ $stmt = $this->pdo->prepare('INSERT INTO supervisors (name) VALUES (?)');
$stmt->execute([$name]);
return $this->pdo->lastInsertId();
}
@@ -929,13 +1080,14 @@ class Database {
/**
* Find or create a tag (formerly keyword)
*/
- public function findOrCreateTag(string $name): ?int {
+ public function findOrCreateTag(string $name): ?int
+ {
$name = trim($name);
if ($name === '') {
return null;
}
- $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ?");
+ $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ?');
$stmt->execute([$name]);
$row = $stmt->fetch();
@@ -943,7 +1095,7 @@ class Database {
return (int)$row['id'];
}
- $stmt = $this->pdo->prepare("INSERT INTO tags (name) VALUES (?)");
+ $stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)');
$stmt->execute([$name]);
return (int)$this->pdo->lastInsertId();
}
@@ -957,48 +1109,57 @@ class Database {
/**
* Return all tags with a count of associated (published) theses.
*/
- public function getAllTagsWithCount(): array {
- $stmt = $this->pdo->query("
+ public function getAllTagsWithCount(): array
+ {
+ $stmt = $this->pdo->query('
SELECT tg.id, tg.name,
COUNT(DISTINCT tt.thesis_id) as thesis_count
FROM tags tg
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
GROUP BY tg.id
ORDER BY tg.name COLLATE NOCASE
- ");
+ ');
return $stmt->fetchAll();
}
/**
* Rename a tag. Throws if the new name already exists.
*/
- public function renameTag(int $id, string $newName): void {
+ public function renameTag(int $id, string $newName): void
+ {
$newName = trim($newName);
- if ($newName === '') throw new Exception("Le nom du tag ne peut pas être vide.");
+ if ($newName === '') {
+ throw new Exception('Le nom du tag ne peut pas être vide.');
+ }
// Check uniqueness
- $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ? AND id != ?");
+ $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ?');
$stmt->execute([$newName, $id]);
- if ($stmt->fetch()) throw new Exception("Un tag avec ce nom existe déjà.");
- $this->pdo->prepare("UPDATE tags SET name = ? WHERE id = ?")->execute([$newName, $id]);
+ if ($stmt->fetch()) {
+ throw new Exception('Un tag avec ce nom existe déjà.');
+ }
+ $this->pdo->prepare('UPDATE tags SET name = ? WHERE id = ?')->execute([$newName, $id]);
}
/**
* Merge sourceId into targetId: reassign all thesis_tags rows, then delete source.
* Uses INSERT OR IGNORE to avoid PK conflicts.
*/
- public function mergeTag(int $sourceId, int $targetId): void {
- if ($sourceId === $targetId) throw new Exception("Source et destination identiques.");
+ public function mergeTag(int $sourceId, int $targetId): void
+ {
+ if ($sourceId === $targetId) {
+ throw new Exception('Source et destination identiques.');
+ }
$this->pdo->beginTransaction();
try {
// Re-point thesis_tags rows from source → target (skip conflicts)
- $this->pdo->prepare("
+ $this->pdo->prepare('
INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id)
SELECT ?, thesis_id FROM thesis_tags WHERE tag_id = ?
- ")->execute([$targetId, $sourceId]);
+ ')->execute([$targetId, $sourceId]);
// Delete the old source rows
- $this->pdo->prepare("DELETE FROM thesis_tags WHERE tag_id = ?")->execute([$sourceId]);
+ $this->pdo->prepare('DELETE FROM thesis_tags WHERE tag_id = ?')->execute([$sourceId]);
// Delete the source tag itself
- $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$sourceId]);
+ $this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$sourceId]);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
@@ -1009,8 +1170,9 @@ class Database {
/**
* Delete a tag and all its thesis_tags rows (cascades via FK).
*/
- public function deleteTag(int $id): void {
- $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$id]);
+ public function deleteTag(int $id): void
+ {
+ $this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$id]);
}
/**
@@ -1027,8 +1189,9 @@ class Database {
* @param string $slug Page slug (e.g. 'about', 'licenses')
* @return array|null
*/
- public function getPage(string $slug): ?array {
- $stmt = $this->pdo->prepare("SELECT * FROM pages WHERE slug = ?");
+ public function getPage(string $slug): ?array
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM pages WHERE slug = ?');
$stmt->execute([$slug]);
$row = $stmt->fetch();
return $row ?: null;
@@ -1038,14 +1201,15 @@ class Database {
* Update content for a static page by slug
* @throws Exception if slug not found
*/
- public function savePage(string $slug, string $content): void {
- $stmt = $this->pdo->prepare("SELECT id FROM pages WHERE slug = ?");
+ public function savePage(string $slug, string $content): void
+ {
+ $stmt = $this->pdo->prepare('SELECT id FROM pages WHERE slug = ?');
$stmt->execute([$slug]);
if (!$stmt->fetch()) {
throw new Exception("Page slug not found: $slug");
}
$stmt = $this->pdo->prepare(
- "UPDATE pages SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"
+ 'UPDATE pages SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?'
);
$stmt->execute([$content, $slug]);
}
@@ -1053,8 +1217,9 @@ class Database {
/**
* Get all static pages
*/
- public function getAllPages(): array {
- $stmt = $this->pdo->query("SELECT * FROM pages ORDER BY slug");
+ public function getAllPages(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM pages ORDER BY slug');
return $stmt->fetchAll();
}
@@ -1065,8 +1230,9 @@ class Database {
/**
* Get all license types ordered by name
*/
- public function getAllLicenseTypes(): array {
- $stmt = $this->pdo->query("SELECT * FROM license_types ORDER BY name");
+ public function getAllLicenseTypes(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM license_types ORDER BY name');
return $stmt->fetchAll();
}
@@ -1079,9 +1245,10 @@ class Database {
* @param int $thesisId
* @param int|null $accessTypeId 1=Libre, 2=Interne, 3=Interdit, null=unset
*/
- public function setVisibility(int $thesisId, ?int $accessTypeId): void {
+ public function setVisibility(int $thesisId, ?int $accessTypeId): void
+ {
$stmt = $this->pdo->prepare(
- "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
+ 'UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
);
$stmt->execute([$accessTypeId, $thesisId]);
}
@@ -1091,8 +1258,11 @@ class Database {
* @param int[] $thesisIds
* @param int|null $accessTypeId
*/
- public function bulkSetVisibility(array $thesisIds, ?int $accessTypeId): void {
- if (empty($thesisIds)) return;
+ public function bulkSetVisibility(array $thesisIds, ?int $accessTypeId): void
+ {
+ if (empty($thesisIds)) {
+ return;
+ }
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
$params = array_merge([$accessTypeId], $thesisIds);
$this->pdo->prepare(
@@ -1103,7 +1273,8 @@ class Database {
/**
* Set the published state of a single thesis.
*/
- public function setPublished(int $thesisId, bool $published): void {
+ public function setPublished(int $thesisId, bool $published): void
+ {
$this->pdo->prepare(
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
)->execute([$published ? 1 : 0, $thesisId]);
@@ -1113,8 +1284,11 @@ class Database {
* Set the published state for multiple theses at once.
* @param int[] $thesisIds
*/
- public function bulkSetPublished(array $thesisIds, bool $published): void {
- if (empty($thesisIds)) return;
+ public function bulkSetPublished(array $thesisIds, bool $published): void
+ {
+ if (empty($thesisIds)) {
+ return;
+ }
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
$params = array_merge([$published ? 1 : 0], $thesisIds);
$this->pdo->prepare(
@@ -1125,8 +1299,9 @@ class Database {
/**
* Get all access types (visibility options).
*/
- public function getAccessTypes(): array {
- $stmt = $this->pdo->query("SELECT * FROM access_types ORDER BY id");
+ public function getAccessTypes(): array
+ {
+ $stmt = $this->pdo->query('SELECT * FROM access_types ORDER BY id');
return $stmt->fetchAll();
}
@@ -1137,8 +1312,9 @@ class Database {
/**
* Get a single site setting value by key. Returns $default if not found.
*/
- public function getSetting(string $key, string $default = ''): string {
- $stmt = $this->pdo->prepare("SELECT value FROM site_settings WHERE key = ? LIMIT 1");
+ public function getSetting(string $key, string $default = ''): string
+ {
+ $stmt = $this->pdo->prepare('SELECT value FROM site_settings WHERE key = ? LIMIT 1');
$stmt->execute([$key]);
$row = $stmt->fetch();
return $row ? (string) $row['value'] : $default;
@@ -1147,8 +1323,9 @@ class Database {
/**
* Get all site settings as an associative array [ key => value ].
*/
- public function getAllSettings(): array {
- $stmt = $this->pdo->query("SELECT key, value FROM site_settings");
+ public function getAllSettings(): array
+ {
+ $stmt = $this->pdo->query('SELECT key, value FROM site_settings');
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $r) {
@@ -1160,11 +1337,12 @@ class Database {
/**
* Upsert a site setting.
*/
- public function setSetting(string $key, string $value): void {
+ public function setSetting(string $key, string $value): void
+ {
$this->pdo->prepare(
- "INSERT INTO site_settings (key, value, updated_at)
+ 'INSERT INTO site_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP'
)->execute([$key, $value]);
}
@@ -1175,7 +1353,8 @@ class Database {
* 'Interne' (id=2) is excluded unless access_type_interne_enabled = '1'.
* 'Interdit' (id=3) is excluded unless access_type_interdit_enabled = '1'.
*/
- public function getEnabledFormAccessTypes(): array {
+ public function getEnabledFormAccessTypes(): array
+ {
$settings = $this->getAllSettings();
$all = $this->getAccessTypes();
$map = [
@@ -1183,20 +1362,21 @@ class Database {
'Interne' => $settings['access_type_interne_enabled'] ?? '1',
'Interdit' => $settings['access_type_interdit_enabled'] ?? '1',
];
- return array_values(array_filter($all, fn($at) => ($map[$at['name']] ?? '0') === '1'));
+ return array_values(array_filter($all, fn ($at) => ($map[$at['name']] ?? '0') === '1'));
}
/**
* Update the show_contact flag for the first author of a thesis.
*/
- public function setAuthorShowContact(int $thesisId, bool $show): void {
+ public function setAuthorShowContact(int $thesisId, bool $show): void
+ {
$stmt = $this->pdo->prepare(
- "UPDATE authors SET show_contact = ?
+ 'UPDATE authors SET show_contact = ?
WHERE id = (
SELECT author_id FROM thesis_authors
WHERE thesis_id = ?
ORDER BY author_order LIMIT 1
- )"
+ )'
);
$stmt->execute([$show ? 1 : 0, $thesisId]);
}
@@ -1208,14 +1388,15 @@ class Database {
/**
* Fetch all jury members for a thesis, with role and is_external flag.
*/
- public function getThesisJury(int $thesisId): array {
- $stmt = $this->pdo->prepare("
+ public function getThesisJury(int $thesisId): array
+ {
+ $stmt = $this->pdo->prepare('
SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order
FROM thesis_supervisors ts
JOIN supervisors s ON s.id = ts.supervisor_id
WHERE ts.thesis_id = ?
ORDER BY ts.supervisor_order
- ");
+ ');
$stmt->execute([$thesisId]);
return $stmt->fetchAll();
}
@@ -1224,20 +1405,23 @@ class Database {
* Replace the entire jury for a thesis in a single transaction.
* $juryMembers: array of ['name' => string, 'role' => string, 'is_external' => int]
*/
- public function setThesisJury(int $thesisId, array $juryMembers): void {
+ public function setThesisJury(int $thesisId, array $juryMembers): void
+ {
$alreadyInTransaction = $this->pdo->inTransaction();
if (!$alreadyInTransaction) {
$this->pdo->beginTransaction();
}
try {
- $this->pdo->prepare("DELETE FROM thesis_supervisors WHERE thesis_id = ?")->execute([$thesisId]);
- $stmt = $this->pdo->prepare("
+ $this->pdo->prepare('DELETE FROM thesis_supervisors WHERE thesis_id = ?')->execute([$thesisId]);
+ $stmt = $this->pdo->prepare('
INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order)
VALUES (?, ?, ?, ?, ?)
- ");
+ ');
foreach ($juryMembers as $order => $member) {
$name = trim($member['name'] ?? '');
- if ($name === '') continue;
+ if ($name === '') {
+ continue;
+ }
$supervisorId = $this->findOrCreateSupervisor($name);
$role = in_array($member['role'], ['president', 'promoteur', 'lecteur'])
? $member['role'] : 'promoteur';
@@ -1264,10 +1448,11 @@ class Database {
* @param int $thesisId
* @param int[] $languageIds IDs from the languages table
*/
- public function setThesisLanguages(int $thesisId, array $languageIds): void {
- $this->pdo->prepare("DELETE FROM thesis_languages WHERE thesis_id = ?")->execute([$thesisId]);
+ public function setThesisLanguages(int $thesisId, array $languageIds): void
+ {
+ $this->pdo->prepare('DELETE FROM thesis_languages WHERE thesis_id = ?')->execute([$thesisId]);
$stmt = $this->pdo->prepare(
- "INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)"
+ 'INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)'
);
foreach ($languageIds as $langId) {
$id = (int)$langId;
@@ -1282,10 +1467,11 @@ class Database {
* @param int $thesisId
* @param int[] $formatIds IDs from the format_types table
*/
- public function setThesisFormats(int $thesisId, array $formatIds): void {
- $this->pdo->prepare("DELETE FROM thesis_formats WHERE thesis_id = ?")->execute([$thesisId]);
+ public function setThesisFormats(int $thesisId, array $formatIds): void
+ {
+ $this->pdo->prepare('DELETE FROM thesis_formats WHERE thesis_id = ?')->execute([$thesisId]);
$stmt = $this->pdo->prepare(
- "INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)"
+ 'INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)'
);
foreach ($formatIds as $fmtId) {
$id = (int)$fmtId;
@@ -1299,9 +1485,10 @@ class Database {
* Return the list of language IDs currently linked to a thesis.
* @return int[]
*/
- public function getThesisLanguageIds(int $thesisId): array {
+ public function getThesisLanguageIds(int $thesisId): array
+ {
$stmt = $this->pdo->prepare(
- "SELECT language_id FROM thesis_languages WHERE thesis_id = ?"
+ 'SELECT language_id FROM thesis_languages WHERE thesis_id = ?'
);
$stmt->execute([$thesisId]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
@@ -1311,9 +1498,10 @@ class Database {
* Return the list of format IDs currently linked to a thesis.
* @return int[]
*/
- public function getThesisFormatIds(int $thesisId): array {
+ public function getThesisFormatIds(int $thesisId): array
+ {
$stmt = $this->pdo->prepare(
- "SELECT format_id FROM thesis_formats WHERE thesis_id = ?"
+ 'SELECT format_id FROM thesis_formats WHERE thesis_id = ?'
);
$stmt->execute([$thesisId]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
@@ -1328,14 +1516,17 @@ class Database {
* @param int $thesisId
* @param string[] $tagNames
*/
- public function setThesisTags(int $thesisId, array $tagNames): void {
- $this->pdo->prepare("DELETE FROM thesis_tags WHERE thesis_id = ?")->execute([$thesisId]);
+ public function setThesisTags(int $thesisId, array $tagNames): void
+ {
+ $this->pdo->prepare('DELETE FROM thesis_tags WHERE thesis_id = ?')->execute([$thesisId]);
$stmt = $this->pdo->prepare(
- "INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)"
+ 'INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)'
);
$count = 0;
foreach ($tagNames as $name) {
- if ($count >= 10) break;
+ if ($count >= 10) {
+ break;
+ }
$tagId = $this->findOrCreateTag($name); // trims, returns null for empty
if ($tagId !== null) {
$stmt->execute([$tagId, $thesisId]);
@@ -1351,9 +1542,10 @@ class Database {
/**
* Set (or clear) the banner_path for a thesis.
*/
- public function setBannerPath(int $thesisId, ?string $path): void {
+ public function setBannerPath(int $thesisId, ?string $path): void
+ {
$stmt = $this->pdo->prepare(
- "UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
+ 'UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
);
$stmt->execute([$path, $thesisId]);
}
@@ -1372,7 +1564,8 @@ class Database {
* @param array|null $uploadedFile Entry from $_FILES (e.g. $_FILES['banner'])
* @return string|null Relative path stored in the DB, or null
*/
- public function handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string {
+ public function handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string
+ {
if (!$uploadedFile || ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return null;
}
@@ -1388,13 +1581,13 @@ class Database {
if (!in_array($mimeType, $allowedMimes, true) ||
!in_array($ext, $allowedExts, true) ||
$uploadedFile['size'] > $maxBytes) {
- error_log("handleBannerUpload: rejected " . $uploadedFile['name'] . " ($mimeType, {$uploadedFile['size']} bytes)");
+ error_log('handleBannerUpload: rejected ' . $uploadedFile['name'] . " ($mimeType, {$uploadedFile['size']} bytes)");
return null;
}
$bannerDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/banners/' : null;
if (!$bannerDir) {
- error_log("handleBannerUpload: STORAGE_ROOT not defined");
+ error_log('handleBannerUpload: STORAGE_ROOT not defined');
return null;
}
if (!file_exists($bannerDir)) {
@@ -1405,7 +1598,7 @@ class Database {
$targetPath = $bannerDir . $safeName;
if (!move_uploaded_file($uploadedFile['tmp_name'], $targetPath)) {
- error_log("handleBannerUpload: move_uploaded_file failed for " . $uploadedFile['name']);
+ error_log('handleBannerUpload: move_uploaded_file failed for ' . $uploadedFile['name']);
return null;
}
@@ -1424,9 +1617,10 @@ class Database {
* Return the raw access_type_id for a thesis (used for visibility gating).
* Returns null if the thesis is not found.
*/
- public function getThesisAccessTypeId(int $thesisId): ?int {
+ public function getThesisAccessTypeId(int $thesisId): ?int
+ {
$stmt = $this->pdo->prepare(
- "SELECT access_type_id FROM theses WHERE id = ? LIMIT 1"
+ 'SELECT access_type_id FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$val = $stmt->fetchColumn();
@@ -1439,9 +1633,10 @@ class Database {
*
* @return array{license_id:int|null,access_type_id:int|null,context_note:string}|null
*/
- public function getThesisRawFields(int $thesisId): ?array {
+ public function getThesisRawFields(int $thesisId): ?array
+ {
$stmt = $this->pdo->prepare(
- "SELECT license_id, access_type_id, context_note FROM theses WHERE id = ? LIMIT 1"
+ 'SELECT license_id, access_type_id, context_note FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$row = $stmt->fetch();
@@ -1452,9 +1647,10 @@ class Database {
* Return the banner_path for a thesis, or null.
* Used when we need just the banner path without the full view expansion.
*/
- public function getThesisBannerPath(int $thesisId): ?string {
+ public function getThesisBannerPath(int $thesisId): ?string
+ {
$stmt = $this->pdo->prepare(
- "SELECT banner_path FROM theses WHERE id = ? LIMIT 1"
+ 'SELECT banner_path FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$val = $stmt->fetchColumn();
@@ -1468,7 +1664,8 @@ class Database {
* @param int[] $thesisIds
* @return array
*/
- public function getCoverPathsForTheses(array $thesisIds): array {
+ public function getCoverPathsForTheses(array $thesisIds): array
+ {
if (empty($thesisIds)) {
return [];
}
@@ -1492,13 +1689,14 @@ class Database {
*
* Access type 3 = Interdit (forbidden).
*/
- public function getFileVisibility(string $filePath): ?int {
- $stmt = $this->pdo->prepare("
+ public function getFileVisibility(string $filePath): ?int
+ {
+ $stmt = $this->pdo->prepare('
SELECT t.access_type_id FROM theses t
JOIN thesis_files tf ON tf.thesis_id = t.id
WHERE tf.file_path = ?
LIMIT 1
- ");
+ ');
$stmt->execute([$filePath]);
$val = $stmt->fetchColumn();
return ($val !== false) ? (int)$val : null;
@@ -1507,8 +1705,9 @@ class Database {
/**
* Return total number of rows in the theses table (for system status display).
*/
- public function getThesisCount(): int {
- return (int)$this->pdo->query("SELECT COUNT(*) FROM theses")->fetchColumn();
+ public function getThesisCount(): int
+ {
+ return (int)$this->pdo->query('SELECT COUNT(*) FROM theses')->fetchColumn();
}
/**
@@ -1526,13 +1725,14 @@ class Database {
*
* Must be called inside an open transaction.
*/
- public function generateThesisIdentifier(int $year): string {
+ public function generateThesisIdentifier(int $year): string
+ {
$stmt = $this->pdo->prepare(
- "SELECT COALESCE(MAX(CAST(SUBSTR(identifier, 6) AS INTEGER)), 0) FROM theses WHERE identifier LIKE ?"
+ 'SELECT COALESCE(MAX(CAST(SUBSTR(identifier, 6) AS INTEGER)), 0) FROM theses WHERE identifier LIKE ?'
);
$stmt->execute([$year . '-%']);
$maxSeq = (int)$stmt->fetchColumn();
- return sprintf("%d-%03d", $year, $maxSeq + 1);
+ return sprintf('%d-%03d', $year, $maxSeq + 1);
}
/**
@@ -1553,8 +1753,9 @@ class Database {
* Update core thesis fields.
* All columns except identifier, submitted_at, and file-related fields.
*/
- public function updateThesis(int $thesisId, array $data): void {
- $stmt = $this->pdo->prepare("
+ public function updateThesis(int $thesisId, array $data): void
+ {
+ $stmt = $this->pdo->prepare('
UPDATE theses SET
title = ?,
subtitle = ?,
@@ -1571,20 +1772,20 @@ class Database {
is_published = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
- ");
+ ');
$stmt->execute([
$data['title'],
- !empty($data['subtitle']) ? $data['subtitle'] : null,
+ !empty($data['subtitle']) ? $data['subtitle'] : null,
(int)$data['year'],
(int)$data['orientation_id'],
(int)$data['ap_program_id'],
(int)$data['finality_id'],
$data['synopsis'],
- !empty($data['context_note']) ? $data['context_note'] : null,
+ !empty($data['context_note']) ? $data['context_note'] : null,
!empty($data['file_size_info']) ? $data['file_size_info'] : null,
- !empty($data['baiu_link']) ? $data['baiu_link'] : null,
- isset($data['license_id']) ? $data['license_id'] : null,
- isset($data['access_type_id']) ? $data['access_type_id'] : null,
+ !empty($data['baiu_link']) ? $data['baiu_link'] : null,
+ $data['license_id'] ?? null,
+ $data['access_type_id'] ?? null,
$data['is_published'] ? 1 : 0,
$thesisId,
]);
@@ -1595,24 +1796,28 @@ class Database {
* $authors is an array of ['name' => string, 'email' => string|null].
* The first entry is considered the primary author (author_order = 1).
*/
- public function setThesisAuthors(int $thesisId, array $authors): void {
- $this->pdo->prepare("DELETE FROM thesis_authors WHERE thesis_id = ?")->execute([$thesisId]);
+ public function setThesisAuthors(int $thesisId, array $authors): void
+ {
+ $this->pdo->prepare('DELETE FROM thesis_authors WHERE thesis_id = ?')->execute([$thesisId]);
$stmt = $this->pdo->prepare(
- "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, ?)"
+ 'INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, ?)'
);
foreach ($authors as $index => $author) {
$name = trim($author['name'] ?? '');
- if ($name === '') continue;
+ if ($name === '') {
+ continue;
+ }
$showContact = !empty($author['show_contact']);
$authorId = $this->findOrCreateAuthor($name, $author['email'] ?? null, $showContact);
$stmt->execute([$thesisId, $authorId, (int)$index + 1]);
}
}
- public function createThesis(array $data): int {
+ public function createThesis(array $data): int
+ {
$identifier = $this->generateThesisIdentifier((int)$data['year']);
- $stmt = $this->pdo->prepare("
+ $stmt = $this->pdo->prepare('
INSERT INTO theses (
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
@@ -1623,7 +1828,7 @@ class Database {
is_published,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
- ");
+ ');
$validObjet = ['tfe', 'thèse', 'frart'];
$objet = in_array($data['objet'] ?? '', $validObjet, true) ? $data['objet'] : 'tfe';
@@ -1638,9 +1843,9 @@ class Database {
(int)$data['finality_id'],
$data['synopsis'],
!empty($data['file_size_info']) ? $data['file_size_info'] : null,
- !empty($data['baiu_link']) ? $data['baiu_link'] : null,
- isset($data['license_id']) ? $data['license_id'] : null,
- isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne
+ !empty($data['baiu_link']) ? $data['baiu_link'] : null,
+ $data['license_id'] ?? null,
+ isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne
$objet,
]);
@@ -1648,7 +1853,7 @@ class Database {
// Link author — always author_order = 1 for single-author submissions.
$stmt = $this->pdo->prepare(
- "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)"
+ 'INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)'
);
$stmt->execute([$thesisId, (int)$data['author_id']]);
@@ -1659,7 +1864,8 @@ class Database {
* Delete a single thesis and all its related data (cascade via FK).
* Also removes the banner file from disk if present.
*/
- public function deleteThesis(int $thesisId): void {
+ public function deleteThesis(int $thesisId): void
+ {
// Clean up banner file
$bannerPath = $this->getThesisBannerPath($thesisId);
if ($bannerPath !== null) {
@@ -1678,15 +1884,18 @@ class Database {
}
// DB cascade handles junction tables
- $this->pdo->prepare("DELETE FROM theses WHERE id = ?")->execute([$thesisId]);
+ $this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]);
}
/**
* Delete multiple theses at once.
* @param int[] $thesisIds
*/
- public function bulkDeleteTheses(array $thesisIds): void {
- if (empty($thesisIds)) return;
+ public function bulkDeleteTheses(array $thesisIds): void
+ {
+ if (empty($thesisIds)) {
+ return;
+ }
// Clean up files for each thesis
foreach ($thesisIds as $id) {
@@ -1712,8 +1921,9 @@ class Database {
/**
* Return the stored identifier string (e.g. "2024-003") for an existing thesis.
*/
- public function getThesisIdentifier(int $thesisId): string {
- $stmt = $this->pdo->prepare("SELECT identifier FROM theses WHERE id = ?");
+ public function getThesisIdentifier(int $thesisId): string
+ {
+ $stmt = $this->pdo->prepare('SELECT identifier FROM theses WHERE id = ?');
$stmt->execute([$thesisId]);
$row = $stmt->fetch();
if (!$row) {
@@ -1725,9 +1935,12 @@ class Database {
/**
* Delete every thesis in the database.
*/
- public function deleteAllTheses(): int {
- $ids = $this->pdo->query("SELECT id FROM theses")->fetchAll(\PDO::FETCH_COLUMN);
- if (empty($ids)) return 0;
+ public function deleteAllTheses(): int
+ {
+ $ids = $this->pdo->query('SELECT id FROM theses')->fetchAll(\PDO::FETCH_COLUMN);
+ if (empty($ids)) {
+ return 0;
+ }
$count = count($ids);
$this->bulkDeleteTheses($ids);
return $count;
@@ -1737,18 +1950,19 @@ class Database {
* Insert a thesis file record.
* sort_order defaults to (max existing sort_order + 1) for the thesis.
*/
- public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, ?string $displayLabel = null, ?int $sortOrder = null) {
+ public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, ?string $displayLabel = null, ?int $sortOrder = null)
+ {
if ($sortOrder === null) {
$maxStmt = $this->pdo->prepare(
- "SELECT COALESCE(MAX(sort_order), 0) FROM thesis_files WHERE thesis_id = ?"
+ 'SELECT COALESCE(MAX(sort_order), 0) FROM thesis_files WHERE thesis_id = ?'
);
$maxStmt->execute([$thesisId]);
$sortOrder = (int)$maxStmt->fetchColumn() + 1;
}
- $stmt = $this->pdo->prepare("
+ $stmt = $this->pdo->prepare('
INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ");
+ ');
$stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, $displayLabel, $sortOrder]);
return $this->pdo->lastInsertId();
}
@@ -1758,9 +1972,10 @@ class Database {
* $order is an array of file IDs in the desired order.
* Only files belonging to $thesisId are updated (safety guard).
*/
- public function reorderThesisFiles(int $thesisId, array $order): void {
+ public function reorderThesisFiles(int $thesisId, array $order): void
+ {
$stmt = $this->pdo->prepare(
- "UPDATE thesis_files SET sort_order = ? WHERE id = ? AND thesis_id = ?"
+ 'UPDATE thesis_files SET sort_order = ? WHERE id = ? AND thesis_id = ?'
);
foreach ($order as $i => $fileId) {
$stmt->execute([(int)$i + 1, (int)$fileId, $thesisId]);
@@ -1770,9 +1985,10 @@ class Database {
/**
* Update the display_label for a thesis file.
*/
- public function updateThesisFileLabel(int $fileId, int $thesisId, ?string $label): void {
+ public function updateThesisFileLabel(int $fileId, int $thesisId, ?string $label): void
+ {
$this->pdo->prepare(
- "UPDATE thesis_files SET display_label = ? WHERE id = ? AND thesis_id = ?"
+ 'UPDATE thesis_files SET display_label = ? WHERE id = ? AND thesis_id = ?'
)->execute([$label ?: null, $fileId, $thesisId]);
}
@@ -1788,14 +2004,14 @@ class Database {
public function deleteThesisFile(int $fileId, int $thesisId): ?string
{
$stmt = $this->pdo->prepare(
- "SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1"
+ 'SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1'
);
$stmt->execute([$fileId, $thesisId]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
- $this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$fileId]);
+ $this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$fileId]);
return $row['file_path'];
}
@@ -1834,10 +2050,12 @@ class Database {
);
$existing->execute([$thesisId]);
if ($old = $existing->fetch()) {
- $this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$old['id']]);
+ $this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$old['id']]);
if (!empty($old['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $old['file_path'];
- if (file_exists($abs)) @unlink($abs);
+ if (file_exists($abs)) {
+ @unlink($abs);
+ }
}
}
@@ -1873,8 +2091,9 @@ class Database {
* Fetch all theses (admin — includes unpublished) with every column
* needed for the CSV export.
*/
- public function getAllThesesForExport(): array {
- return $this->pdo->query("
+ public function getAllThesesForExport(): array
+ {
+ return $this->pdo->query('
SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name AS orientation,
@@ -1895,67 +2114,72 @@ class Database {
LEFT JOIN access_types at ON t.access_type_id = at.id
LEFT JOIN license_types lt ON t.license_id = lt.id
ORDER BY t.year DESC, t.title ASC
- ")->fetchAll();
+ ')->fetchAll();
}
/**
* All thesis→author rows with author name and email.
*/
- public function getAllThesisAuthorsForExport(): array {
- return $this->pdo->query("
+ public function getAllThesisAuthorsForExport(): array
+ {
+ return $this->pdo->query('
SELECT ta.thesis_id, a.name, a.email
FROM thesis_authors ta
JOIN authors a ON a.id = ta.author_id
ORDER BY ta.thesis_id, ta.author_order
- ")->fetchAll();
+ ')->fetchAll();
}
/**
* All thesis→supervisor rows with name.
*/
- public function getAllThesisSupervisorsForExport(): array {
- return $this->pdo->query("
+ public function getAllThesisSupervisorsForExport(): array
+ {
+ return $this->pdo->query('
SELECT ts.thesis_id, s.name
FROM thesis_supervisors ts
JOIN supervisors s ON s.id = ts.supervisor_id
ORDER BY ts.thesis_id, ts.supervisor_order
- ")->fetchAll();
+ ')->fetchAll();
}
/**
* All thesis→tag rows with tag name.
*/
- public function getAllThesisTagsForExport(): array {
- return $this->pdo->query("
+ public function getAllThesisTagsForExport(): array
+ {
+ return $this->pdo->query('
SELECT tt.thesis_id, t.name
FROM thesis_tags tt
JOIN tags t ON t.id = tt.tag_id
ORDER BY tt.thesis_id, t.name
- ")->fetchAll();
+ ')->fetchAll();
}
/**
* All thesis→language rows with language name.
*/
- public function getAllThesisLanguagesForExport(): array {
- return $this->pdo->query("
+ public function getAllThesisLanguagesForExport(): array
+ {
+ return $this->pdo->query('
SELECT tl.thesis_id, l.name
FROM thesis_languages tl
JOIN languages l ON l.id = tl.language_id
ORDER BY tl.thesis_id, l.name
- ")->fetchAll();
+ ')->fetchAll();
}
/**
* All thesis→format rows with format name.
*/
- public function getAllThesisFormatsForExport(): array {
- return $this->pdo->query("
+ public function getAllThesisFormatsForExport(): array
+ {
+ return $this->pdo->query('
SELECT tf.thesis_id, ft.name
FROM thesis_formats tf
JOIN format_types ft ON ft.id = tf.format_id
ORDER BY tf.thesis_id, ft.name
- ")->fetchAll();
+ ')->fetchAll();
}
// ========================================================================
@@ -1971,11 +2195,14 @@ class Database {
* @param string $key 'contacts', 'credits', or 'erg_url'
* @return array|string|null JSON-decoded array for contacts/credits, string for erg_url
*/
- public function getAproposContent(string $key) {
- $stmt = $this->pdo->prepare("SELECT value FROM apropos_contents WHERE key = ?");
+ public function getAproposContent(string $key)
+ {
+ $stmt = $this->pdo->prepare('SELECT value FROM apropos_contents WHERE key = ?');
$stmt->execute([$key]);
$row = $stmt->fetch();
- if (!$row) return null;
+ if (!$row) {
+ return null;
+ }
$value = $row['value'];
if ($key === 'erg_url') {
@@ -1990,15 +2217,16 @@ class Database {
* @param string $key
* @param mixed $value array for contacts/credits, string for erg_url
*/
- public function saveAproposContent(string $key, $value): void {
- $stmt = $this->pdo->prepare("SELECT id FROM apropos_contents WHERE key = ?");
+ public function saveAproposContent(string $key, $value): void
+ {
+ $stmt = $this->pdo->prepare('SELECT id FROM apropos_contents WHERE key = ?');
$stmt->execute([$key]);
if (!$stmt->fetch()) {
throw new Exception("Apropos key not found: $key");
}
$storedValue = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string)$value;
$stmt = $this->pdo->prepare(
- "UPDATE apropos_contents SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?"
+ 'UPDATE apropos_contents SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?'
);
$stmt->execute([$storedValue, $key]);
}
@@ -2006,8 +2234,9 @@ class Database {
/**
* Get all apropos contents as [key => value] pairs (raw DB values).
*/
- public function getAllAproposContents(): array {
- $stmt = $this->pdo->query("SELECT key, value, updated_at FROM apropos_contents ORDER BY key");
+ public function getAllAproposContents(): array
+ {
+ $stmt = $this->pdo->query('SELECT key, value, updated_at FROM apropos_contents ORDER BY key');
return $stmt->fetchAll();
}
@@ -2046,9 +2275,10 @@ class Database {
/**
* Get a single form help block by key. Returns '' when missing.
*/
- public function getFormHelpBlock(string $key): string {
+ public function getFormHelpBlock(string $key): string
+ {
$stmt = $this->pdo->prepare(
- "SELECT content FROM form_help_blocks WHERE key = ? LIMIT 1"
+ 'SELECT content FROM form_help_blocks WHERE key = ? LIMIT 1'
);
$stmt->execute([$key]);
$val = $stmt->fetchColumn();
@@ -2058,24 +2288,26 @@ class Database {
/**
* Upsert a form help block.
*/
- public function setFormHelpBlock(string $key, string $content): void {
+ public function setFormHelpBlock(string $key, string $content): void
+ {
if (!in_array($key, self::FORM_HELP_KEYS, true)) {
throw new Exception("Unknown form help block key: $key");
}
$this->pdo->prepare(
- "INSERT INTO form_help_blocks (key, content, updated_at)
+ 'INSERT INTO form_help_blocks (key, content, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET content = excluded.content,
- updated_at = CURRENT_TIMESTAMP"
+ updated_at = CURRENT_TIMESTAMP'
)->execute([$key, $content]);
}
/**
* Return all form help blocks ordered by sort_order, as [ key => ['content' => ..., 'updated_at' => ..., 'sort_order' => ...] ].
*/
- public function getAllFormHelpBlocks(): array {
+ public function getAllFormHelpBlocks(): array
+ {
$stmt = $this->pdo->query(
- "SELECT key, content, updated_at, sort_order FROM form_help_blocks ORDER BY sort_order, key"
+ 'SELECT key, content, updated_at, sort_order FROM form_help_blocks ORDER BY sort_order, key'
);
$rows = $stmt->fetchAll();
$out = [];
@@ -2093,9 +2325,10 @@ class Database {
* Persist a new sort order for all form help blocks.
* $keys must be an ordered array of known block keys.
*/
- public function reorderFormHelpBlocks(array $keys): void {
+ public function reorderFormHelpBlocks(array $keys): void
+ {
$stmt = $this->pdo->prepare(
- "UPDATE form_help_blocks SET sort_order = ? WHERE key = ?"
+ 'UPDATE form_help_blocks SET sort_order = ? WHERE key = ?'
);
foreach ($keys as $i => $key) {
if (in_array($key, self::FORM_HELP_KEYS, true)) {
@@ -2111,13 +2344,16 @@ class Database {
/**
* Prevent cloning
*/
- private function __clone() {}
+ private function __clone()
+ {
+ }
/**
* Prevent unserialization.
* PHP 8.x deprecates throwing from __wakeup(); use trigger_error instead.
*/
- public function __wakeup(): void {
+ public function __wakeup(): void
+ {
// phpcs:ignore
trigger_error('Cannot unserialize singleton ' . static::class, E_USER_ERROR);
}
@@ -2129,7 +2365,8 @@ class Database {
/**
* Check if restricted files feature is enabled.
*/
- public function isRestrictedFilesEnabled(): bool {
+ public function isRestrictedFilesEnabled(): bool
+ {
return $this->getSetting('restricted_files_enabled', '0') === '1';
}
@@ -2141,7 +2378,8 @@ class Database {
* @param string $justification Optional justification for non-ERG emails
* @return int New request ID
*/
- public function createFileAccessRequest(int $thesisId, string $email, ?string $justification = null): int {
+ public function createFileAccessRequest(int $thesisId, string $email, ?string $justification = null): int
+ {
$stmt = $this->pdo->prepare(
"INSERT INTO file_access_requests (thesis_id, email, justification, status)
VALUES (?, ?, ?, 'pending')"
@@ -2165,13 +2403,14 @@ class Database {
* @param int $expiryHours Hours until token expires (default: 24)
* @return string The generated token (256-bit hex)
*/
- public function generateAccessToken(int $requestId, int $expiryHours = 24): string {
+ public function generateAccessToken(int $requestId, int $expiryHours = 24): string
+ {
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + $expiryHours * 3600);
$stmt = $this->pdo->prepare(
- "INSERT INTO file_access_tokens (request_id, token, expires_at)
- VALUES (?, ?, ?)"
+ 'INSERT INTO file_access_tokens (request_id, token, expires_at)
+ VALUES (?, ?, ?)'
);
$stmt->execute([$requestId, $token, $expiresAt]);
@@ -2200,7 +2439,8 @@ class Database {
* @param string $ua Client User-Agent for audit log
* @return array{thesis_id:int,request_id:int}|null
*/
- public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array {
+ public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array
+ {
// Look up the token — only valid if unused, unexpired, and approved
$stmt = $this->pdo->prepare(
"SELECT fat.id AS token_id, fat.request_id, fr.thesis_id
@@ -2218,7 +2458,7 @@ class Database {
if (!$row) {
// Log failed attempt if we can find the token at all
$check = $this->pdo->prepare(
- "SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1"
+ 'SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1'
);
$check->execute([$token]);
$bad = $check->fetch();
@@ -2230,7 +2470,7 @@ class Database {
// Mark token as used (one-time)
$this->pdo->prepare(
- "UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?"
+ 'UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?'
)->execute([(int)$row['token_id']]);
// Audit log
@@ -2250,13 +2490,14 @@ class Database {
* @param int $expiryDays Days until session expires (default: 30)
* @return string Session token (256-bit hex)
*/
- public function createAccessSession(int $requestId, int $expiryDays = 30): string {
+ public function createAccessSession(int $requestId, int $expiryDays = 30): string
+ {
$sessionToken = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + $expiryDays * 86400);
$this->pdo->prepare(
- "INSERT INTO file_access_sessions (request_id, session_token, expires_at)
- VALUES (?, ?, ?)"
+ 'INSERT INTO file_access_sessions (request_id, session_token, expires_at)
+ VALUES (?, ?, ?)'
)->execute([$requestId, $sessionToken, $expiresAt]);
return $sessionToken;
@@ -2269,7 +2510,8 @@ class Database {
* @param int $thesisId
* @return bool True if access is granted
*/
- public function hasValidCookieAccess(string $sessionToken, int $thesisId): bool {
+ public function hasValidCookieAccess(string $sessionToken, int $thesisId): bool
+ {
$stmt = $this->pdo->prepare(
"SELECT COUNT(*) as count
FROM file_access_sessions fas
@@ -2294,10 +2536,11 @@ class Database {
* @param string $ip
* @param string $ua
*/
- public function logAccessAudit(int $requestId, string $event, string $ip, string $ua): void {
+ public function logAccessAudit(int $requestId, string $event, string $ip, string $ua): void
+ {
$this->pdo->prepare(
- "INSERT INTO file_access_audit (request_id, event, ip, user_agent)
- VALUES (?, ?, ?, ?)"
+ 'INSERT INTO file_access_audit (request_id, event, ip, user_agent)
+ VALUES (?, ?, ?, ?)'
)->execute([$requestId, $event, $ip, $ua]);
}
@@ -2308,7 +2551,8 @@ class Database {
* @param int $offset Pagination offset
* @return array List of pending requests with thesis info
*/
- public function getPendingAccessRequests(int $limit = 50, int $offset = 0): array {
+ public function getPendingAccessRequests(int $limit = 50, int $offset = 0): array
+ {
$sql = "
SELECT
far.id,
@@ -2350,7 +2594,8 @@ class Database {
* @param int $expiryHours Hours until email link expires (default: 24)
* @return string The generated one-time access token
*/
- public function approveAccessRequest(int $requestId, ?int $adminId = null, int $expiryHours = 24): string {
+ public function approveAccessRequest(int $requestId, ?int $adminId = null, int $expiryHours = 24): string
+ {
$this->pdo->beginTransaction();
try {
// Update request status
@@ -2380,7 +2625,8 @@ class Database {
* @param int $requestId
* @param string $adminNotes Optional rejection notes
*/
- public function rejectAccessRequest(int $requestId, ?string $adminNotes = null): void {
+ public function rejectAccessRequest(int $requestId, ?string $adminNotes = null): void
+ {
$stmt = $this->pdo->prepare(
"UPDATE file_access_requests
SET status = 'rejected',
@@ -2397,8 +2643,9 @@ class Database {
* @param int $requestId
* @return array|null Request data or null if not found
*/
- public function getAccessRequestById(int $requestId): ?array {
- $stmt = $this->pdo->prepare("
+ public function getAccessRequestById(int $requestId): ?array
+ {
+ $stmt = $this->pdo->prepare('
SELECT
far.*,
t.id as thesis_id,
@@ -2411,7 +2658,7 @@ class Database {
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
WHERE far.id = ?
- ");
+ ');
$stmt->execute([$requestId]);
$result = $stmt->fetch();
@@ -2421,7 +2668,8 @@ class Database {
/**
* Count pending access requests.
*/
- public function countPendingAccessRequests(): int {
+ public function countPendingAccessRequests(): int
+ {
$stmt = $this->pdo->query(
"SELECT COUNT(*) as count FROM file_access_requests WHERE status = 'pending'"
);
@@ -2436,17 +2684,18 @@ class Database {
* @param string $email
* @return array|null Existing request or null
*/
- public function getExistingAccessRequest(int $thesisId, string $email): ?array {
+ public function getExistingAccessRequest(int $thesisId, string $email): ?array
+ {
$stmt = $this->pdo->prepare(
- "SELECT id, status, created_at
+ 'SELECT id, status, created_at
FROM file_access_requests
WHERE thesis_id = ? AND email = ?
ORDER BY created_at DESC
- LIMIT 1"
+ LIMIT 1'
);
$stmt->execute([$thesisId, $email]);
$result = $stmt->fetch();
return $result ?: null;
}
-}
\ No newline at end of file
+}
diff --git a/app/src/Dispatcher.php b/app/src/Dispatcher.php
index c076bd5..a584ff1 100644
--- a/app/src/Dispatcher.php
+++ b/app/src/Dispatcher.php
@@ -1,4 +1,5 @@
→ 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);
diff --git a/app/src/DuplicateThesisException.php b/app/src/DuplicateThesisException.php
new file mode 100644
index 0000000..487d238
--- /dev/null
+++ b/app/src/DuplicateThesisException.php
@@ -0,0 +1,25 @@
+textElements($text);
@@ -37,10 +37,10 @@ class Parsedown
protected function textElements($text)
{
# make sure no definitions are set
- $this->DefinitionData = array();
+ $this->DefinitionData = [];
# standardize line breaks
- $text = str_replace(array("\r\n", "\r"), "\n", $text);
+ $text = str_replace(["\r\n", "\r"], "\n", $text);
# remove surrounding line breaks
$text = trim($text, "\n");
@@ -56,7 +56,7 @@ class Parsedown
# Setters
#
- function setBreaksEnabled($breaksEnabled)
+ public function setBreaksEnabled($breaksEnabled)
{
$this->breaksEnabled = $breaksEnabled;
@@ -65,7 +65,7 @@ class Parsedown
protected $breaksEnabled;
- function setMarkupEscaped($markupEscaped)
+ public function setMarkupEscaped($markupEscaped)
{
$this->markupEscaped = $markupEscaped;
@@ -74,7 +74,7 @@ class Parsedown
protected $markupEscaped;
- function setUrlsLinked($urlsLinked)
+ public function setUrlsLinked($urlsLinked)
{
$this->urlsLinked = $urlsLinked;
@@ -83,7 +83,7 @@ class Parsedown
protected $urlsLinked = true;
- function setSafeMode($safeMode)
+ public function setSafeMode($safeMode)
{
$this->safeMode = (bool) $safeMode;
@@ -92,7 +92,7 @@ class Parsedown
protected $safeMode;
- function setStrictMode($strictMode)
+ public function setStrictMode($strictMode)
{
$this->strictMode = (bool) $strictMode;
@@ -101,7 +101,7 @@ class Parsedown
protected $strictMode;
- protected $safeLinksWhitelist = array(
+ protected $safeLinksWhitelist = [
'http://',
'https://',
'ftp://',
@@ -117,43 +117,43 @@ class Parsedown
'ssh:',
'news:',
'steam:',
- );
+ ];
#
# Lines
#
- protected $BlockTypes = array(
- '#' => array('Header'),
- '*' => array('Rule', 'List'),
- '+' => array('List'),
- '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
- '0' => array('List'),
- '1' => array('List'),
- '2' => array('List'),
- '3' => array('List'),
- '4' => array('List'),
- '5' => array('List'),
- '6' => array('List'),
- '7' => array('List'),
- '8' => array('List'),
- '9' => array('List'),
- ':' => array('Table'),
- '<' => array('Comment', 'Markup'),
- '=' => array('SetextHeader'),
- '>' => array('Quote'),
- '[' => array('Reference'),
- '_' => array('Rule'),
- '`' => array('FencedCode'),
- '|' => array('Table'),
- '~' => array('FencedCode'),
- );
+ protected $BlockTypes = [
+ '#' => ['Header'],
+ '*' => ['Rule', 'List'],
+ '+' => ['List'],
+ '-' => ['SetextHeader', 'Table', 'Rule', 'List'],
+ '0' => ['List'],
+ '1' => ['List'],
+ '2' => ['List'],
+ '3' => ['List'],
+ '4' => ['List'],
+ '5' => ['List'],
+ '6' => ['List'],
+ '7' => ['List'],
+ '8' => ['List'],
+ '9' => ['List'],
+ ':' => ['Table'],
+ '<' => ['Comment', 'Markup'],
+ '=' => ['SetextHeader'],
+ '>' => ['Quote'],
+ '[' => ['Reference'],
+ '_' => ['Rule'],
+ '`' => ['FencedCode'],
+ '|' => ['Table'],
+ '~' => ['FencedCode'],
+ ];
# ~
- protected $unmarkedBlockTypes = array(
+ protected $unmarkedBlockTypes = [
'Code',
- );
+ ];
#
# Blocks
@@ -166,16 +166,14 @@ class Parsedown
protected function linesElements(array $lines)
{
- $Elements = array();
+ $Elements = [];
$CurrentBlock = null;
- foreach ($lines as $line)
- {
- if (chop($line) === '')
- {
- if (isset($CurrentBlock))
- {
- $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted'])
+ foreach ($lines as $line) {
+ if (chop($line) === '') {
+ if (isset($CurrentBlock)) {
+ $CurrentBlock['interrupted'] = (
+ isset($CurrentBlock['interrupted'])
? $CurrentBlock['interrupted'] + 1 : 1
);
}
@@ -183,8 +181,7 @@ class Parsedown
continue;
}
- while (($beforeTab = strstr($line, "\t", true)) !== false)
- {
+ while (($beforeTab = strstr($line, "\t", true)) !== false) {
$shortage = 4 - strlen($beforeTab) % 4;
$line = $beforeTab
@@ -199,25 +196,20 @@ class Parsedown
# ~
- $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+ $Line = ['body' => $line, 'indent' => $indent, 'text' => $text];
# ~
- if (isset($CurrentBlock['continuable']))
- {
+ if (isset($CurrentBlock['continuable'])) {
$methodName = 'block' . $CurrentBlock['type'] . 'Continue';
$Block = $this->$methodName($Line, $CurrentBlock);
- if (isset($Block))
- {
+ if (isset($Block)) {
$CurrentBlock = $Block;
continue;
- }
- else
- {
- if ($this->isBlockCompletable($CurrentBlock['type']))
- {
+ } else {
+ if ($this->isBlockCompletable($CurrentBlock['type'])) {
$methodName = 'block' . $CurrentBlock['type'] . 'Complete';
$CurrentBlock = $this->$methodName($CurrentBlock);
}
@@ -232,37 +224,30 @@ class Parsedown
$blockTypes = $this->unmarkedBlockTypes;
- if (isset($this->BlockTypes[$marker]))
- {
- foreach ($this->BlockTypes[$marker] as $blockType)
- {
- $blockTypes []= $blockType;
+ if (isset($this->BlockTypes[$marker])) {
+ foreach ($this->BlockTypes[$marker] as $blockType) {
+ $blockTypes [] = $blockType;
}
}
#
# ~
- foreach ($blockTypes as $blockType)
- {
+ foreach ($blockTypes as $blockType) {
$Block = $this->{"block$blockType"}($Line, $CurrentBlock);
- if (isset($Block))
- {
+ if (isset($Block)) {
$Block['type'] = $blockType;
- if ( ! isset($Block['identified']))
- {
- if (isset($CurrentBlock))
- {
+ if (! isset($Block['identified'])) {
+ if (isset($CurrentBlock)) {
$Elements[] = $this->extractElement($CurrentBlock);
}
$Block['identified'] = true;
}
- if ($this->isBlockContinuable($blockType))
- {
+ if ($this->isBlockContinuable($blockType)) {
$Block['continuable'] = true;
}
@@ -274,19 +259,14 @@ class Parsedown
# ~
- if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph')
- {
+ if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') {
$Block = $this->paragraphContinue($Line, $CurrentBlock);
}
- if (isset($Block))
- {
+ if (isset($Block)) {
$CurrentBlock = $Block;
- }
- else
- {
- if (isset($CurrentBlock))
- {
+ } else {
+ if (isset($CurrentBlock)) {
$Elements[] = $this->extractElement($CurrentBlock);
}
@@ -298,16 +278,14 @@ class Parsedown
# ~
- if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
- {
+ if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) {
$methodName = 'block' . $CurrentBlock['type'] . 'Complete';
$CurrentBlock = $this->$methodName($CurrentBlock);
}
# ~
- if (isset($CurrentBlock))
- {
+ if (isset($CurrentBlock)) {
$Elements[] = $this->extractElement($CurrentBlock);
}
@@ -318,15 +296,11 @@ class Parsedown
protected function extractElement(array $Component)
{
- if ( ! isset($Component['element']))
- {
- if (isset($Component['markup']))
- {
- $Component['element'] = array('rawHtml' => $Component['markup']);
- }
- elseif (isset($Component['hidden']))
- {
- $Component['element'] = array();
+ if (! isset($Component['element'])) {
+ if (isset($Component['markup'])) {
+ $Component['element'] = ['rawHtml' => $Component['markup']];
+ } elseif (isset($Component['hidden'])) {
+ $Component['element'] = [];
}
}
@@ -348,24 +322,22 @@ class Parsedown
protected function blockCode($Line, $Block = null)
{
- if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted']))
- {
+ if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) {
return;
}
- if ($Line['indent'] >= 4)
- {
+ if ($Line['indent'] >= 4) {
$text = substr($Line['body'], 4);
- $Block = array(
- 'element' => array(
+ $Block = [
+ 'element' => [
'name' => 'pre',
- 'element' => array(
+ 'element' => [
'name' => 'code',
'text' => $text,
- ),
- ),
- );
+ ],
+ ],
+ ];
return $Block;
}
@@ -373,10 +345,8 @@ class Parsedown
protected function blockCodeContinue($Line, $Block)
{
- if ($Line['indent'] >= 4)
- {
- if (isset($Block['interrupted']))
- {
+ if ($Line['indent'] >= 4) {
+ if (isset($Block['interrupted'])) {
$Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
unset($Block['interrupted']);
@@ -402,22 +372,19 @@ class Parsedown
protected function blockComment($Line)
{
- if ($this->markupEscaped or $this->safeMode)
- {
+ if ($this->markupEscaped or $this->safeMode) {
return;
}
- if (strpos($Line['text'], '') !== false)
- {
+ if (strpos($Line['text'], '-->') !== false) {
$Block['closed'] = true;
}
@@ -427,15 +394,13 @@ class Parsedown
protected function blockCommentContinue($Line, array $Block)
{
- if (isset($Block['closed']))
- {
+ if (isset($Block['closed'])) {
return;
}
$Block['element']['rawHtml'] .= "\n" . $Line['body'];
- if (strpos($Line['text'], '-->') !== false)
- {
+ if (strpos($Line['text'], '-->') !== false) {
$Block['closed'] = true;
}
@@ -451,25 +416,22 @@ class Parsedown
$openerLength = strspn($Line['text'], $marker);
- if ($openerLength < 3)
- {
+ if ($openerLength < 3) {
return;
}
$infostring = trim(substr($Line['text'], $openerLength), "\t ");
- if (strpos($infostring, '`') !== false)
- {
+ if (strpos($infostring, '`') !== false) {
return;
}
- $Element = array(
+ $Element = [
'name' => 'code',
'text' => '',
- );
+ ];
- if ($infostring !== '')
- {
+ if ($infostring !== '') {
/**
* https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
* Every HTML element may have a class attribute specified.
@@ -484,30 +446,28 @@ class Parsedown
*/
$language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r"));
- $Element['attributes'] = array('class' => "language-$language");
+ $Element['attributes'] = ['class' => "language-$language"];
}
- $Block = array(
+ $Block = [
'char' => $marker,
'openerLength' => $openerLength,
- 'element' => array(
+ 'element' => [
'name' => 'pre',
'element' => $Element,
- ),
- );
+ ],
+ ];
return $Block;
}
protected function blockFencedCodeContinue($Line, $Block)
{
- if (isset($Block['complete']))
- {
+ if (isset($Block['complete'])) {
return;
}
- if (isset($Block['interrupted']))
- {
+ if (isset($Block['interrupted'])) {
$Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
unset($Block['interrupted']);
@@ -540,30 +500,28 @@ class Parsedown
{
$level = strspn($Line['text'], '#');
- if ($level > 6)
- {
+ if ($level > 6) {
return;
}
$text = trim($Line['text'], '#');
- if ($this->strictMode and isset($text[0]) and $text[0] !== ' ')
- {
+ if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') {
return;
}
$text = trim($text, ' ');
- $Block = array(
- 'element' => array(
+ $Block = [
+ 'element' => [
'name' => 'h' . $level,
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => $text,
'destination' => 'elements',
- )
- ),
- );
+ ],
+ ],
+ ];
return $Block;
}
@@ -573,46 +531,40 @@ class Parsedown
protected function blockList($Line, ?array $CurrentBlock = null)
{
- list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]');
+ [$name, $pattern] = $Line['text'][0] <= '-' ? ['ul', '[*+-]'] : ['ol', '[0-9]{1,9}+[.\)]'];
- if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches))
- {
+ if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) {
$contentIndent = strlen($matches[2]);
- if ($contentIndent >= 5)
- {
+ if ($contentIndent >= 5) {
$contentIndent -= 1;
$matches[1] = substr($matches[1], 0, -$contentIndent);
$matches[3] = str_repeat(' ', $contentIndent) . $matches[3];
- }
- elseif ($contentIndent === 0)
- {
+ } elseif ($contentIndent === 0) {
$matches[1] .= ' ';
}
$markerWithoutWhitespace = strstr($matches[1], ' ', true);
- $Block = array(
+ $Block = [
'indent' => $Line['indent'],
'pattern' => $pattern,
- 'data' => array(
+ 'data' => [
'type' => $name,
'marker' => $matches[1],
'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)),
- ),
- 'element' => array(
+ ],
+ 'element' => [
'name' => $name,
- 'elements' => array(),
- ),
- );
+ 'elements' => [],
+ ],
+ ];
$Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/');
- if ($name === 'ol')
- {
+ if ($name === 'ol') {
$listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0';
- if ($listStart !== '1')
- {
+ if ($listStart !== '1') {
if (
isset($CurrentBlock)
and $CurrentBlock['type'] === 'Paragraph'
@@ -621,20 +573,20 @@ class Parsedown
return;
}
- $Block['element']['attributes'] = array('start' => $listStart);
+ $Block['element']['attributes'] = ['start' => $listStart];
}
}
- $Block['li'] = array(
+ $Block['li'] = [
'name' => 'li',
- 'handler' => array(
+ 'handler' => [
'function' => 'li',
- 'argument' => !empty($matches[3]) ? array($matches[3]) : array(),
- 'destination' => 'elements'
- )
- );
+ 'argument' => !empty($matches[3]) ? [$matches[3]] : [],
+ 'destination' => 'elements',
+ ],
+ ];
- $Block['element']['elements'] []= & $Block['li'];
+ $Block['element']['elements'] [] = & $Block['li'];
return $Block;
}
@@ -642,8 +594,7 @@ class Parsedown
protected function blockListContinue($Line, array $Block)
{
- if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument']))
- {
+ if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) {
return null;
}
@@ -660,9 +611,8 @@ class Parsedown
)
)
) {
- if (isset($Block['interrupted']))
- {
- $Block['li']['handler']['argument'] []= '';
+ if (isset($Block['interrupted'])) {
+ $Block['li']['handler']['argument'] [] = '';
$Block['loose'] = true;
@@ -671,38 +621,33 @@ class Parsedown
unset($Block['li']);
- $text = isset($matches[1]) ? $matches[1] : '';
+ $text = $matches[1] ?? '';
$Block['indent'] = $Line['indent'];
- $Block['li'] = array(
+ $Block['li'] = [
'name' => 'li',
- 'handler' => array(
+ 'handler' => [
'function' => 'li',
- 'argument' => array($text),
- 'destination' => 'elements'
- )
- );
+ 'argument' => [$text],
+ 'destination' => 'elements',
+ ],
+ ];
- $Block['element']['elements'] []= & $Block['li'];
+ $Block['element']['elements'] [] = & $Block['li'];
return $Block;
- }
- elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line))
- {
+ } elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) {
return null;
}
- if ($Line['text'][0] === '[' and $this->blockReference($Line))
- {
+ if ($Line['text'][0] === '[' and $this->blockReference($Line)) {
return $Block;
}
- if ($Line['indent'] >= $requiredIndent)
- {
- if (isset($Block['interrupted']))
- {
- $Block['li']['handler']['argument'] []= '';
+ if ($Line['indent'] >= $requiredIndent) {
+ if (isset($Block['interrupted'])) {
+ $Block['li']['handler']['argument'] [] = '';
$Block['loose'] = true;
@@ -711,16 +656,15 @@ class Parsedown
$text = substr($Line['body'], $requiredIndent);
- $Block['li']['handler']['argument'] []= $text;
+ $Block['li']['handler']['argument'] [] = $text;
return $Block;
}
- if ( ! isset($Block['interrupted']))
- {
+ if (! isset($Block['interrupted'])) {
$text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']);
- $Block['li']['handler']['argument'] []= $text;
+ $Block['li']['handler']['argument'] [] = $text;
return $Block;
}
@@ -728,13 +672,10 @@ class Parsedown
protected function blockListComplete(array $Block)
{
- if (isset($Block['loose']))
- {
- foreach ($Block['element']['elements'] as &$li)
- {
- if (end($li['handler']['argument']) !== '')
- {
- $li['handler']['argument'] []= '';
+ if (isset($Block['loose'])) {
+ foreach ($Block['element']['elements'] as &$li) {
+ if (end($li['handler']['argument']) !== '') {
+ $li['handler']['argument'] [] = '';
}
}
}
@@ -747,18 +688,17 @@ class Parsedown
protected function blockQuote($Line)
{
- if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
- {
- $Block = array(
- 'element' => array(
+ if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) {
+ $Block = [
+ 'element' => [
'name' => 'blockquote',
- 'handler' => array(
+ 'handler' => [
'function' => 'linesElements',
'argument' => (array) $matches[1],
'destination' => 'elements',
- )
- ),
- );
+ ],
+ ],
+ ];
return $Block;
}
@@ -766,21 +706,18 @@ class Parsedown
protected function blockQuoteContinue($Line, array $Block)
{
- if (isset($Block['interrupted']))
- {
+ if (isset($Block['interrupted'])) {
return;
}
- if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
- {
- $Block['element']['handler']['argument'] []= $matches[1];
+ if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) {
+ $Block['element']['handler']['argument'] [] = $matches[1];
return $Block;
}
- if ( ! isset($Block['interrupted']))
- {
- $Block['element']['handler']['argument'] []= $Line['text'];
+ if (! isset($Block['interrupted'])) {
+ $Block['element']['handler']['argument'] [] = $Line['text'];
return $Block;
}
@@ -793,13 +730,12 @@ class Parsedown
{
$marker = $Line['text'][0];
- if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '')
- {
- $Block = array(
- 'element' => array(
+ if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') {
+ $Block = [
+ 'element' => [
'name' => 'hr',
- ),
- );
+ ],
+ ];
return $Block;
}
@@ -810,13 +746,11 @@ class Parsedown
protected function blockSetextHeader($Line, ?array $Block = null)
{
- if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
- {
+ if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) {
return;
}
- if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '')
- {
+ if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') {
$Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
return $Block;
@@ -828,27 +762,24 @@ class Parsedown
protected function blockMarkup($Line)
{
- if ($this->markupEscaped or $this->safeMode)
- {
+ if ($this->markupEscaped or $this->safeMode) {
return;
}
- if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches))
- {
+ if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) {
$element = strtolower($matches[1]);
- if (in_array($element, $this->textLevelElements))
- {
+ if (in_array($element, $this->textLevelElements)) {
return;
}
- $Block = array(
+ $Block = [
'name' => $matches[1],
- 'element' => array(
+ 'element' => [
'rawHtml' => $Line['text'],
'autobreak' => true,
- ),
- );
+ ],
+ ];
return $Block;
}
@@ -856,8 +787,7 @@ class Parsedown
protected function blockMarkupContinue($Line, array $Block)
{
- if (isset($Block['closed']) or isset($Block['interrupted']))
- {
+ if (isset($Block['closed']) or isset($Block['interrupted'])) {
return;
}
@@ -876,16 +806,16 @@ class Parsedown
) {
$id = strtolower($matches[1]);
- $Data = array(
+ $Data = [
'url' => $matches[2],
- 'title' => isset($matches[3]) ? $matches[3] : null,
- );
+ 'title' => $matches[3] ?? null,
+ ];
$this->DefinitionData['Reference'][$id] = $Data;
- $Block = array(
- 'element' => array(),
- );
+ $Block = [
+ 'element' => [],
+ ];
return $Block;
}
@@ -896,8 +826,7 @@ class Parsedown
protected function blockTable($Line, ?array $Block = null)
{
- if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
- {
+ if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) {
return;
}
@@ -910,12 +839,11 @@ class Parsedown
return;
}
- if (chop($Line['text'], ' -:|') !== '')
- {
+ if (chop($Line['text'], ' -:|') !== '') {
return;
}
- $alignments = array();
+ $alignments = [];
$divider = $Line['text'];
@@ -924,33 +852,29 @@ class Parsedown
$dividerCells = explode('|', $divider);
- foreach ($dividerCells as $dividerCell)
- {
+ foreach ($dividerCells as $dividerCell) {
$dividerCell = trim($dividerCell);
- if ($dividerCell === '')
- {
+ if ($dividerCell === '') {
return;
}
$alignment = null;
- if ($dividerCell[0] === ':')
- {
+ if ($dividerCell[0] === ':') {
$alignment = 'left';
}
- if (substr($dividerCell, - 1) === ':')
- {
+ if (substr($dividerCell, - 1) === ':') {
$alignment = $alignment === 'left' ? 'center' : 'right';
}
- $alignments []= $alignment;
+ $alignments [] = $alignment;
}
# ~
- $HeaderElements = array();
+ $HeaderElements = [];
$header = $Block['element']['handler']['argument'];
@@ -959,74 +883,69 @@ class Parsedown
$headerCells = explode('|', $header);
- if (count($headerCells) !== count($alignments))
- {
+ if (count($headerCells) !== count($alignments)) {
return;
}
- foreach ($headerCells as $index => $headerCell)
- {
+ foreach ($headerCells as $index => $headerCell) {
$headerCell = trim($headerCell);
- $HeaderElement = array(
+ $HeaderElement = [
'name' => 'th',
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => $headerCell,
'destination' => 'elements',
- )
- );
+ ],
+ ];
- if (isset($alignments[$index]))
- {
+ if (isset($alignments[$index])) {
$alignment = $alignments[$index];
- $HeaderElement['attributes'] = array(
+ $HeaderElement['attributes'] = [
'style' => "text-align: $alignment;",
- );
+ ];
}
- $HeaderElements []= $HeaderElement;
+ $HeaderElements [] = $HeaderElement;
}
# ~
- $Block = array(
+ $Block = [
'alignments' => $alignments,
'identified' => true,
- 'element' => array(
+ 'element' => [
'name' => 'table',
- 'elements' => array(),
- ),
- );
+ 'elements' => [],
+ ],
+ ];
- $Block['element']['elements'] []= array(
+ $Block['element']['elements'] [] = [
'name' => 'thead',
- );
+ ];
- $Block['element']['elements'] []= array(
+ $Block['element']['elements'] [] = [
'name' => 'tbody',
- 'elements' => array(),
- );
+ 'elements' => [],
+ ];
- $Block['element']['elements'][0]['elements'] []= array(
+ $Block['element']['elements'][0]['elements'] [] = [
'name' => 'tr',
'elements' => $HeaderElements,
- );
+ ];
return $Block;
}
protected function blockTableContinue($Line, array $Block)
{
- if (isset($Block['interrupted']))
- {
+ if (isset($Block['interrupted'])) {
return;
}
- if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|'))
- {
- $Elements = array();
+ if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) {
+ $Elements = [];
$row = $Line['text'];
@@ -1037,35 +956,33 @@ class Parsedown
$cells = array_slice($matches[0], 0, count($Block['alignments']));
- foreach ($cells as $index => $cell)
- {
+ foreach ($cells as $index => $cell) {
$cell = trim($cell);
- $Element = array(
+ $Element = [
'name' => 'td',
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => $cell,
'destination' => 'elements',
- )
- );
+ ],
+ ];
- if (isset($Block['alignments'][$index]))
- {
- $Element['attributes'] = array(
+ if (isset($Block['alignments'][$index])) {
+ $Element['attributes'] = [
'style' => 'text-align: ' . $Block['alignments'][$index] . ';',
- );
+ ];
}
- $Elements []= $Element;
+ $Elements [] = $Element;
}
- $Element = array(
+ $Element = [
'name' => 'tr',
'elements' => $Elements,
- );
+ ];
- $Block['element']['elements'][1]['elements'] []= $Element;
+ $Block['element']['elements'][1]['elements'] [] = $Element;
return $Block;
}
@@ -1077,23 +994,22 @@ class Parsedown
protected function paragraph($Line)
{
- return array(
+ return [
'type' => 'Paragraph',
- 'element' => array(
+ 'element' => [
'name' => 'p',
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => $Line['text'],
'destination' => 'elements',
- ),
- ),
- );
+ ],
+ ],
+ ];
}
protected function paragraphContinue($Line, array $Block)
{
- if (isset($Block['interrupted']))
- {
+ if (isset($Block['interrupted'])) {
return;
}
@@ -1106,18 +1022,18 @@ class Parsedown
# Inline Elements
#
- protected $InlineTypes = array(
- '!' => array('Image'),
- '&' => array('SpecialCharacter'),
- '*' => array('Emphasis'),
- ':' => array('Url'),
- '<' => array('UrlTag', 'EmailTag', 'Markup'),
- '[' => array('Link'),
- '_' => array('Emphasis'),
- '`' => array('Code'),
- '~' => array('Strikethrough'),
- '\\' => array('EscapeSequence'),
- );
+ protected $InlineTypes = [
+ '!' => ['Image'],
+ '&' => ['SpecialCharacter'],
+ '*' => ['Emphasis'],
+ ':' => ['Url'],
+ '<' => ['UrlTag', 'EmailTag', 'Markup'],
+ '[' => ['Link'],
+ '_' => ['Emphasis'],
+ '`' => ['Code'],
+ '~' => ['Strikethrough'],
+ '\\' => ['EscapeSequence'],
+ ];
# ~
@@ -1127,60 +1043,55 @@ class Parsedown
# ~
#
- public function line($text, $nonNestables = array())
+ public function line($text, $nonNestables = [])
{
return $this->elements($this->lineElements($text, $nonNestables));
}
- protected function lineElements($text, $nonNestables = array())
+ protected function lineElements($text, $nonNestables = [])
{
# standardize line breaks
- $text = str_replace(array("\r\n", "\r"), "\n", $text);
+ $text = str_replace(["\r\n", "\r"], "\n", $text);
- $Elements = array();
+ $Elements = [];
- $nonNestables = (empty($nonNestables)
- ? array()
+ $nonNestables = (
+ empty($nonNestables)
+ ? []
: array_combine($nonNestables, $nonNestables)
);
# $excerpt is based on the first occurrence of a marker
- while ($excerpt = strpbrk($text, $this->inlineMarkerList))
- {
+ while ($excerpt = strpbrk($text, $this->inlineMarkerList)) {
$marker = $excerpt[0];
$markerPosition = strlen($text) - strlen($excerpt);
- $Excerpt = array('text' => $excerpt, 'context' => $text);
+ $Excerpt = ['text' => $excerpt, 'context' => $text];
- foreach ($this->InlineTypes[$marker] as $inlineType)
- {
+ foreach ($this->InlineTypes[$marker] as $inlineType) {
# check to see if the current inline type is nestable in the current context
- if (isset($nonNestables[$inlineType]))
- {
+ if (isset($nonNestables[$inlineType])) {
continue;
}
$Inline = $this->{"inline$inlineType"}($Excerpt);
- if ( ! isset($Inline))
- {
+ if (! isset($Inline)) {
continue;
}
# makes sure that the inline belongs to "our" marker
- if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
- {
+ if (isset($Inline['position']) and $Inline['position'] > $markerPosition) {
continue;
}
# sets a default inline position
- if ( ! isset($Inline['position']))
- {
+ if (! isset($Inline['position'])) {
$Inline['position'] = $markerPosition;
}
@@ -1221,10 +1132,8 @@ class Parsedown
$InlineText = $this->inlineText($text);
$Elements[] = $InlineText['element'];
- foreach ($Elements as &$Element)
- {
- if ( ! isset($Element['autobreak']))
- {
+ foreach ($Elements as &$Element) {
+ if (! isset($Element['autobreak'])) {
$Element['autobreak'] = false;
}
}
@@ -1238,17 +1147,17 @@ class Parsedown
protected function inlineText($text)
{
- $Inline = array(
+ $Inline = [
'extent' => strlen($text),
- 'element' => array(),
- );
+ 'element' => [],
+ ];
$Inline['element']['elements'] = self::pregReplaceElements(
$this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
- array(
- array('name' => 'br'),
- array('text' => "\n"),
- ),
+ [
+ ['name' => 'br'],
+ ['text' => "\n"],
+ ],
$text
);
@@ -1259,18 +1168,17 @@ class Parsedown
{
$marker = $Excerpt['text'][0];
- if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]),
- 'element' => array(
+ 'element' => [
'name' => 'code',
'text' => $text,
- ),
- );
+ ],
+ ];
}
}
@@ -1283,100 +1191,90 @@ class Parsedown
if (strpos($Excerpt['text'], '>') !== false
and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches)
- ){
+ ) {
$url = $matches[1];
- if ( ! isset($matches[2]))
- {
+ if (! isset($matches[2])) {
$url = "mailto:$url";
}
- return array(
+ return [
'extent' => strlen($matches[0]),
- 'element' => array(
+ 'element' => [
'name' => 'a',
'text' => $matches[1],
- 'attributes' => array(
+ 'attributes' => [
'href' => $url,
- ),
- ),
- );
+ ],
+ ],
+ ];
}
}
protected function inlineEmphasis($Excerpt)
{
- if ( ! isset($Excerpt['text'][1]))
- {
+ if (! isset($Excerpt['text'][1])) {
return;
}
$marker = $Excerpt['text'][0];
- if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
- {
+ if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) {
$emphasis = 'strong';
- }
- elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
- {
+ } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) {
$emphasis = 'em';
- }
- else
- {
+ } else {
return;
}
- return array(
+ return [
'extent' => strlen($matches[0]),
- 'element' => array(
+ 'element' => [
'name' => $emphasis,
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => $matches[1],
'destination' => 'elements',
- )
- ),
- );
+ ],
+ ],
+ ];
}
protected function inlineEscapeSequence($Excerpt)
{
- if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
- {
- return array(
- 'element' => array('rawHtml' => $Excerpt['text'][1]),
+ if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) {
+ return [
+ 'element' => ['rawHtml' => $Excerpt['text'][1]],
'extent' => 2,
- );
+ ];
}
}
protected function inlineImage($Excerpt)
{
- if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
- {
+ if (! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') {
return;
}
- $Excerpt['text']= substr($Excerpt['text'], 1);
+ $Excerpt['text'] = substr($Excerpt['text'], 1);
$Link = $this->inlineLink($Excerpt);
- if ($Link === null)
- {
+ if ($Link === null) {
return;
}
- $Inline = array(
+ $Inline = [
'extent' => $Link['extent'] + 1,
- 'element' => array(
+ 'element' => [
'name' => 'img',
- 'attributes' => array(
+ 'attributes' => [
'src' => $Link['element']['attributes']['href'],
'alt' => $Link['element']['handler']['argument'],
- ),
+ ],
'autobreak' => true,
- ),
- );
+ ],
+ ];
$Inline['element']['attributes'] += $Link['element']['attributes'];
@@ -1387,64 +1285,53 @@ class Parsedown
protected function inlineLink($Excerpt)
{
- $Element = array(
+ $Element = [
'name' => 'a',
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => null,
'destination' => 'elements',
- ),
- 'nonNestables' => array('Url', 'Link'),
- 'attributes' => array(
+ ],
+ 'nonNestables' => ['Url', 'Link'],
+ 'attributes' => [
'href' => null,
'title' => null,
- ),
- );
+ ],
+ ];
$extent = 0;
$remainder = $Excerpt['text'];
- if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
- {
+ if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) {
$Element['handler']['argument'] = $matches[1];
$extent += strlen($matches[0]);
$remainder = substr($remainder, $extent);
- }
- else
- {
+ } else {
return;
}
- if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches))
- {
+ if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) {
$Element['attributes']['href'] = $matches[1];
- if (isset($matches[2]))
- {
+ if (isset($matches[2])) {
$Element['attributes']['title'] = substr($matches[2], 1, - 1);
}
$extent += strlen($matches[0]);
- }
- else
- {
- if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
- {
+ } else {
+ if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) {
$definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument'];
$definition = strtolower($definition);
$extent += strlen($matches[0]);
- }
- else
- {
+ } else {
$definition = strtolower($Element['handler']['argument']);
}
- if ( ! isset($this->DefinitionData['Reference'][$definition]))
- {
+ if (! isset($this->DefinitionData['Reference'][$definition])) {
return;
}
@@ -1454,41 +1341,37 @@ class Parsedown
$Element['attributes']['title'] = $Definition['title'];
}
- return array(
+ return [
'extent' => $extent,
'element' => $Element,
- );
+ ];
}
protected function inlineMarkup($Excerpt)
{
- if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)
- {
+ if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) {
return;
}
- if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches))
- {
- return array(
- 'element' => array('rawHtml' => $matches[0]),
+ if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) {
+ return [
+ 'element' => ['rawHtml' => $matches[0]],
'extent' => strlen($matches[0]),
- );
+ ];
}
- if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches))
- {
- return array(
- 'element' => array('rawHtml' => $matches[0]),
+ if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) {
+ return [
+ 'element' => ['rawHtml' => $matches[0]],
'extent' => strlen($matches[0]),
- );
+ ];
}
- if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches))
- {
- return array(
- 'element' => array('rawHtml' => $matches[0]),
+ if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) {
+ return [
+ 'element' => ['rawHtml' => $matches[0]],
'extent' => strlen($matches[0]),
- );
+ ];
}
}
@@ -1497,40 +1380,37 @@ class Parsedown
if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false
and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches)
) {
- return array(
- 'element' => array('rawHtml' => '&' . $matches[1] . ';'),
+ return [
+ 'element' => ['rawHtml' => '&' . $matches[1] . ';'],
'extent' => strlen($matches[0]),
- );
+ ];
}
}
protected function inlineStrikethrough($Excerpt)
{
- if ( ! isset($Excerpt['text'][1]))
- {
+ if (! isset($Excerpt['text'][1])) {
return;
}
- if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
- {
- return array(
+ if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) {
+ return [
'extent' => strlen($matches[0]),
- 'element' => array(
+ 'element' => [
'name' => 'del',
- 'handler' => array(
+ 'handler' => [
'function' => 'lineElements',
'argument' => $matches[1],
'destination' => 'elements',
- )
- ),
- );
+ ],
+ ],
+ ];
}
}
protected function inlineUrl($Excerpt)
{
- if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
- {
+ if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') {
return;
}
@@ -1539,17 +1419,17 @@ class Parsedown
) {
$url = $matches[0][0];
- $Inline = array(
+ $Inline = [
'extent' => strlen($matches[0][0]),
'position' => $matches[0][1],
- 'element' => array(
+ 'element' => [
'name' => 'a',
'text' => $url,
- 'attributes' => array(
+ 'attributes' => [
'href' => $url,
- ),
- ),
- );
+ ],
+ ],
+ ];
return $Inline;
}
@@ -1557,20 +1437,19 @@ class Parsedown
protected function inlineUrlTag($Excerpt)
{
- if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches))
- {
+ if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) {
$url = $matches[1];
- return array(
+ return [
'extent' => strlen($matches[0]),
- 'element' => array(
+ 'element' => [
'name' => 'a',
'text' => $url,
- 'attributes' => array(
+ 'attributes' => [
'href' => $url,
- ),
- ),
- );
+ ],
+ ],
+ ];
}
}
@@ -1588,22 +1467,17 @@ class Parsedown
protected function handle(array $Element)
{
- if (isset($Element['handler']))
- {
- if (!isset($Element['nonNestables']))
- {
- $Element['nonNestables'] = array();
+ if (isset($Element['handler'])) {
+ if (!isset($Element['nonNestables'])) {
+ $Element['nonNestables'] = [];
}
- if (is_string($Element['handler']))
- {
+ if (is_string($Element['handler'])) {
$function = $Element['handler'];
$argument = $Element['text'];
unset($Element['text']);
$destination = 'rawHtml';
- }
- else
- {
+ } else {
$function = $Element['handler']['function'];
$argument = $Element['handler']['argument'];
$destination = $Element['handler']['destination'];
@@ -1611,8 +1485,7 @@ class Parsedown
$Element[$destination] = $this->{$function}($argument, $Element['nonNestables']);
- if ($destination === 'handler')
- {
+ if ($destination === 'handler') {
$Element = $this->handle($Element);
}
@@ -1624,24 +1497,21 @@ class Parsedown
protected function handleElementRecursive(array $Element)
{
- return $this->elementApplyRecursive(array($this, 'handle'), $Element);
+ return $this->elementApplyRecursive([$this, 'handle'], $Element);
}
protected function handleElementsRecursive(array $Elements)
{
- return $this->elementsApplyRecursive(array($this, 'handle'), $Elements);
+ return $this->elementsApplyRecursive([$this, 'handle'], $Elements);
}
protected function elementApplyRecursive($closure, array $Element)
{
$Element = call_user_func($closure, $Element);
- if (isset($Element['elements']))
- {
+ if (isset($Element['elements'])) {
$Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']);
- }
- elseif (isset($Element['element']))
- {
+ } elseif (isset($Element['element'])) {
$Element['element'] = $this->elementApplyRecursive($closure, $Element['element']);
}
@@ -1650,12 +1520,9 @@ class Parsedown
protected function elementApplyRecursiveDepthFirst($closure, array $Element)
{
- if (isset($Element['elements']))
- {
+ if (isset($Element['elements'])) {
$Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']);
- }
- elseif (isset($Element['element']))
- {
+ } elseif (isset($Element['element'])) {
$Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']);
}
@@ -1666,8 +1533,7 @@ class Parsedown
protected function elementsApplyRecursive($closure, array $Elements)
{
- foreach ($Elements as &$Element)
- {
+ foreach ($Elements as &$Element) {
$Element = $this->elementApplyRecursive($closure, $Element);
}
@@ -1676,8 +1542,7 @@ class Parsedown
protected function elementsApplyRecursiveDepthFirst($closure, array $Elements)
{
- foreach ($Elements as &$Element)
- {
+ foreach ($Elements as &$Element) {
$Element = $this->elementApplyRecursiveDepthFirst($closure, $Element);
}
@@ -1686,8 +1551,7 @@ class Parsedown
protected function element(array $Element)
{
- if ($this->safeMode)
- {
+ if ($this->safeMode) {
$Element = $this->sanitiseElement($Element);
}
@@ -1698,16 +1562,12 @@ class Parsedown
$markup = '';
- if ($hasName)
- {
+ if ($hasName) {
$markup .= '<' . $Element['name'];
- if (isset($Element['attributes']))
- {
- foreach ($Element['attributes'] as $name => $value)
- {
- if ($value === null)
- {
+ if (isset($Element['attributes'])) {
+ foreach ($Element['attributes'] as $name => $value) {
+ if ($value === null) {
continue;
}
@@ -1718,14 +1578,12 @@ class Parsedown
$permitRawHtml = false;
- if (isset($Element['text']))
- {
+ if (isset($Element['text'])) {
$text = $Element['text'];
}
// very strongly consider an alternative if you're writing an
// extension
- elseif (isset($Element['rawHtml']))
- {
+ elseif (isset($Element['rawHtml'])) {
$text = $Element['rawHtml'];
$allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
@@ -1734,34 +1592,23 @@ class Parsedown
$hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']);
- if ($hasContent)
- {
+ if ($hasContent) {
$markup .= $hasName ? '>' : '';
- if (isset($Element['elements']))
- {
+ if (isset($Element['elements'])) {
$markup .= $this->elements($Element['elements']);
- }
- elseif (isset($Element['element']))
- {
+ } elseif (isset($Element['element'])) {
$markup .= $this->element($Element['element']);
- }
- else
- {
- if (!$permitRawHtml)
- {
+ } else {
+ if (!$permitRawHtml) {
$markup .= self::escape($text, true);
- }
- else
- {
+ } else {
$markup .= $text;
}
}
$markup .= $hasName ? '' . $Element['name'] . '>' : '';
- }
- elseif ($hasName)
- {
+ } elseif ($hasName) {
$markup .= ' />';
}
@@ -1774,15 +1621,13 @@ class Parsedown
$autoBreak = true;
- foreach ($Elements as $Element)
- {
- if (empty($Element))
- {
+ foreach ($Elements as $Element) {
+ if (empty($Element)) {
continue;
}
- $autoBreakNext = (isset($Element['autobreak'])
- ? $Element['autobreak'] : isset($Element['name'])
+ $autoBreakNext = (
+ $Element['autobreak'] ?? isset($Element['name'])
);
// (autobreak === false) covers both sides of an element
$autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext;
@@ -1802,7 +1647,7 @@ class Parsedown
{
$Elements = $this->linesElements($lines);
- if ( ! in_array('', $lines)
+ if (! in_array('', $lines)
and isset($Elements[0]) and isset($Elements[0]['name'])
and $Elements[0]['name'] === 'p'
) {
@@ -1822,25 +1667,23 @@ class Parsedown
*/
protected static function pregReplaceElements($regexp, $Elements, $text)
{
- $newElements = array();
+ $newElements = [];
- while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE))
- {
+ while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) {
$offset = $matches[0][1];
$before = substr($text, 0, $offset);
$after = substr($text, $offset + strlen($matches[0][0]));
- $newElements[] = array('text' => $before);
+ $newElements[] = ['text' => $before];
- foreach ($Elements as $Element)
- {
+ foreach ($Elements as $Element) {
$newElements[] = $Element;
}
$text = $after;
}
- $newElements[] = array('text' => $text);
+ $newElements[] = ['text' => $text];
return $newElements;
}
@@ -1852,7 +1695,7 @@ class Parsedown
/**
* @deprecated use text() instead
*/
- function parse($text)
+ public function parse($text)
{
$markup = $this->text($text);
@@ -1862,34 +1705,28 @@ class Parsedown
protected function sanitiseElement(array $Element)
{
static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
- static $safeUrlNameToAtt = array(
+ static $safeUrlNameToAtt = [
'a' => 'href',
'img' => 'src',
- );
+ ];
- if ( ! isset($Element['name']))
- {
+ if (! isset($Element['name'])) {
unset($Element['attributes']);
return $Element;
}
- if (isset($safeUrlNameToAtt[$Element['name']]))
- {
+ if (isset($safeUrlNameToAtt[$Element['name']])) {
$Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
}
- if ( ! empty($Element['attributes']))
- {
- foreach ($Element['attributes'] as $att => $_)
- {
+ if (! empty($Element['attributes'])) {
+ foreach ($Element['attributes'] as $att => $_) {
# filter out badly parsed attribute
- if ( ! preg_match($goodAttribute, $att))
- {
+ if (! preg_match($goodAttribute, $att)) {
unset($Element['attributes'][$att]);
}
# dump onevent attribute
- elseif (self::striAtStart($att, 'on'))
- {
+ elseif (self::striAtStart($att, 'on')) {
unset($Element['attributes'][$att]);
}
}
@@ -1900,10 +1737,8 @@ class Parsedown
protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
{
- foreach ($this->safeLinksWhitelist as $scheme)
- {
- if (self::striAtStart($Element['attributes'][$attribute], $scheme))
- {
+ foreach ($this->safeLinksWhitelist as $scheme) {
+ if (self::striAtStart($Element['attributes'][$attribute], $scheme)) {
return $Element;
}
}
@@ -1926,20 +1761,16 @@ class Parsedown
{
$len = strlen($needle);
- if ($len > strlen($string))
- {
+ if ($len > strlen($string)) {
return false;
- }
- else
- {
+ } else {
return strtolower(substr($string, 0, $len)) === strtolower($needle);
}
}
- static function instance($name = 'default')
+ public static function instance($name = 'default')
{
- if (isset(self::$instances[$name]))
- {
+ if (isset(self::$instances[$name])) {
return self::$instances[$name];
}
@@ -1950,7 +1781,7 @@ class Parsedown
return $instance;
}
- private static $instances = array();
+ private static $instances = [];
#
# Fields
@@ -1961,27 +1792,27 @@ class Parsedown
#
# Read-Only
- protected $specialCharacters = array(
- '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~'
- );
+ protected $specialCharacters = [
+ '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~',
+ ];
- protected $StrongRegex = array(
+ protected $StrongRegex = [
'*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
'_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us',
- );
+ ];
- protected $EmRegex = array(
+ protected $EmRegex = [
'*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
'_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
- );
+ ];
protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';
- protected $voidElements = array(
+ protected $voidElements = [
'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
- );
+ ];
- protected $textLevelElements = array(
+ protected $textLevelElements = [
'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
'i', 'rp', 'del', 'code', 'strike', 'marquee',
@@ -1991,5 +1822,5 @@ class Parsedown
'sup', 'ruby',
'var', 'span',
'wbr', 'time',
- );
+ ];
}
diff --git a/app/src/RateLimit.php b/app/src/RateLimit.php
index 33f9fd0..05612b2 100644
--- a/app/src/RateLimit.php
+++ b/app/src/RateLimit.php
@@ -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()));
diff --git a/app/src/ShareLink.php b/app/src/ShareLink.php
index 64670e7..ae7a8eb 100644
--- a/app/src/ShareLink.php
+++ b/app/src/ShareLink.php
@@ -1,4 +1,5 @@
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]);
}
diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php
index 582ed2c..f51d04d 100644
--- a/app/src/SmtpRelay.php
+++ b/app/src/SmtpRelay.php
@@ -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);
diff --git a/app/src/StudentEmail.php b/app/src/StudentEmail.php
index 9e17d3f..a9f1a5c 100644
--- a/app/src/StudentEmail.php
+++ b/app/src/StudentEmail.php
@@ -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,24 +43,24 @@ class StudentEmail {
foreach ($fields as $label => $value) {
$v = $value === '' ? '–' : htmlspecialchars((string)$value);
$rows .= "| "
- . htmlspecialchars($label) . " | "
+ . htmlspecialchars($label) . ''
. "{$v} |
\n";
}
return <<
- Merci — ton TFE a bien été enregistré 🎉
-
- Voici un récapitulatif de ta soumission. Tu n'as pas besoin de répondre à cet e-mail.
-
-
-
- Plateforme xamxam · erg Bruxelles
-
-
- HTML;
+
+
Merci — ton TFE a bien été enregistré 🎉
+
+ Voici un récapitulatif de ta soumission. Tu n'as pas besoin de répondre à cet e-mail.
+
+
+
+ Plateforme xamxam · erg Bruxelles
+
+
+ HTML;
}
/**
diff --git a/app/templates/admin/partials/toast.php b/app/templates/admin/partials/toast.php
index ae703eb..5ad16b2 100644
--- a/app/templates/admin/partials/toast.php
+++ b/app/templates/admin/partials/toast.php
@@ -4,6 +4,12 @@
= htmlspecialchars($flash['error']) ?>
+
+
+ ⚠
+ = $flash['warning'] /* pre-sanitised HTML allowed for the duplicate link */ ?>
+
+
✓
diff --git a/app/tests/Integration/SearchTest.php b/app/tests/Integration/SearchTest.php
index 6fcea12..8dadc42 100644
--- a/app/tests/Integration/SearchTest.php
+++ b/app/tests/Integration/SearchTest.php
@@ -1,4 +1,5 @@
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;
}
diff --git a/app/tests/Security/SecurityTest.php b/app/tests/Security/SecurityTest.php
index a696e33..81699bd 100644
--- a/app/tests/Security/SecurityTest.php
+++ b/app/tests/Security/SecurityTest.php
@@ -1,4 +1,5 @@
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;
}
diff --git a/app/tests/Unit/DatabaseTest.php b/app/tests/Unit/DatabaseTest.php
index 091c114..6ff6d3d 100644
--- a/app/tests/Unit/DatabaseTest.php
+++ b/app/tests/Unit/DatabaseTest.php
@@ -1,4 +1,5 @@
= 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)
@@ -41,14 +42,14 @@ try {
echo "Test 4: Get Single Thesis\n";
$first = $theses[0];
$thesis = $db->getThesisById($first['id']);
-
+
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;
}
diff --git a/app/tests/Unit/RateLimitTest.php b/app/tests/Unit/RateLimitTest.php
index 42627b9..6e7adf3 100644
--- a/app/tests/Unit/RateLimitTest.php
+++ b/app/tests/Unit/RateLimitTest.php
@@ -1,4 +1,5 @@
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;
}
diff --git a/app/tests/run-tests.php b/app/tests/run-tests.php
index d82892d..b5d8b2f 100755
--- a/app/tests/run-tests.php
+++ b/app/tests/run-tests.php
@@ -1,5 +1,6 @@
#!/usr/bin/env php
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";
diff --git a/justfile b/justfile
index 3e5fe90..77bc449 100644
--- a/justfile
+++ b/justfile
@@ -1,3 +1,6 @@
+default:
+ @just --list
+
# XAMXAM Justfile
# ============================================================================