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 + + + @@ -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 - - - -
- -

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 + + + +
+ +

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 ? '' : ''; - } - 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. -

- - {$rows} -
-

- 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. +

+ + {$rows} +
+

+ 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 @@

+ + +

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 # ============================================================================