Files
xamxam/app/src/RateLimit.php
Pontoporeia a2cba6d3c0 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
2026-05-05 11:04:52 +02:00

204 lines
5.8 KiB
PHP

<?php
/**
* Simple file-based rate limiter
* Prevents abuse by limiting requests per IP address
*/
class RateLimit
{
private $cacheDir;
private $maxRequests;
private $timeWindow;
/**
* Constructor
* @param int $maxRequests Maximum requests allowed in time window
* @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)
{
$this->maxRequests = $maxRequests;
$this->timeWindow = $timeWindow;
$this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit';
// Create cache directory if it doesn't exist
if (!is_dir($this->cacheDir)) {
@mkdir($this->cacheDir, 0755, true);
}
}
/**
* Get client identifier (IP address)
* @return string Client identifier
*/
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
// freely to bypass the limiter). Nginx-level rate limiting also
// uses $binary_remote_addr for the same reason. If this app is
// ever placed behind a trusted reverse-proxy, IP extraction should
// be handled at the nginx level, not here.
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
/**
* Get cache file path for a client
* @param string $identifier Client identifier
* @return string File path
*/
private function getCacheFile($identifier)
{
return $this->cacheDir . '/' . md5($identifier) . '.json';
}
/**
* Check and record a hit for an arbitrary string key.
* Useful when the caller wants a compound key (e.g. slug + IP).
*
* @return bool True if allowed, false if rate limit exceeded
*/
public function checkKey(string $key): bool
{
$file = $this->getCacheFile($key);
$data = [];
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true) ?? [];
}
$now = time();
$data = array_values(array_filter($data, fn ($ts) => ($now - $ts) < $this->timeWindow));
if (count($data) >= $this->maxRequests) {
return false;
}
$data[] = $now;
if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) {
file_put_contents($file, json_encode($data));
}
return true;
}
/**
* Check if client has exceeded rate limit
* @return bool True if allowed, false if rate limit exceeded
*/
public function check()
{
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
// Load existing request timestamps
$data = [];
if (file_exists($file)) {
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
}
// Clean old entries outside time window
$now = time();
$data = array_filter($data, function ($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
// Check if limit exceeded
if (count($data) >= $this->maxRequests) {
return false;
}
// Add new request timestamp
$data[] = $now;
// Save updated data (silently skip if directory is not writable)
if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) {
file_put_contents($file, json_encode($data));
}
return true;
}
/**
* Get remaining requests for current client
* @return int Number of requests remaining
*/
public function getRemaining()
{
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
if (!file_exists($file)) {
return $this->maxRequests;
}
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
// Clean old entries
$now = time();
$data = array_filter($data, function ($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
return max(0, $this->maxRequests - count($data));
}
/**
* Get time until rate limit resets
* @return int Seconds until reset
*/
public function getResetTime()
{
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
if (!file_exists($file)) {
return 0;
}
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
if (empty($data)) {
return 0;
}
// Find oldest timestamp
$oldest = min($data);
$resetTime = $oldest + $this->timeWindow - time();
return max(0, $resetTime);
}
/**
* Clean up old cache files (run periodically)
* Removes files that haven't been modified in 24 hours
*/
public function cleanup()
{
$files = glob($this->cacheDir . '/*.json');
$cutoff = time() - 86400; // 24 hours
foreach ($files as $file) {
if (filemtime($file) < $cutoff) {
unlink($file);
}
}
}
/**
* Send rate limit headers
* Provides information about rate limits to clients
*/
public function sendHeaders()
{
header('X-RateLimit-Limit: ' . $this->maxRequests);
header('X-RateLimit-Remaining: ' . $this->getRemaining());
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));
}
}