mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-26 00:29:18 +02:00
feat: prevent duplicate TFE submissions with logging and user feedback
- Add DuplicateThesisException (typed, carries existing thesis metadata) - Add Database::findDuplicateThesis(): matches on year + author + normalised title (exact, prefix, Levenshtein ≤10% of longer string) - ThesisCreateController::submit() runs duplicate check before any DB write and throws DuplicateThesisException on match - AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines log for audit purposes - App::flash/consumeFlash extended to support 'warning' flash type - admin/actions/formulaire.php: catches DuplicateThesisException, logs it, flashes an HTML warning toast with a clickable link to the existing thesis, and repopulates the form fields - partage/index.php: same catch block; surfaces a plain-text flash-warning banner on the student form with identifier, title, and year of the match; form is repopulated via session - toast.php: renders toast--warning variant - admin.css: .toast--warning + link colour rules - form.css: .flash-warning style for the partage form
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Front-controller Dispatcher
|
||||
*
|
||||
@@ -17,7 +18,8 @@
|
||||
* /partage/<slug> → share-link flow
|
||||
* /maintenance.php → static maintenance page
|
||||
*/
|
||||
class Dispatcher {
|
||||
class Dispatcher
|
||||
{
|
||||
private const ROUTES = [
|
||||
'' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
||||
'/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
||||
@@ -37,7 +39,8 @@ class Dispatcher {
|
||||
private string $path;
|
||||
private array $queryParams;
|
||||
|
||||
public function __construct() {
|
||||
public function __construct()
|
||||
{
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$this->path = $uri;
|
||||
$this->queryParams = $_GET;
|
||||
@@ -47,7 +50,8 @@ class Dispatcher {
|
||||
* Resolve the URI to a route, instantiate the controller,
|
||||
* execute the action, and render the view.
|
||||
*/
|
||||
public function dispatch(): void {
|
||||
public function dispatch(): void
|
||||
{
|
||||
// 1. Direct-response endpoints (render their own output)
|
||||
$direct = $this->matchDirect();
|
||||
if ($direct) {
|
||||
@@ -81,12 +85,13 @@ class Dispatcher {
|
||||
/**
|
||||
* Match endpoints that render their own response (no view layer).
|
||||
*/
|
||||
private function matchDirect(): ?callable {
|
||||
private function matchDirect(): ?callable
|
||||
{
|
||||
$path = $this->path;
|
||||
|
||||
// /live-reload
|
||||
if ($path === '/live-reload' || $path === '/live-reload.php') {
|
||||
return function() {
|
||||
return function () {
|
||||
require_once APP_ROOT . '/src/Controllers/LiveReloadController.php';
|
||||
$controller = new LiveReloadController(APP_ROOT);
|
||||
$result = $controller->handle();
|
||||
@@ -97,7 +102,7 @@ class Dispatcher {
|
||||
|
||||
// /media.php
|
||||
if ($path === '/media' || $path === '/media.php') {
|
||||
return function() {
|
||||
return function () {
|
||||
require_once APP_ROOT . '/src/Controllers/MediaController.php';
|
||||
$controller = new MediaController();
|
||||
$controller->handle();
|
||||
@@ -106,14 +111,14 @@ class Dispatcher {
|
||||
|
||||
// /maintenance.php
|
||||
if ($path === '/maintenance' || $path === '/maintenance.php') {
|
||||
return function() {
|
||||
return function () {
|
||||
require APP_ROOT . '/public/maintenance.php';
|
||||
};
|
||||
}
|
||||
|
||||
// /repertoire/student-preview (HTMX popover)
|
||||
if ($path === '/repertoire/student-preview') {
|
||||
return function() {
|
||||
return function () {
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/RateLimit.php';
|
||||
require_once APP_ROOT . '/src/Controllers/SearchController.php';
|
||||
@@ -124,28 +129,28 @@ class Dispatcher {
|
||||
|
||||
// /partage/retry-email (GET: show retry form, POST: resend)
|
||||
if ($path === '/partage/retry-email') {
|
||||
return function() {
|
||||
return function () {
|
||||
require APP_ROOT . '/public/partage/retry-email.php';
|
||||
};
|
||||
}
|
||||
|
||||
// /partage/*
|
||||
if (preg_match('#^/partage(/.*)?$#', $path)) {
|
||||
return function() {
|
||||
return function () {
|
||||
require APP_ROOT . '/public/partage/index.php';
|
||||
};
|
||||
}
|
||||
|
||||
// /validate-access (GET: confirmation page, POST: token redemption)
|
||||
if ($path === '/validate-access' || $path === '/validate-access.php') {
|
||||
return function() {
|
||||
return function () {
|
||||
require APP_ROOT . '/public/validate-access.php';
|
||||
};
|
||||
}
|
||||
|
||||
// /request-access (POST: submit access request)
|
||||
if ($path === '/request-access' || $path === '/request-access.php') {
|
||||
return function() {
|
||||
return function () {
|
||||
require APP_ROOT . '/public/request-access.php';
|
||||
};
|
||||
}
|
||||
@@ -157,7 +162,8 @@ class Dispatcher {
|
||||
* Match the current path against the static route table.
|
||||
* Supports exact match and prefix-based (for /tfe?id=).
|
||||
*/
|
||||
private function matchRoute(): ?array {
|
||||
private function matchRoute(): ?array
|
||||
{
|
||||
$path = $this->path;
|
||||
|
||||
// Exact match first
|
||||
@@ -177,7 +183,8 @@ class Dispatcher {
|
||||
* Render a view template wrapped in the full page layout.
|
||||
* Includes head.php, header.php, the view, and footer.php.
|
||||
*/
|
||||
private function render(string $view, array $vars): void {
|
||||
private function render(string $view, array $vars): void
|
||||
{
|
||||
$viewPath = APP_ROOT . '/templates/' . $view . '.php';
|
||||
if (!file_exists($viewPath)) {
|
||||
http_response_code(500);
|
||||
|
||||
Reference in New Issue
Block a user