feat: prevent duplicate TFE submissions with logging and user feedback

- Add DuplicateThesisException (typed, carries existing thesis metadata)
- Add Database::findDuplicateThesis(): matches on year + author + normalised
  title (exact, prefix, Levenshtein ≤10% of longer string)
- ThesisCreateController::submit() runs duplicate check before any DB write
  and throws DuplicateThesisException on match
- AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines
  log for audit purposes
- App::flash/consumeFlash extended to support 'warning' flash type
- admin/actions/formulaire.php: catches DuplicateThesisException, logs it,
  flashes an HTML warning toast with a clickable link to the existing thesis,
  and repopulates the form fields
- partage/index.php: same catch block; surfaces a plain-text flash-warning
  banner on the student form with identifier, title, and year of the match;
  form is repopulated via session
- toast.php: renders toast--warning variant
- admin.css: .toast--warning + link colour rules
- form.css: .flash-warning style for the partage form
This commit is contained in:
Pontoporeia
2026-05-04 16:29:31 +02:00
parent 0a05f3911c
commit a2cba6d3c0
35 changed files with 1726 additions and 1302 deletions

View File

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