[FIX] Uge session of debugging and code coverage.
This commit is contained in:
+50
-47
@@ -7,7 +7,7 @@ class Auth {
|
||||
private string $usersFile;
|
||||
private string $jwtSecret;
|
||||
|
||||
public function __construct(string $usersFile = null) {
|
||||
public function __construct(?string $usersFile = null) {
|
||||
$this->usersFile = $usersFile ?? __DIR__ . '/../config/users.json';
|
||||
|
||||
// Load JWT secret from environment variable
|
||||
@@ -19,25 +19,34 @@ class Auth {
|
||||
|
||||
public function login(string $username, string $password): array {
|
||||
$users = $this->loadUsers();
|
||||
$user = null;
|
||||
|
||||
foreach ($users['users'] as $user) {
|
||||
if ($user['username'] === $username) {
|
||||
if (password_verify($password, $user['password'])) {
|
||||
$token = $this->generateToken($user);
|
||||
return [
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
];
|
||||
}
|
||||
return ['success' => false, 'error' => 'Invalid password'];
|
||||
foreach ($users['users'] as $candidate) {
|
||||
if (hash_equals($candidate['username'], $username)) {
|
||||
$user = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'User not found'];
|
||||
// Always run a password check to avoid a timing oracle on user existence
|
||||
if ($user === null) {
|
||||
password_verify($password, '$2y$10$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG');
|
||||
return ['success' => false, 'error' => 'Invalid credentials'];
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user['password'])) {
|
||||
return ['success' => false, 'error' => 'Invalid credentials'];
|
||||
}
|
||||
|
||||
$token = $this->generateToken($user);
|
||||
return [
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyToken(string $token): ?array {
|
||||
@@ -49,8 +58,14 @@ class Auth {
|
||||
|
||||
list($header, $payload, $signature) = $parts;
|
||||
|
||||
// Verify signature
|
||||
$expectedSignature = base64_encode(
|
||||
// Verify algorithm header before trusting anything
|
||||
$headerData = json_decode(self::base64UrlDecode($header), true);
|
||||
if (!is_array($headerData) || ($headerData['alg'] ?? '') !== self::JWT_ALGO) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify signature (constant-time)
|
||||
$expectedSignature = self::base64UrlEncode(
|
||||
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
|
||||
);
|
||||
|
||||
@@ -59,54 +74,46 @@ class Auth {
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
$payloadData = json_decode(base64_decode($payload), true);
|
||||
$payloadData = json_decode(self::base64UrlDecode($payload), true);
|
||||
|
||||
// Check expiry
|
||||
if (isset($payloadData['exp']) && $payloadData['exp'] < time()) {
|
||||
if (!is_array($payloadData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expiry is mandatory
|
||||
if (!isset($payloadData['exp']) || !is_numeric($payloadData['exp']) || (int) $payloadData['exp'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payloadData;
|
||||
}
|
||||
|
||||
public function requireAuth(string $token): array {
|
||||
$payload = $this->verifyToken($token);
|
||||
|
||||
if ($payload === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
private static function base64UrlEncode(string $data): string {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public function requireAdmin(string $token): array {
|
||||
$payload = $this->requireAuth($token);
|
||||
|
||||
if ($payload['role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
exit;
|
||||
private static function base64UrlDecode(string $data): string {
|
||||
$remainder = strlen($data) % 4;
|
||||
if ($remainder !== 0) {
|
||||
$data .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
return base64_decode(strtr($data, '-_', '+/'));
|
||||
}
|
||||
|
||||
private function generateToken(array $user): string {
|
||||
$header = base64_encode(json_encode([
|
||||
$header = self::base64UrlEncode(json_encode([
|
||||
'alg' => self::JWT_ALGO,
|
||||
'typ' => 'JWT'
|
||||
]));
|
||||
|
||||
$payload = base64_encode(json_encode([
|
||||
$payload = self::base64UrlEncode(json_encode([
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role'],
|
||||
'iat' => time(),
|
||||
'exp' => time() + self::JWT_EXPIRY
|
||||
]));
|
||||
|
||||
$signature = base64_encode(
|
||||
$signature = self::base64UrlEncode(
|
||||
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
|
||||
);
|
||||
|
||||
@@ -121,8 +128,4 @@ class Auth {
|
||||
$content = file_get_contents($this->usersFile);
|
||||
return json_decode($content, true) ?? ['users' => []];
|
||||
}
|
||||
|
||||
public function hashPassword(string $password): string {
|
||||
return password_hash($password, PASSWORD_BCRYPT);
|
||||
}
|
||||
}
|
||||
|
||||
+150
-60
@@ -406,6 +406,11 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
public function createScore(string $id, string $name, string $compositor, array $pieces = []): array {
|
||||
// Security: the id becomes a directory name, allow digits only
|
||||
if (!$this->isValidSegment($id, '/^\d{1,6}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid score id'];
|
||||
}
|
||||
|
||||
$scoreDir = $this->scoresPath . $id;
|
||||
|
||||
if (is_dir($scoreDir)) {
|
||||
@@ -425,12 +430,17 @@ class ScoreScanner {
|
||||
|
||||
// Add piece names if provided
|
||||
foreach ($pieces as $piece) {
|
||||
$num = $piece['number'];
|
||||
$pieceName = $this->sanitizeIniValue($piece['name']);
|
||||
$num = $piece['number'] ?? $piece['id'] ?? '';
|
||||
if (!preg_match('/^\d{1,3}$/', (string) $num)) {
|
||||
$this->deleteDirectory($scoreDir);
|
||||
return ['success' => false, 'error' => 'Invalid piece number'];
|
||||
}
|
||||
$pieceName = $this->sanitizeIniValue($piece['name'] ?? '');
|
||||
$iniContent .= "{$num} = $pieceName\n";
|
||||
}
|
||||
|
||||
if (file_put_contents($scoreDir . '/score.ini', $iniContent) === false) {
|
||||
$this->deleteDirectory($scoreDir);
|
||||
return ['success' => false, 'error' => 'Failed to create score.ini'];
|
||||
}
|
||||
|
||||
@@ -445,11 +455,15 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
private function sanitizeIniValue(string $value): string {
|
||||
// Remove newlines and carriage returns to prevent INI injection
|
||||
return str_replace(["\n", "\r", "\x00"], '', $value);
|
||||
// Remove control characters that would break/inject INI lines
|
||||
return trim(str_replace(["\n", "\r", "\x00", "\x1a"], '', $value));
|
||||
}
|
||||
|
||||
private function isValidSegment(string $value, string $pattern): bool {
|
||||
return $value !== '' && $value !== '.' && $value !== '..' && (bool) preg_match($pattern, $value);
|
||||
}
|
||||
|
||||
public function updateScore(string $scoreId, ?string $name, ?string $compositor): array {
|
||||
public function updateScore(string $scoreId, ?string $name, ?string $compositor, ?string $ressource = null): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
$iniFile = $scoreDir . '/score.ini';
|
||||
|
||||
@@ -462,23 +476,39 @@ class ScoreScanner {
|
||||
return ['success' => false, 'error' => 'Failed to parse score.ini'];
|
||||
}
|
||||
|
||||
// Update values
|
||||
$currentName = $ini['info']['name'] ?? '';
|
||||
$currentCompositor = $ini['info']['compositor'] ?? '';
|
||||
$currentRessource = $ini['info']['ressource'] ?? '';
|
||||
|
||||
// Update values (keep existing when the field is not provided)
|
||||
if ($name !== null) {
|
||||
$ini['info']['name'] = $name;
|
||||
$currentName = $this->sanitizeIniValue($name);
|
||||
}
|
||||
if ($compositor !== null) {
|
||||
$ini['info']['compositor'] = $compositor;
|
||||
$currentCompositor = $this->sanitizeIniValue($compositor);
|
||||
}
|
||||
if ($ressource !== null) {
|
||||
$ressource = trim($ressource);
|
||||
if ($ressource !== '' && !preg_match('#^https?://#i', $ressource)) {
|
||||
return ['success' => false, 'error' => 'Invalid ressource URL (http/https only)'];
|
||||
}
|
||||
$currentRessource = $this->sanitizeIniValue($ressource);
|
||||
}
|
||||
|
||||
// Rebuild ini content
|
||||
$content = "[info]\n";
|
||||
$content .= "name = " . ($ini['info']['name'] ?? '') . "\n";
|
||||
$content .= "compositor = " . ($ini['info']['compositor'] ?? '') . "\n\n";
|
||||
$content .= "[pieces]\n";
|
||||
$content .= "name = $currentName\n";
|
||||
$content .= "compositor = $currentCompositor\n";
|
||||
if ($currentRessource !== '') {
|
||||
$content .= "ressource = $currentRessource\n";
|
||||
}
|
||||
$content .= "\n[pieces]\n";
|
||||
|
||||
if (isset($ini['pieces'])) {
|
||||
foreach ($ini['pieces'] as $key => $value) {
|
||||
$content .= "$key = $value\n";
|
||||
$safeKey = $this->sanitizeIniValue((string) $key);
|
||||
$safeValue = $this->sanitizeIniValue((string) $value);
|
||||
$content .= "$safeKey = $safeValue\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,9 +533,9 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
// Get existing info
|
||||
$name = $ini['info']['name'] ?? '';
|
||||
$compositor = $ini['info']['compositor'] ?? '';
|
||||
$ressource = $ini['info']['ressource'] ?? '';
|
||||
$name = $this->sanitizeIniValue($ini['info']['name'] ?? '');
|
||||
$compositor = $this->sanitizeIniValue($ini['info']['compositor'] ?? '');
|
||||
$ressource = $this->sanitizeIniValue($ini['info']['ressource'] ?? '');
|
||||
|
||||
// Rebuild ini content with new pieces
|
||||
$content = "[info]\n";
|
||||
@@ -518,8 +548,11 @@ class ScoreScanner {
|
||||
$content .= "count = " . count($pieces) . "\n";
|
||||
|
||||
foreach ($pieces as $piece) {
|
||||
$num = $piece['number'] ?? $piece['id'];
|
||||
$pieceName = $piece['name'] ?? '';
|
||||
$num = $piece['number'] ?? $piece['id'] ?? '';
|
||||
if (!preg_match('/^\d{1,3}$/', (string) $num)) {
|
||||
return ['success' => false, 'error' => 'Invalid piece number'];
|
||||
}
|
||||
$pieceName = $this->sanitizeIniValue($piece['name'] ?? '');
|
||||
$content .= "$num = $pieceName\n";
|
||||
}
|
||||
|
||||
@@ -562,6 +595,32 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1', bool $watermark = false, string $watermarkPosition = 'left'): array {
|
||||
// Security: every input used to build a path or a filename is validated
|
||||
if (!$this->isValidSegment($scoreId, '/^\d{1,6}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid score id'];
|
||||
}
|
||||
if (!$this->isValidSegment($piece, '/^\d{1,3}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid piece'];
|
||||
}
|
||||
if (!$this->isValidSegment($instrument, '/^[a-z]{2,4}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid instrument'];
|
||||
}
|
||||
if (!$this->isValidSegment($version, '/^[A-Za-z0-9_-]{1,10}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid version'];
|
||||
}
|
||||
if ($part !== '' && !$this->isValidSegment($part, '/^[A-Za-z0-9_+-]{1,20}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid part'];
|
||||
}
|
||||
if ($key !== '' && !$this->isValidSegment($key, '/^[a-z]{2,8}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid key'];
|
||||
}
|
||||
if ($clef !== '' && !$this->isValidSegment($clef, '/^[a-z]{2,8}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid clef'];
|
||||
}
|
||||
if ($variant !== '' && !$this->isValidSegment($variant, '/^[a-z0-9_-]{1,20}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid variant'];
|
||||
}
|
||||
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
|
||||
if (!is_dir($scoreDir)) {
|
||||
@@ -578,15 +637,6 @@ class ScoreScanner {
|
||||
return ['success' => false, 'error' => 'Upload error: ' . $file['error']];
|
||||
}
|
||||
|
||||
// Security: Validate MIME type
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if ($mimeType !== 'application/pdf') {
|
||||
return ['success' => false, 'error' => 'Invalid file type. Only PDF files are allowed'];
|
||||
}
|
||||
|
||||
// Security: Validate extension
|
||||
$originalExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if ($originalExtension !== 'pdf') {
|
||||
@@ -647,22 +697,38 @@ class ScoreScanner {
|
||||
|
||||
$targetPath = $targetDir . '/' . $filename;
|
||||
|
||||
// Use copy for CLI testing, move_uploaded_file for real uploads
|
||||
if (is_uploaded_file($file['tmp_name'])) {
|
||||
$result = move_uploaded_file($file['tmp_name'], $targetPath);
|
||||
if ($watermark) {
|
||||
// Write to a temp file, watermark it, then move into place atomically
|
||||
$tmpPath = $targetPath . '.tmp';
|
||||
if (is_uploaded_file($file['tmp_name'])) {
|
||||
$result = move_uploaded_file($file['tmp_name'], $tmpPath);
|
||||
} else {
|
||||
$result = copy($file['tmp_name'], $tmpPath);
|
||||
}
|
||||
if (!$result) {
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
if (!$this->addWatermark($tmpPath, $scoreId, $watermarkPosition)) {
|
||||
@unlink($tmpPath);
|
||||
return ['success' => false, 'error' => 'Failed to apply watermark'];
|
||||
}
|
||||
if (!rename($tmpPath, $targetPath)) {
|
||||
@unlink($tmpPath);
|
||||
return ['success' => false, 'error' => 'Failed to store watermarked file'];
|
||||
}
|
||||
} else {
|
||||
$result = copy($file['tmp_name'], $targetPath);
|
||||
// Use copy for CLI testing, move_uploaded_file for real uploads
|
||||
if (is_uploaded_file($file['tmp_name'])) {
|
||||
$result = move_uploaded_file($file['tmp_name'], $targetPath);
|
||||
} else {
|
||||
$result = copy($file['tmp_name'], $targetPath);
|
||||
}
|
||||
if (!$result) {
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($result && $watermark) {
|
||||
$this->addWatermark($targetPath, $scoreId, $watermarkPosition);
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||
}
|
||||
|
||||
public function getScoreFiles(string $scoreId): array {
|
||||
@@ -709,22 +775,42 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
public function deleteScoreFile(string $scoreId, string $filePath): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
$fullPath = $scoreDir . '/' . $filePath;
|
||||
|
||||
if (!file_exists($fullPath)) {
|
||||
$scoreDir = realpath($this->scoresPath . $scoreId);
|
||||
|
||||
if ($scoreDir === false || !is_dir($scoreDir)) {
|
||||
return ['success' => false, 'error' => 'Score not found'];
|
||||
}
|
||||
|
||||
// Security: reject null bytes and resolve the real target
|
||||
if (strpos($filePath, "\x00") !== false) {
|
||||
return ['success' => false, 'error' => 'Invalid file path'];
|
||||
}
|
||||
|
||||
$fullPath = realpath($scoreDir . '/' . $filePath);
|
||||
|
||||
if ($fullPath === false) {
|
||||
return ['success' => false, 'error' => 'File not found'];
|
||||
}
|
||||
|
||||
|
||||
// Security: ensure the file stays inside the score directory
|
||||
if (strpos($fullPath, $scoreDir . DIRECTORY_SEPARATOR) !== 0) {
|
||||
return ['success' => false, 'error' => 'Access denied'];
|
||||
}
|
||||
|
||||
// Security: only PDF files can be deleted, never score.ini
|
||||
if (is_dir($fullPath)) {
|
||||
return ['success' => false, 'error' => 'Cannot delete directory'];
|
||||
}
|
||||
|
||||
|
||||
if (strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
return ['success' => false, 'error' => 'Only PDF files can be deleted'];
|
||||
}
|
||||
|
||||
if (unlink($fullPath)) {
|
||||
$this->cleanupEmptyDirectories($scoreDir, dirname($filePath));
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to delete file'];
|
||||
}
|
||||
|
||||
@@ -750,26 +836,30 @@ class ScoreScanner {
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$pdf = new \setasign\Fpdi\Fpdi();
|
||||
$pdf->setSourceFile($pdfPath);
|
||||
|
||||
$templateId = $pdf->importPage(1);
|
||||
$size = $pdf->getTemplateSize($templateId);
|
||||
|
||||
$pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]);
|
||||
$pdf->useTemplate($templateId);
|
||||
$pageCount = $pdf->setSourceFile($pdfPath);
|
||||
|
||||
$pdf->SetFont('helvetica', 'B', 20);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
if ($position === 'right') {
|
||||
$pdf->SetXY($size['width'] - 45, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'R');
|
||||
} else {
|
||||
$pdf->SetXY(10, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'L');
|
||||
// Apply the watermark on every page (do not truncate multi-page PDFs)
|
||||
for ($i = 1; $i <= $pageCount; $i++) {
|
||||
$templateId = $pdf->importPage($i);
|
||||
$size = $pdf->getTemplateSize($templateId);
|
||||
|
||||
$pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]);
|
||||
$pdf->useTemplate($templateId);
|
||||
|
||||
if ($position === 'right') {
|
||||
$pdf->SetXY($size['width'] - 45, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'R');
|
||||
} else {
|
||||
$pdf->SetXY(10, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'L');
|
||||
}
|
||||
}
|
||||
|
||||
return $pdf->Output('F', $pdfPath);
|
||||
$pdf->Output('F', $pdfPath);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
error_log('Watermark error: ' . $e->getMessage());
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user