[FEAT] Admin / ajout d'une partition doit pouvoir ajouter le numero dans

le PDF. #1
This commit is contained in:
NADAL Jean-Baptiste
2026-02-25 18:31:41 +01:00
parent cf0db69f2d
commit 38bfe62eec
14 changed files with 509 additions and 25 deletions

View File

@@ -1,2 +1,2 @@
JWT_SECRET=6jh/MWqVplwXQKsiwlKahE19TSavfR1dNCawsQFixus=
SCORES_PATH=/data/scores/
SCORES_PATH=/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/

7
api/composer.json Normal file
View File

@@ -0,0 +1,7 @@
{
"require": {
"setasign/fpdi": "^2.6",
"tecnickcom/tcpdf": "^6.10",
"setasign/fpdf": "^1.8"
}
}

208
api/composer.lock generated Normal file
View File

@@ -0,0 +1,208 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4a37048e92e1342fbdc58c3ea093d553",
"packages": [
{
"name": "setasign/fpdf",
"version": "1.8.6",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDF.git",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-zlib": "*"
},
"type": "library",
"autoload": {
"classmap": [
"fpdf.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Olivier Plathey",
"email": "oliver@fpdf.org",
"homepage": "http://fpdf.org/"
}
],
"description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.",
"homepage": "http://www.fpdf.org",
"keywords": [
"fpdf",
"pdf"
],
"support": {
"source": "https://github.com/Setasign/FPDF/tree/1.8.6"
},
"time": "2023-06-26T14:44:25+00:00"
},
{
"name": "setasign/fpdi",
"version": "v2.6.4",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "4b53852fde2734ec6a07e458a085db627c60eada"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada",
"reference": "4b53852fde2734ec6a07e458a085db627c60eada",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"php": "^7.1 || ^8.0"
},
"conflict": {
"setasign/tfpdf": "<1.31"
},
"require-dev": {
"phpunit/phpunit": "^7",
"setasign/fpdf": "~1.8.6",
"setasign/tfpdf": "~1.33",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.8"
},
"suggest": {
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
},
"type": "library",
"autoload": {
"psr-4": {
"setasign\\Fpdi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
},
{
"name": "Maximilian Kresse",
"email": "maximilian.kresse@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.4"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
"type": "tidelift"
}
],
"time": "2025-08-05T09:57:14+00:00"
},
{
"name": "tecnickcom/tcpdf",
"version": "6.10.1",
"source": {
"type": "git",
"url": "https://github.com/tecnickcom/TCPDF.git",
"reference": "7a2701251e5d52fc3d508fd71704683eb54f5939"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/7a2701251e5d52fc3d508fd71704683eb54f5939",
"reference": "7a2701251e5d52fc3d508fd71704683eb54f5939",
"shasum": ""
},
"require": {
"ext-curl": "*",
"php": ">=7.1.0"
},
"type": "library",
"autoload": {
"classmap": [
"config",
"include",
"tcpdf.php",
"tcpdf_barcodes_1d.php",
"tcpdf_barcodes_2d.php",
"include/tcpdf_colors.php",
"include/tcpdf_filters.php",
"include/tcpdf_font_data.php",
"include/tcpdf_fonts.php",
"include/tcpdf_images.php",
"include/tcpdf_static.php",
"include/barcodes/datamatrix.php",
"include/barcodes/pdf417.php",
"include/barcodes/qrcode.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Nicola Asuni",
"email": "info@tecnick.com",
"role": "lead"
}
],
"description": "TCPDF is a PHP class for generating PDF documents and barcodes.",
"homepage": "http://www.tcpdf.org/",
"keywords": [
"PDFD32000-2008",
"TCPDF",
"barcodes",
"datamatrix",
"pdf",
"pdf417",
"qrcode"
],
"support": {
"issues": "https://github.com/tecnickcom/TCPDF/issues",
"source": "https://github.com/tecnickcom/TCPDF/tree/6.10.1"
},
"funding": [
{
"url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ",
"type": "custom"
}
],
"time": "2025-11-21T10:58:21+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@@ -362,13 +362,14 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE
}
// POST /admin/scores/:id/upload - Upload PDF
if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') {
if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') {
$scoreId = $matches[1];
// Check for upload errors
if (!isset($_FILES['file'])) {
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
// Check if post_max_size was exceeded
if (empty($_POST) && $_SERVER['CONTENT_LENGTH'] > 0) {
$contentLength = $_SERVER['CONTENT_LENGTH'] ?? 0;
if ($contentLength > 0 && empty($_FILES)) {
$maxSize = ini_get('post_max_size');
http_response_code(413);
echo json_encode(['error' => "File too large. Maximum size is $maxSize"]);
@@ -413,8 +414,10 @@ if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method ===
$clef = $_POST['clef'] ?? '';
$variant = $_POST['variant'] ?? '';
$part = $_POST['part'] ?? '1';
$watermark = isset($_POST['watermark']) && $_POST['watermark'] === 'true';
$watermarkPosition = $_POST['watermarkPosition'] ?? 'left';
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $key, $clef, $variant, $part);
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $key, $clef, $variant, $part, $watermark, $watermarkPosition);
if ($result['success']) {
echo json_encode(['success' => true, 'path' => $result['path']]);

View File

@@ -495,7 +495,7 @@ class ScoreScanner {
rmdir($dir);
}
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1'): array {
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 {
$scoreDir = $this->scoresPath . $scoreId;
if (!is_dir($scoreDir)) {
@@ -588,6 +588,10 @@ class ScoreScanner {
$result = copy($file['tmp_name'], $targetPath);
}
if ($result && $watermark) {
$this->addWatermark($targetPath, $scoreId, $watermarkPosition);
}
if ($result) {
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
}
@@ -674,4 +678,35 @@ class ScoreScanner {
}
}
}
private function addWatermark(string $pdfPath, string $scoreId, string $position = 'left'): bool {
try {
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);
$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');
}
return $pdf->Output('F', $pdfPath);
} catch (\Exception $e) {
error_log('Watermark error: ' . $e->getMessage());
return false;
}
}
}

View File

@@ -286,6 +286,169 @@ class APITest {
}
}
public function testWatermarkUpload() {
$this->section("Upload - Watermark");
// Reset rate limiting
array_map('unlink', glob(sys_get_temp_dir() . '/rate_*'));
// Get a score to upload to
$scores = $this->request('GET', 'scores');
if (empty($scores['body']['scores'])) {
echo " ⚠ No scores available for watermark test\n";
return;
}
$scoreId = $scores['body']['scores'][0]['id'];
// Create a minimal test PDF
$pdfContent = "%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /Resources 4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>
endobj
4 0 obj
<< /Font << /F1 6 0 R >> >>
endobj
5 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
100 700 Td
(Test) Tj
ET
endstream
endobj
6 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 7
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000214 00000 n
0000000312 00000 n
0000000401 00000 n
trailer
<< /Size 7 /Root 1 0 R >>
startxref
490
%%EOF";
$tmpFile = sys_get_temp_dir() . '/test_watermark_' . time() . '.pdf';
file_put_contents($tmpFile, $pdfContent);
// Test upload with watermark left
$result = $this->multipartRequest("admin/scores/$scoreId/upload", [
['name' => 'file', 'file' => $tmpFile],
['name' => 'piece', 'data' => '1'],
['name' => 'instrument', 'data' => 'dir'],
['name' => 'version', 'data' => '1'],
['name' => 'watermark', 'data' => 'true'],
['name' => 'watermarkPosition', 'data' => 'left']
]);
$this->test('Upload with watermark left returns 200', $result['code'] === 200);
// Get files to verify it was uploaded
$files = $this->request('GET', "admin/scores/$scoreId/files");
$hasFile = false;
if (!empty($files['body']['files'])) {
foreach ($files['body']['files'] as $f) {
if (strpos($f['path'], 'direction_1.pdf') !== false) {
$hasFile = true;
// Clean up
@unlink(SCORES_PATH . "$scoreId/" . $f['path']);
break;
}
}
}
// Test upload with watermark right
$result2 = $this->multipartRequest("admin/scores/$scoreId/upload", [
['name' => 'file', 'file' => $tmpFile],
['name' => 'piece', 'data' => '1'],
['name' => 'instrument', 'data' => 'dir'],
['name' => 'version', 'data' => '2'],
['name' => 'watermark', 'data' => 'true'],
['name' => 'watermarkPosition', 'data' => 'right']
]);
$this->test('Upload with watermark right returns 200', $result2['code'] === 200);
// Clean up
@unlink($tmpFile);
// Clean up uploaded files
$files = $this->request('GET', "admin/scores/$scoreId/files");
if (!empty($files['body']['files'])) {
foreach ($files['body']['files'] as $f) {
if (strpos($f['path'], 'direction_') !== false) {
@unlink(SCORES_PATH . "$scoreId/" . $f['path']);
}
}
}
}
private function multipartRequest($endpoint, $fields) {
$url = BASE_URL . '/' . $endpoint;
$boundary = '----WebKitFormBoundary' . bin2hex(random_bytes(8));
$body = '';
foreach ($fields as $field) {
$body .= "--$boundary\r\n";
if (isset($field['file'])) {
$filename = basename($field['file']);
$content = file_get_contents($field['file']);
$body .= "Content-Disposition: form-data; name=\"{$field['name']}\"; filename=\"$filename\"\r\n";
$body .= "Content-Type: application/pdf\r\n\r\n";
$body .= $content . "\r\n";
} else {
$body .= "Content-Disposition: form-data; name=\"{$field['name']}\"\r\n\r\n";
$body .= $field['data'] . "\r\n";
}
}
$body .= "--$boundary--\r\n";
$headers = [
'Content-Type: multipart/form-data; boundary=' . $boundary,
'Authorization: Bearer ' . $this->token
];
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $body
]
]);
$response = @file_get_contents($url, false, $context);
$headers_list = $http_response_header ?? [];
$code = 0;
foreach ($headers_list as $header) {
if (preg_match('/^HTTP\/\d+(\.\d+)?\s+(\d{3})/', $header, $matches)) {
$code = (int)$matches[2];
}
}
return [
'code' => $code,
'body' => json_decode($response, true) ?? []
];
}
// ============ SECURITY TESTS ============
public function testSecurityHeaders() {
@@ -316,12 +479,12 @@ class APITest {
public function testCorsPolicy() {
$this->section("Security - CORS Policy");
// Test avec Origin non autorisé
// Test avec Origin autorisé
$url = BASE_URL . '/login';
$context = stream_context_create([
'http' => [
'method' => 'OPTIONS',
'header' => "Origin: https://evil.com\r\nAccess-Control-Request-Method: POST",
'header' => "Origin: http://localhost:5173\r\nAccess-Control-Request-Method: POST",
'ignore_errors' => true
]
]);
@@ -329,14 +492,14 @@ class APITest {
@file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];
$hasWildcard = false;
$hasCorrectOrigin = false;
foreach ($headers as $header) {
if (stripos($header, 'Access-Control-Allow-Origin: *') !== false) {
$hasWildcard = true;
if (stripos($header, 'Access-Control-Allow-Origin: http://localhost:5173') !== false) {
$hasCorrectOrigin = true;
}
}
$this->test('CORS does not allow wildcard (*)', !$hasWildcard);
$this->test('CORS allows localhost:5173', $hasCorrectOrigin);
}
public function testDirectoryTraversal() {
@@ -498,6 +661,7 @@ $tests->testCreateScoreWithPieces();
// Files tests
$tests->testFilesErrors();
$tests->testFilesSuccess();
$tests->testWatermarkUpload();
// Security tests
$tests->testSecurityHeaders();