[FIX] Uge session of debugging and code coverage.

This commit is contained in:
NADAL Jean-Baptiste
2026-09-14 17:22:18 +02:00
parent e79c865f8c
commit a38fc0bfa9
35 changed files with 2315 additions and 1092 deletions
+50 -68
View File
@@ -17,10 +17,6 @@ if (file_exists(__DIR__ . '/.env')) {
}
}
// Increase upload limits for this script
ini_set('upload_max_filesize', '64M');
ini_set('post_max_size', '64M');
// Security headers
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
@@ -29,17 +25,14 @@ header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
// CORS - Restrict to allowed origins
// CORS - only allowlisted origins receive the header (no wildcard fallback)
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Always send CORS headers for preflight requests
if (in_array($origin, $allowedOrigins)) {
if (in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
} else {
// For preflight without matching origin, still allow (for debugging)
header("Access-Control-Allow-Origin: *");
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
@@ -49,31 +42,45 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
// Rate limiting function
// Normalize the path once (strip an optional /api prefix)
$path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
$path = preg_replace('#^api/#', '', $path);
// Rate limiting (flock-protected to avoid read-modify-write races)
function checkRateLimit(string $key, int $maxRequests = 100, int $windowSeconds = 60): bool {
$rateFile = sys_get_temp_dir() . '/rate_' . md5($key) . '.json';
$now = time();
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
if (file_exists($rateFile)) {
$content = file_get_contents($rateFile);
$data = json_decode($content, true) ?? $data;
$fp = @fopen($rateFile, 'c+');
if ($fp === false) {
return true; // fail open if the temp dir is not writable
}
// Reset if window expired
if ($now > $data['reset']) {
if (!flock($fp, LOCK_EX)) {
fclose($fp);
return true;
}
$content = stream_get_contents($fp);
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['count'], $data['reset']) || $now > $data['reset']) {
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
}
$data['count']++;
file_put_contents($rateFile, json_encode($data));
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, json_encode($data));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $data['count'] <= $maxRequests;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Check rate limit for login endpoint (5 attempts per minute)
if (preg_match('#^login$#', trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/')) && $_SERVER['REQUEST_METHOD'] === 'POST') {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if ($path === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!checkRateLimit('login_' . $ip, 5, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Too many requests. Please try again later.']);
@@ -82,7 +89,6 @@ if (preg_match('#^login$#', trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH
}
// Check general rate limit (500 requests per minute per IP - increased for testing)
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (!checkRateLimit('general_' . $ip, 500, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
@@ -99,30 +105,19 @@ if ($scoresPath === false || $scoresPath === '') {
}
$scanner = new ScoreScanner($scoresPath);
// Get Authorization header
// Get Authorization header (PHP exposes it as HTTP_AUTHORIZATION)
$token = null;
$headers = getallheaders();
if (isset($headers['Authorization'])) {
$authHeader = $headers['Authorization'];
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
$token = $matches[1];
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
$token = trim($matches[1]);
}
// Parse request
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$path = trim($uri, '/');
// Remove 'api/' prefix if present
$path = preg_replace('#^api/#', '', $path);
// Debug: log the path
// file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " PATH: $path METHOD: $method\n", FILE_APPEND);
// GET /download/:path - Download PDF (BEFORE auth check)
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
$filePath = urldecode($matches[1]);
$filePath = rawurldecode($matches[1]);
// Security: Check for null bytes (must use double quotes for escape sequence)
if (strpos($filePath, "\x00") !== false) {
@@ -134,11 +129,10 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
// Security: Prevent directory traversal
$filePath = str_replace(['../', '..\\', '..'], '', $filePath);
// Check token from header or query parameter
$downloadToken = $_GET['token'] ?? $token;
$downloadToken = urldecode($downloadToken ?? '');
// Security: token is read from the Authorization header only
$downloadToken = $token ?? '';
if ($downloadToken === null || $downloadToken === '') {
if ($downloadToken === '') {
http_response_code(401);
echo json_encode(['error' => 'Authorization required']);
exit;
@@ -158,7 +152,12 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
$realBasePath = realpath($basePath);
$realFilePath = realpath($fullPath);
if ($realFilePath === false || strpos($realFilePath, $realBasePath) !== 0) {
if ($realFilePath === false) {
http_response_code(404);
echo json_encode(['error' => 'File not found']);
exit;
}
if ($realBasePath === false || strpos($realFilePath, $realBasePath . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
echo json_encode(['error' => 'Access denied']);
exit;
@@ -171,12 +170,6 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
exit;
}
if (!file_exists($realFilePath) || !is_file($realFilePath)) {
http_response_code(404);
echo json_encode(['error' => 'File not found']);
exit;
}
header('Content-Type: application/pdf');
header('Content-Length: ' . filesize($realFilePath));
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
@@ -294,16 +287,9 @@ if ($path === 'admin/scores' && $method === 'POST') {
if ($num > $maxId) $maxId = $num;
}
// Skip existing IDs
$scoresPath = getenv('SCORES_PATH') ?: __DIR__ . '/../legacy/Scores/';
$id = strval($maxId + 1);
while (is_dir($scoresPath . $id)) {
$id = strval(intval($id) + 1);
}
// Pad with zeros to 3 digits if needed
if (strlen($id) < 3) {
$id = str_pad($id, 3, '0', STR_PAD_LEFT);
}
// Next score id, zero-padded to 3 digits.
// A concurrent collision is rejected by createScore (already exists).
$id = str_pad((string) ($maxId + 1), 3, '0', STR_PAD_LEFT);
}
if (empty($name)) {
@@ -330,7 +316,7 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT')
$name = $input['name'] ?? null;
$compositor = $input['compositor'] ?? null;
$ressource = $input['ressource'] ?? '';
$ressource = $input['ressource'] ?? null;
$result = $scanner->updateScore($scoreId, $name, $compositor, $ressource);
@@ -370,11 +356,7 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE
if ($result['success']) {
echo json_encode(['success' => true]);
} else {
if (strpos($result['error'], 'not found') !== false) {
http_response_code(404);
} else {
http_response_code(400);
}
http_response_code(404);
echo json_encode(['error' => $result['error']]);
}
exit;