868 lines
31 KiB
PHP
868 lines
31 KiB
PHP
<?php
|
|
class ScoreScanner {
|
|
private $scoresPath;
|
|
|
|
public function __construct($path = '../legacy/Scores/') {
|
|
$this->scoresPath = $path;
|
|
}
|
|
|
|
public function getAllScores() {
|
|
if (!is_dir($this->scoresPath)) {
|
|
return ['error' => 'Scores directory not found: ' . $this->scoresPath];
|
|
}
|
|
|
|
$scores = [];
|
|
$directories = scandir($this->scoresPath);
|
|
|
|
foreach ($directories as $dir) {
|
|
if ($dir === '.' || $dir === '..') continue;
|
|
|
|
// Ignorer les fichiers cachés et non-numériques
|
|
if (strpos($dir, '.') === 0) continue;
|
|
if (!is_numeric($dir)) continue;
|
|
|
|
$scorePath = $this->scoresPath . $dir;
|
|
if (!is_dir($scorePath)) continue;
|
|
|
|
$score = $this->getScoreInfo($dir);
|
|
if ($score) {
|
|
$scores[] = $score;
|
|
}
|
|
}
|
|
|
|
// Trier par ID
|
|
usort($scores, function($a, $b) {
|
|
return intval($a['id']) - intval($b['id']);
|
|
});
|
|
|
|
return $scores;
|
|
}
|
|
|
|
public function getScoreInfo($id) {
|
|
$scoreDir = $this->scoresPath . $id;
|
|
$iniFile = $scoreDir . '/score.ini';
|
|
|
|
if (!file_exists($iniFile)) {
|
|
return null;
|
|
}
|
|
|
|
$ini = @parse_ini_file($iniFile, true);
|
|
|
|
// If parse fails, try to extract info manually
|
|
if ($ini === false) {
|
|
$content = file_get_contents($iniFile);
|
|
|
|
$name = '';
|
|
$compositor = '';
|
|
|
|
if (preg_match('/name\s*=\s*(.+)/i', $content, $matches)) {
|
|
$name = trim($matches[1]);
|
|
}
|
|
if (preg_match('/compositor\s*=\s*(.+)/i', $content, $matches)) {
|
|
$compositor = trim($matches[1]);
|
|
}
|
|
|
|
if ($name || $compositor) {
|
|
return [
|
|
'id' => $id,
|
|
'name' => $name,
|
|
'compositor' => $compositor,
|
|
'ressource' => null
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
$info = $ini['info'] ?? [];
|
|
|
|
return [
|
|
'id' => $id,
|
|
'name' => $info['name'] ?? '',
|
|
'compositor' => $info['compositor'] ?? '',
|
|
'ressource' => $info['ressource'] ?? null
|
|
];
|
|
}
|
|
|
|
public function getScoreDetail($id) {
|
|
$scoreDir = $this->scoresPath . $id;
|
|
|
|
if (!is_dir($scoreDir)) {
|
|
return null;
|
|
}
|
|
|
|
$basicInfo = $this->getScoreInfo($id);
|
|
if (!$basicInfo) {
|
|
return null;
|
|
}
|
|
|
|
$instruments = [];
|
|
|
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
|
// First get all piece directories
|
|
$pieceDirs = scandir($scoreDir);
|
|
|
|
foreach ($pieceDirs as $pieceDir) {
|
|
if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue;
|
|
if (strpos($pieceDir, '.') === 0) continue;
|
|
if (!is_dir($scoreDir . '/' . $pieceDir)) continue;
|
|
|
|
// Then get instrument directories
|
|
$instrumentDirs = scandir($scoreDir . '/' . $pieceDir);
|
|
|
|
foreach ($instrumentDirs as $instrumentId) {
|
|
if ($instrumentId === '.' || $instrumentId === '..') continue;
|
|
if (strpos($instrumentId, '.') === 0) continue;
|
|
if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue;
|
|
|
|
$instrument = $this->getInstrumentInfo($id, $instrumentId, $pieceDir);
|
|
if ($instrument) {
|
|
$instruments[] = $instrument;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trier instruments par nom
|
|
usort($instruments, function($a, $b) {
|
|
return strcmp($a['title'], $b['title']);
|
|
});
|
|
|
|
return array_merge($basicInfo, ['instruments' => $instruments]);
|
|
}
|
|
|
|
private function getInstrumentInfo($scoreId, $instrumentId, $pieceId = null) {
|
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
|
if ($pieceId !== null) {
|
|
$instrumentPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId;
|
|
} else {
|
|
$instrumentPath = $this->scoresPath . $scoreId . '/' . $instrumentId;
|
|
}
|
|
|
|
if (!is_dir($instrumentPath)) {
|
|
return null;
|
|
}
|
|
|
|
$title = $this->getInstrumentName($instrumentId);
|
|
$parts = [];
|
|
|
|
$entries = scandir($instrumentPath);
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') continue;
|
|
if (strpos($entry, '.') === 0) continue;
|
|
|
|
$partPath = $instrumentPath . '/' . $entry;
|
|
if (!is_dir($partPath)) continue;
|
|
|
|
$part = $this->getPartInfo($scoreId, $instrumentId, $entry, $pieceId);
|
|
if ($part) {
|
|
$parts[] = $part;
|
|
}
|
|
}
|
|
|
|
// Trier les parties par numéro
|
|
usort($parts, function($a, $b) {
|
|
return intval($a['id']) - intval($b['id']);
|
|
});
|
|
|
|
if (empty($parts)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => $instrumentId,
|
|
'title' => $title,
|
|
'piece' => $pieceId,
|
|
'parts' => $parts
|
|
];
|
|
}
|
|
|
|
private function getPartInfo($scoreId, $instrumentId, $partId, $pieceId = null) {
|
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
|
if ($pieceId !== null) {
|
|
$partPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId . '/' . $partId;
|
|
} else {
|
|
$partPath = $this->scoresPath . $scoreId . '/' . $instrumentId . '/' . $partId;
|
|
}
|
|
|
|
if (!is_dir($partPath)) {
|
|
return null;
|
|
}
|
|
|
|
$files = [];
|
|
$entries = scandir($partPath);
|
|
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') continue;
|
|
if (strpos($entry, '.') === 0) continue;
|
|
|
|
$filePath = $partPath . '/' . $entry;
|
|
if (!is_file($filePath)) continue;
|
|
|
|
// Vérifier que c'est un PDF
|
|
if (strtolower(pathinfo($entry, PATHINFO_EXTENSION)) !== 'pdf') continue;
|
|
|
|
$relativePath = $pieceId !== null
|
|
? "$scoreId/$pieceId/$instrumentId/$partId/$entry"
|
|
: "$scoreId/$instrumentId/$partId/$entry";
|
|
|
|
$parsed = $this->parseFilename(pathinfo($entry, PATHINFO_FILENAME));
|
|
|
|
$files[] = [
|
|
'name' => pathinfo($entry, PATHINFO_FILENAME),
|
|
'filename' => $entry,
|
|
'path' => $relativePath,
|
|
'part' => $parsed['part'],
|
|
'key' => $parsed['key'],
|
|
'clef' => $parsed['clef'],
|
|
'variant' => $parsed['variant']
|
|
];
|
|
}
|
|
|
|
if (empty($files)) {
|
|
return null;
|
|
}
|
|
|
|
// Trier les fichiers par nom
|
|
usort($files, function($a, $b) {
|
|
return strcmp($a['name'], $b['name']);
|
|
});
|
|
|
|
return [
|
|
'id' => $partId,
|
|
'files' => $files
|
|
];
|
|
}
|
|
|
|
private function getInstrumentName($code) {
|
|
$names = [
|
|
'cla' => 'Clarinette',
|
|
'flu' => 'Flûte',
|
|
'trb' => 'Trombone',
|
|
'pic' => 'Piccolo',
|
|
'per' => 'Percussions',
|
|
'htb' => 'Hautbois',
|
|
'trp' => 'Trompette',
|
|
'dir' => 'Direction',
|
|
'sax' => 'Sax Alto',
|
|
'sat' => 'Sax Ténor',
|
|
'sab' => 'Sax Baryton',
|
|
'cor' => 'Cor',
|
|
'eup' => 'Euphonium',
|
|
'bas' => 'Basson',
|
|
'cba' => 'Contrebasse',
|
|
'crn' => 'Cornet',
|
|
'coa' => 'Cor Anglais',
|
|
'clb' => 'Clarinette Basse',
|
|
'har' => 'Harpe',
|
|
'pia' => 'Piano',
|
|
'tub' => 'Tuba',
|
|
'sup' => 'Parties supplémentaires',
|
|
'par' => 'Parties'
|
|
];
|
|
|
|
return $names[$code] ?? $code;
|
|
}
|
|
|
|
private function parseFilename(string $filename): array {
|
|
$result = [
|
|
'part' => null,
|
|
'key' => null,
|
|
'clef' => null,
|
|
'variant' => null
|
|
];
|
|
|
|
// Normalize: replace - by _ and lowercase
|
|
$name = strtolower(str_replace('-', '_', $filename));
|
|
|
|
// Extract clef (clesol, clefa)
|
|
if (preg_match('/(clesol|clefa)$/', $name, $m)) {
|
|
$result['clef'] = $m[1];
|
|
$name = preg_replace('/_(clesol|clefa)$/', '', $name);
|
|
}
|
|
|
|
// Extract variant (solo, etc.)
|
|
$variants = ['solo', 'default'];
|
|
foreach ($variants as $variant) {
|
|
if (strpos($name, $variant) !== false) {
|
|
$result['variant'] = $variant;
|
|
$name = str_replace('_' . $variant, '', $name);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Extract key (tonalités: sib, mib, fa, do, etc.)
|
|
$keys = ['sib', 'mib', 'reb', 'dob', 'solb', 'lab', 'mibemol', 'sibemol'];
|
|
foreach ($keys as $key) {
|
|
if (preg_match('/_' . $key . '/', $name)) {
|
|
$result['key'] = $key;
|
|
$name = preg_replace('/_' . $key . '/', '', $name);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no compound keys found, try single keys
|
|
if ($result['key'] === null) {
|
|
$singleKeys = ['fa', 'do', 'ut', 'sol', 're', 'mi', 'si'];
|
|
foreach ($singleKeys as $key) {
|
|
if (preg_match('/_' . $key . '(?:_|$)/', $name)) {
|
|
$result['key'] = $key;
|
|
$name = preg_replace('/_' . $key . '/', '', $name);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract part number (1, 2, 1_et_2, etc.) - should be last
|
|
// Handle formats: 1, 2, 1_et_2
|
|
if (preg_match('/_(\d+(?:_\w+)*)$/', $name, $m)) {
|
|
$result['part'] = $m[1];
|
|
$name = preg_replace('/_\d+(?:_\w+)*$/', '', $name);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// API Methods
|
|
|
|
public function listScores(): array {
|
|
return $this->getAllScores();
|
|
}
|
|
|
|
public function getScore(string $id): ?array {
|
|
return $this->getScoreDetail($id);
|
|
}
|
|
|
|
public function getPieces(string $scoreId): array {
|
|
$scoreDir = $this->scoresPath . $scoreId;
|
|
$iniFile = $scoreDir . '/score.ini';
|
|
|
|
if (!file_exists($iniFile)) {
|
|
return [];
|
|
}
|
|
|
|
$ini = @parse_ini_file($iniFile, true);
|
|
if ($ini === false) {
|
|
return [];
|
|
}
|
|
|
|
$pieces = [];
|
|
|
|
// Check if [pieces] section exists
|
|
if (isset($ini['pieces'])) {
|
|
$piecesSection = $ini['pieces'];
|
|
$count = intval($piecesSection['count'] ?? 1);
|
|
|
|
for ($i = 1; $i <= $count; $i++) {
|
|
$pieces[] = [
|
|
'id' => $i,
|
|
'name' => $piecesSection[$i] ?? "Pièce $i"
|
|
];
|
|
}
|
|
}
|
|
|
|
return $pieces;
|
|
}
|
|
|
|
public function getInstruments(string $scoreId, ?string $pieceId = null): array {
|
|
$scoreDir = $this->scoresPath . $scoreId;
|
|
|
|
if (!is_dir($scoreDir)) {
|
|
return [];
|
|
}
|
|
|
|
$instruments = [];
|
|
|
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
|
$pieceDirs = scandir($scoreDir);
|
|
|
|
foreach ($pieceDirs as $pieceDir) {
|
|
if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue;
|
|
if (strpos($pieceDir, '.') === 0) continue;
|
|
if (!is_dir($scoreDir . '/' . $pieceDir)) continue;
|
|
|
|
// Filter by piece if specified
|
|
if ($pieceId !== null && $pieceDir !== $pieceId) continue;
|
|
|
|
$instrumentDirs = scandir($scoreDir . '/' . $pieceDir);
|
|
|
|
foreach ($instrumentDirs as $instrumentId) {
|
|
if ($instrumentId === '.' || $instrumentId === '..') continue;
|
|
if (strpos($instrumentId, '.') === 0) continue;
|
|
if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue;
|
|
|
|
$instrument = $this->getInstrumentInfo($scoreId, $instrumentId, $pieceDir);
|
|
if ($instrument) {
|
|
$instruments[] = $instrument;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by title
|
|
usort($instruments, function($a, $b) {
|
|
return strcmp($a['title'], $b['title']);
|
|
});
|
|
|
|
return $instruments;
|
|
}
|
|
|
|
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)) {
|
|
return ['success' => false, 'error' => 'Score already exists'];
|
|
}
|
|
|
|
if (!mkdir($scoreDir, 0755, true)) {
|
|
return ['success' => false, 'error' => 'Failed to create directory'];
|
|
}
|
|
|
|
// Security: Sanitize inputs to prevent INI injection
|
|
$name = $this->sanitizeIniValue($name);
|
|
$compositor = $this->sanitizeIniValue($compositor);
|
|
|
|
$pieceCount = count($pieces) > 0 ? count($pieces) : 1;
|
|
$iniContent = "[info]\nname = $name\ncompositor = $compositor\n\n[pieces]\ncount = $pieceCount\n";
|
|
|
|
// Add piece names if provided
|
|
foreach ($pieces as $piece) {
|
|
$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'];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'score' => [
|
|
'id' => $id,
|
|
'name' => $name,
|
|
'compositor' => $compositor
|
|
]
|
|
];
|
|
}
|
|
|
|
private function sanitizeIniValue(string $value): string {
|
|
// 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, ?string $ressource = null): array {
|
|
$scoreDir = $this->scoresPath . $scoreId;
|
|
$iniFile = $scoreDir . '/score.ini';
|
|
|
|
if (!file_exists($iniFile)) {
|
|
return ['success' => false, 'error' => 'Score not found'];
|
|
}
|
|
|
|
$ini = @parse_ini_file($iniFile, true);
|
|
if ($ini === false) {
|
|
return ['success' => false, 'error' => 'Failed to parse score.ini'];
|
|
}
|
|
|
|
$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) {
|
|
$currentName = $this->sanitizeIniValue($name);
|
|
}
|
|
if ($compositor !== null) {
|
|
$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 = $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) {
|
|
$safeKey = $this->sanitizeIniValue((string) $key);
|
|
$safeValue = $this->sanitizeIniValue((string) $value);
|
|
$content .= "$safeKey = $safeValue\n";
|
|
}
|
|
}
|
|
|
|
if (file_put_contents($iniFile, $content) === false) {
|
|
return ['success' => false, 'error' => 'Failed to write score.ini'];
|
|
}
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
public function updatePieces(string $scoreId, array $pieces): array {
|
|
$scoreDir = $this->scoresPath . $scoreId;
|
|
$iniFile = $scoreDir . '/score.ini';
|
|
|
|
if (!file_exists($iniFile)) {
|
|
return ['success' => false, 'error' => 'Score not found'];
|
|
}
|
|
|
|
$ini = @parse_ini_file($iniFile, true);
|
|
if ($ini === false) {
|
|
return ['success' => false, 'error' => 'Failed to parse score.ini'];
|
|
}
|
|
|
|
// Get existing info
|
|
$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";
|
|
$content .= "name = $name\n";
|
|
$content .= "compositor = $compositor\n";
|
|
if ($ressource) {
|
|
$content .= "ressource = $ressource\n";
|
|
}
|
|
$content .= "\n[pieces]\n";
|
|
$content .= "count = " . count($pieces) . "\n";
|
|
|
|
foreach ($pieces as $piece) {
|
|
$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";
|
|
}
|
|
|
|
if (file_put_contents($iniFile, $content) === false) {
|
|
return ['success' => false, 'error' => 'Failed to write score.ini'];
|
|
}
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
public function deleteScore(string $scoreId): array {
|
|
$scoreDir = $this->scoresPath . $scoreId;
|
|
|
|
if (!is_dir($scoreDir)) {
|
|
return ['success' => false, 'error' => 'Score not found'];
|
|
}
|
|
|
|
// Recursive delete
|
|
$this->deleteDirectory($scoreDir);
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
private function deleteDirectory(string $dir): void {
|
|
if (!is_dir($dir)) return;
|
|
|
|
$items = scandir($dir);
|
|
foreach ($items as $item) {
|
|
if ($item === '.' || $item === '..') continue;
|
|
|
|
$path = $dir . '/' . $item;
|
|
if (is_dir($path)) {
|
|
$this->deleteDirectory($path);
|
|
} else {
|
|
unlink($path);
|
|
}
|
|
}
|
|
|
|
rmdir($dir);
|
|
}
|
|
|
|
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', ?string $combined = null): 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)) {
|
|
return ['success' => false, 'error' => 'Score not found'];
|
|
}
|
|
|
|
// Security: Validate file upload
|
|
if (!isset($file['tmp_name']) || !isset($file['name'])) {
|
|
return ['success' => false, 'error' => 'No file uploaded'];
|
|
}
|
|
|
|
// Security: Check file upload errors
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
return ['success' => false, 'error' => 'Upload error: ' . $file['error']];
|
|
}
|
|
|
|
// Security: Validate extension
|
|
$originalExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
if ($originalExtension !== 'pdf') {
|
|
return ['success' => false, 'error' => 'Invalid file extension. Only .pdf files are allowed'];
|
|
}
|
|
|
|
// Security: Check magic bytes (PDF starts with %PDF)
|
|
$handle = fopen($file['tmp_name'], 'rb');
|
|
if ($handle) {
|
|
$header = fread($handle, 4);
|
|
fclose($handle);
|
|
if ($header !== '%PDF') {
|
|
return ['success' => false, 'error' => 'Invalid PDF file header'];
|
|
}
|
|
}
|
|
|
|
// Security: Check file size (max 20MB)
|
|
$maxSize = 20 * 1024 * 1024; // 20MB
|
|
if ($file['size'] > $maxSize) {
|
|
return ['success' => false, 'error' => 'File too large. Maximum size is 20MB'];
|
|
}
|
|
|
|
// Create directory structure: scoreId/piece/instrument/version
|
|
$targetDir = $scoreDir . '/' . $piece . '/' . $instrument . '/' . $version;
|
|
|
|
if (!is_dir($targetDir)) {
|
|
if (!mkdir($targetDir, 0755, true)) {
|
|
return ['success' => false, 'error' => 'Failed to create directory'];
|
|
}
|
|
}
|
|
|
|
// Map instrument code to name
|
|
$instrumentNames = [
|
|
'dir' => 'direction', 'pic' => 'piccolo', 'flu' => 'flute', 'cla' => 'clarinette',
|
|
'clb' => 'clarinette_basse', 'sax' => 'saxophone_alto', 'sat' => 'saxophone_tenor',
|
|
'sab' => 'saxophone_baryton', 'coa' => 'cor_anglais', 'htb' => 'hautbois',
|
|
'bas' => 'basson', 'cor' => 'cor', 'trp' => 'trompette', 'crn' => 'cornet',
|
|
'trb' => 'trombone', 'eup' => 'euphonium', 'tub' => 'tuba', 'cba' => 'contrebasse',
|
|
'per' => 'percussion', 'pia' => 'piano', 'har' => 'harpe'
|
|
];
|
|
|
|
$instName = $instrumentNames[$instrument] ?? $instrument;
|
|
|
|
if (in_array($instrument, ['sup', 'par'], true) && $combined !== null && trim($combined) !== '') {
|
|
// Combined/superposed parts keep their real instrument names (e.g. "contrebasse-1+clarinette-1")
|
|
$combined = strtolower(trim($combined));
|
|
if (!$this->isValidSegment($combined, '/^[a-z0-9_+.-]+$/')) {
|
|
return ['success' => false, 'error' => 'Invalid combined instruments'];
|
|
}
|
|
$filename = $combined . '.pdf';
|
|
} else {
|
|
// NAMING.md: fields separated by '-', compound names/suffixes by '_'
|
|
$nameParts = array_values(array_filter([$instName, $key, $variant, $clef], fn($v) => $v !== ''));
|
|
$nameParts[] = $part;
|
|
$filename = implode('-', $nameParts) . '.pdf';
|
|
}
|
|
|
|
$targetPath = $targetDir . '/' . $filename;
|
|
|
|
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 {
|
|
// 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'];
|
|
}
|
|
}
|
|
|
|
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
|
}
|
|
|
|
public function getScoreFiles(string $scoreId): array {
|
|
$scoreDir = $this->scoresPath . $scoreId;
|
|
|
|
if (!is_dir($scoreDir)) {
|
|
return ['success' => false, 'error' => 'Score not found'];
|
|
}
|
|
|
|
$tree = $this->buildFileTree($scoreDir, $scoreId);
|
|
|
|
return ['success' => true, 'files' => $tree];
|
|
}
|
|
|
|
private function buildFileTree(string $dir, string $scoreId, string $relativePath = ''): array {
|
|
$items = [];
|
|
$directories = scandir($dir);
|
|
|
|
foreach ($directories as $item) {
|
|
if ($item === '.' || $item === '..') continue;
|
|
if ($item === 'score.ini') continue;
|
|
|
|
$fullPath = $dir . '/' . $item;
|
|
$itemRelativePath = $relativePath ? $relativePath . '/' . $item : $item;
|
|
|
|
if (is_dir($fullPath)) {
|
|
$children = $this->buildFileTree($fullPath, $scoreId, $itemRelativePath);
|
|
$items[] = [
|
|
'name' => $item,
|
|
'path' => $itemRelativePath,
|
|
'type' => 'folder',
|
|
'children' => $children
|
|
];
|
|
} else {
|
|
$items[] = [
|
|
'name' => $item,
|
|
'path' => $itemRelativePath,
|
|
'type' => 'file'
|
|
];
|
|
}
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
public function deleteScoreFile(string $scoreId, string $filePath): array {
|
|
$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'];
|
|
}
|
|
|
|
private function cleanupEmptyDirectories(string $scoreDir, string $dirPath): void {
|
|
while ($dirPath && $dirPath !== '.') {
|
|
$fullPath = $scoreDir . '/' . $dirPath;
|
|
if (!is_dir($fullPath)) break;
|
|
|
|
$files = scandir($fullPath);
|
|
$files = array_diff($files, ['.', '..']);
|
|
|
|
if (empty($files)) {
|
|
rmdir($fullPath);
|
|
$dirPath = dirname($dirPath);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function addWatermark(string $pdfPath, string $scoreId, string $position = 'left'): bool {
|
|
try {
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
|
|
$pdf = new \setasign\Fpdi\Fpdi();
|
|
$pageCount = $pdf->setSourceFile($pdfPath);
|
|
|
|
$pdf->SetFont('helvetica', 'B', 20);
|
|
$pdf->SetTextColor(0, 0, 0);
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
$pdf->Output('F', $pdfPath);
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
error_log('Watermark error: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
}
|