468 lines
15 KiB
PHP
468 lines
15 KiB
PHP
<?php
|
|
// Load .env file if it exists
|
|
if (file_exists(__DIR__ . '/.env')) {
|
|
$lines = file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
foreach ($lines as $line) {
|
|
if (strpos($line, '#') === 0) continue;
|
|
if (strpos($line, '=') !== false) {
|
|
list($key, $value) = explode('=', $line, 2);
|
|
$key = trim($key);
|
|
$value = trim($value);
|
|
if (!getenv($key)) {
|
|
putenv("$key=$value");
|
|
$_ENV[$key] = $value;
|
|
$_SERVER[$key] = $value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
header('X-Frame-Options: DENY');
|
|
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
|
|
$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)) {
|
|
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('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(200);
|
|
exit;
|
|
}
|
|
|
|
// Rate limiting function
|
|
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;
|
|
}
|
|
|
|
// Reset if window expired
|
|
if ($now > $data['reset']) {
|
|
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
|
|
}
|
|
|
|
$data['count']++;
|
|
file_put_contents($rateFile, json_encode($data));
|
|
|
|
return $data['count'] <= $maxRequests;
|
|
}
|
|
|
|
// 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 (!checkRateLimit('login_' . $ip, 5, 60)) {
|
|
http_response_code(429);
|
|
echo json_encode(['error' => 'Too many requests. Please try again later.']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// 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']);
|
|
exit;
|
|
}
|
|
|
|
require_once __DIR__ . '/lib/Auth.php';
|
|
require_once __DIR__ . '/lib/ScoreScanner.php';
|
|
|
|
$auth = new Auth();
|
|
$scoresPath = getenv('SCORES_PATH');
|
|
if ($scoresPath === false || $scoresPath === '') {
|
|
$scoresPath = __DIR__ . '/../legacy/Scores/';
|
|
}
|
|
$scanner = new ScoreScanner($scoresPath);
|
|
|
|
// Get Authorization header
|
|
$token = null;
|
|
$headers = getallheaders();
|
|
if (isset($headers['Authorization'])) {
|
|
$authHeader = $headers['Authorization'];
|
|
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
|
|
$token = $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]);
|
|
|
|
// Security: Check for null bytes (must use double quotes for escape sequence)
|
|
if (strpos($filePath, "\x00") !== false) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Invalid file path']);
|
|
exit;
|
|
}
|
|
|
|
// Security: Prevent directory traversal
|
|
$filePath = str_replace(['../', '..\\', '..'], '', $filePath);
|
|
|
|
// Check token from header or query parameter
|
|
$downloadToken = $_GET['token'] ?? $token;
|
|
$downloadToken = urldecode($downloadToken ?? '');
|
|
|
|
if ($downloadToken === null || $downloadToken === '') {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Authorization required']);
|
|
exit;
|
|
}
|
|
|
|
$user = $auth->verifyToken($downloadToken);
|
|
if ($user === null) {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Invalid token']);
|
|
exit;
|
|
}
|
|
|
|
$basePath = getenv('SCORES_PATH') ?: __DIR__ . '/../legacy/Scores/';
|
|
$fullPath = $basePath . $filePath;
|
|
|
|
// Security: Verify resolved path is within allowed directory
|
|
$realBasePath = realpath($basePath);
|
|
$realFilePath = realpath($fullPath);
|
|
|
|
if ($realFilePath === false || strpos($realFilePath, $realBasePath) !== 0) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Access denied']);
|
|
exit;
|
|
}
|
|
|
|
// Security: Check file extension
|
|
if (strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)) !== 'pdf') {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Only PDF files are allowed']);
|
|
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) . '"');
|
|
readfile($realFilePath);
|
|
exit;
|
|
}
|
|
|
|
// Route matching
|
|
if (($path === 'login' || $path === 'api/login') && $method === 'POST') {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$username = $input['username'] ?? '';
|
|
$password = $input['password'] ?? '';
|
|
|
|
$result = $auth->login($username, $password);
|
|
|
|
if ($result['success']) {
|
|
echo json_encode([
|
|
'success' => true,
|
|
'token' => $result['token'],
|
|
'user' => $result['user']
|
|
]);
|
|
} else {
|
|
http_response_code(401);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $result['error']
|
|
]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Protected routes - require auth
|
|
if ($token === null) {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Authorization required']);
|
|
exit;
|
|
}
|
|
|
|
$user = $auth->verifyToken($token);
|
|
if ($user === null) {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Invalid token']);
|
|
exit;
|
|
}
|
|
|
|
// GET /scores - List all scores
|
|
if ($path === 'scores' && $method === 'GET') {
|
|
$scores = $scanner->listScores();
|
|
if (isset($scores['error'])) {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => $scores['error']]);
|
|
exit;
|
|
}
|
|
echo json_encode(['success' => true, 'scores' => $scores]);
|
|
exit;
|
|
}
|
|
|
|
// GET /scores/:id - Get score details
|
|
if (preg_match('#^scores/(\d+)$#', $path, $matches) && $method === 'GET') {
|
|
$scoreId = $matches[1];
|
|
$score = $scanner->getScore($scoreId);
|
|
|
|
if ($score === null) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Score not found']);
|
|
exit;
|
|
}
|
|
|
|
echo json_encode(['success' => true, 'score' => $score]);
|
|
exit;
|
|
}
|
|
|
|
// GET /scores/:id/instruments - Get instruments for a score
|
|
// GET /scores/:id/instruments?piece=1 - Get instruments for a specific piece
|
|
if (preg_match('#^scores/(\d+)/instruments$#', $path, $matches) && $method === 'GET') {
|
|
$scoreId = $matches[1];
|
|
$pieceId = $_GET['piece'] ?? null;
|
|
$instruments = $scanner->getInstruments($scoreId, $pieceId);
|
|
|
|
echo json_encode(['success' => true, 'instruments' => $instruments]);
|
|
exit;
|
|
}
|
|
|
|
// GET /pieces/:scoreId - Get pieces for a score
|
|
if (preg_match('#^pieces/(\d+)$#', $path, $matches) && $method === 'GET') {
|
|
$scoreId = $matches[1];
|
|
$pieces = $scanner->getPieces($scoreId);
|
|
|
|
echo json_encode(['success' => true, 'pieces' => $pieces]);
|
|
exit;
|
|
}
|
|
|
|
// ADMIN ROUTES - require admin role
|
|
if ($user['role'] !== 'admin') {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Admin access required']);
|
|
exit;
|
|
}
|
|
|
|
// POST /admin/scores - Create new score
|
|
if ($path === 'admin/scores' && $method === 'POST') {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
$id = $input['id'] ?? null;
|
|
$name = $input['name'] ?? '';
|
|
$compositor = $input['compositor'] ?? '';
|
|
$pieces = $input['pieces'] ?? [];
|
|
|
|
// Auto-generate ID if not provided
|
|
if (empty($id)) {
|
|
$scores = $scanner->listScores();
|
|
$maxId = 0;
|
|
foreach ($scores as $s) {
|
|
$num = intval($s['id']);
|
|
if ($num > $maxId) $maxId = $maxId;
|
|
}
|
|
// Find highest numeric ID
|
|
foreach ($scores as $s) {
|
|
$num = intval($s['id']);
|
|
if ($num > $maxId) $maxId = $num;
|
|
}
|
|
$id = strval($maxId + 1);
|
|
// Pad with zeros to 3 digits if needed
|
|
if (strlen($id) < 3) {
|
|
$id = str_pad($id, 3, '0', STR_PAD_LEFT);
|
|
}
|
|
}
|
|
|
|
if (empty($name)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'ID and name required']);
|
|
exit;
|
|
}
|
|
|
|
$result = $scanner->createScore($id, $name, $compositor, $pieces);
|
|
|
|
if ($result['success']) {
|
|
echo json_encode(['success' => true, 'score' => $result['score']]);
|
|
} else {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => $result['error']]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// PUT /admin/scores/:id - Update score
|
|
if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT') {
|
|
$scoreId = $matches[1];
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
$name = $input['name'] ?? null;
|
|
$compositor = $input['compositor'] ?? null;
|
|
$ressource = $input['ressource'] ?? '';
|
|
|
|
$result = $scanner->updateScore($scoreId, $name, $compositor, $ressource);
|
|
|
|
if ($result['success']) {
|
|
echo json_encode(['success' => true]);
|
|
} else {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => $result['error']]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// DELETE /admin/scores/:id - Delete score
|
|
if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE') {
|
|
$scoreId = $matches[1];
|
|
|
|
$result = $scanner->deleteScore($scoreId);
|
|
|
|
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);
|
|
}
|
|
echo json_encode(['error' => $result['error']]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// POST /admin/scores/:id/upload - Upload PDF
|
|
if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') {
|
|
$scoreId = $matches[1];
|
|
|
|
// Check for upload errors
|
|
if (!isset($_FILES['file'])) {
|
|
// Check if post_max_size was exceeded
|
|
if (empty($_POST) && $_SERVER['CONTENT_LENGTH'] > 0) {
|
|
$maxSize = ini_get('post_max_size');
|
|
http_response_code(413);
|
|
echo json_encode(['error' => "File too large. Maximum size is $maxSize"]);
|
|
exit;
|
|
}
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'No file uploaded']);
|
|
exit;
|
|
}
|
|
|
|
$file = $_FILES['file'];
|
|
|
|
// Check for PHP upload errors
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
$errorMsg = 'Upload failed';
|
|
switch ($file['error']) {
|
|
case UPLOAD_ERR_INI_SIZE:
|
|
case UPLOAD_ERR_FORM_SIZE:
|
|
$maxSize = ini_get('upload_max_filesize');
|
|
$errorMsg = "File too large. Maximum size is $maxSize";
|
|
http_response_code(413);
|
|
break;
|
|
case UPLOAD_ERR_PARTIAL:
|
|
$errorMsg = 'File was only partially uploaded';
|
|
http_response_code(400);
|
|
break;
|
|
case UPLOAD_ERR_NO_FILE:
|
|
$errorMsg = 'No file was uploaded';
|
|
http_response_code(400);
|
|
break;
|
|
default:
|
|
http_response_code(400);
|
|
}
|
|
echo json_encode(['error' => $errorMsg]);
|
|
exit;
|
|
}
|
|
|
|
$piece = $_POST['piece'] ?? '1';
|
|
$instrument = $_POST['instrument'] ?? '';
|
|
$version = $_POST['version'] ?? '1';
|
|
$key = $_POST['key'] ?? '';
|
|
$clef = $_POST['clef'] ?? '';
|
|
$variant = $_POST['variant'] ?? '';
|
|
$part = $_POST['part'] ?? '1';
|
|
|
|
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $key, $clef, $variant, $part);
|
|
|
|
if ($result['success']) {
|
|
echo json_encode(['success' => true, 'path' => $result['path']]);
|
|
} else {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => $result['error']]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// GET /admin/scores/:id/files - Get all files for a score
|
|
if (preg_match('#^admin/scores/(\d+)/files$#', $path, $matches) && $method === 'GET') {
|
|
$scoreId = $matches[1];
|
|
|
|
$result = $scanner->getScoreFiles($scoreId);
|
|
|
|
if ($result['success']) {
|
|
echo json_encode(['success' => true, 'files' => $result['files']]);
|
|
} else {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => $result['error']]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// DELETE /admin/scores/:id/files - Delete a specific file
|
|
if (preg_match('#^admin/scores/(\d+)/files$#', $path, $matches) && $method === 'DELETE') {
|
|
$scoreId = $matches[1];
|
|
$filePath = $_GET['path'] ?? '';
|
|
|
|
if (empty($filePath)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'File path required']);
|
|
exit;
|
|
}
|
|
|
|
$result = $scanner->deleteScoreFile($scoreId, $filePath);
|
|
|
|
if ($result['success']) {
|
|
echo json_encode(['success' => true]);
|
|
} else {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => $result['error']]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// 404 Not Found
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Not found']);
|