feat: extract MediaController, wire into Dispatcher, delete media.php

This commit is contained in:
Pontoporeia
2026-04-17 11:44:08 +02:00
parent b03be51b92
commit 75f808bee4
157 changed files with 1713 additions and 452 deletions

193
app/src/RateLimit.php Normal file
View File

@@ -0,0 +1,193 @@
<?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()));
}
}