diff --git a/composer.json b/composer.json index 713be7b..364d665 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,8 @@ "symfony/webpack-encore-bundle": "^2.1", "symfony/yaml": "7.0.*", "twig/extra-bundle": "^2.12|^3.0", - "twig/twig": "^2.12|^3.0" + "twig/twig": "^2.12|^3.0", + "vich/uploader-bundle": "^2.7" }, "config": { "allow-plugins": { diff --git a/config/bundles.php b/config/bundles.php index 2aee5e1..e682523 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -22,4 +22,5 @@ return [ Symfony\UX\Turbo\TurboBundle::class => ['all' => true], DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], Symfony\UX\React\ReactBundle::class => ['all' => true], + Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 04a7cef..d4be244 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -6,6 +6,8 @@ api_platform: jsonld: ['application/ld+json'] html: ['text/html'] jsonhal: ['application/hal+json'] + multipart: ['multipart/form-data'] + cbz: ['application/x-cbz'] swagger: api_keys: access_token: @@ -30,6 +32,7 @@ api_platform: - '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Setting/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' + - '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource' patch_formats: json: ['application/merge-patch+json'] diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml new file mode 100644 index 0000000..26c03e0 --- /dev/null +++ b/config/packages/vich_uploader.yaml @@ -0,0 +1,10 @@ +vich_uploader: + db_driver: orm + + mappings: + conversion_uploads: + uri_prefix: /uploads/conversions + upload_destination: '%kernel.project_dir%/public/tmp/conversions' + namer: Vich\UploaderBundle\Naming\UniqidNamer + delete_on_update: true + delete_on_remove: true diff --git a/config/services.yaml b/config/services.yaml index a11740d..dc1f39c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -6,6 +6,8 @@ parameters: cache_adapter: 'cache.adapter.filesystem' +imports: + services: # default configuration for services in *this* file _defaults: @@ -49,10 +51,14 @@ services: arguments: $projectDir: '%kernel.project_dir%' - App\Service\CbrToCbzConverter: + App\Domain\Conversion\Infrastructure\Service\ConversionService: arguments: $projectDir: '%kernel.project_dir%' + App\Service\CbrToCbzConverter: + arguments: + $projectDir: '%kernel.project_dir%' + App\Manager\FileSystemManager: arguments: $projectDir: '%kernel.project_dir%' @@ -73,7 +79,7 @@ services: arguments: $client: '@App\Client\MangadexClient' - # Scrapers + # Scraper Service App\Service\Scraper\HtmlScraper: tags: [ 'app.scraper' ] diff --git a/src/Domain/Conversion/Application/Command/ConvertFileCommand.php b/src/Domain/Conversion/Application/Command/ConvertFileCommand.php new file mode 100644 index 0000000..bdc6d9a --- /dev/null +++ b/src/Domain/Conversion/Application/Command/ConvertFileCommand.php @@ -0,0 +1,12 @@ +validateCommand($command); + + $request = new ConversionRequest( + filePath: $command->filePath, + originalFilename: $command->originalFilename, + fileSize: $command->fileSize + ); + + $result = $this->conversionService->convert($request); + + return ConversionResponse::fromConversionResult($result); + } + + private function validateCommand(ConvertFileCommand $command): void + { + if (!file_exists($command->filePath)) { + throw ConversionException::fileNotFound($command->filePath); + } + + if ($command->fileSize > self::MAX_FILE_SIZE) { + throw ConversionException::fileSizeExceedsLimit($command->fileSize, self::MAX_FILE_SIZE); + } + + $extension = strtolower(pathinfo($command->originalFilename, PATHINFO_EXTENSION)); + $allowedExtensions = ['cbr', 'cbz', 'zip', 'rar']; + + if (!in_array($extension, $allowedExtensions)) { + throw ConversionException::invalidFileFormat($command->originalFilename); + } + } +} diff --git a/src/Domain/Conversion/Application/Response/ConversionResponse.php b/src/Domain/Conversion/Application/Response/ConversionResponse.php new file mode 100644 index 0000000..02077e9 --- /dev/null +++ b/src/Domain/Conversion/Application/Response/ConversionResponse.php @@ -0,0 +1,25 @@ +getConvertedFilePath(), + outputFilename: $result->getOutputFilename(), + originalFileSize: $result->getOriginalFileSize(), + convertedFileSize: $result->getConvertedFileSize() + ); + } +} diff --git a/src/Domain/Conversion/Domain/Contract/ConversionServiceInterface.php b/src/Domain/Conversion/Domain/Contract/ConversionServiceInterface.php new file mode 100644 index 0000000..6933e7b --- /dev/null +++ b/src/Domain/Conversion/Domain/Contract/ConversionServiceInterface.php @@ -0,0 +1,11 @@ +filePath; + } + + public function getOriginalFilename(): string + { + return $this->originalFilename; + } + + public function getFileSize(): int + { + return $this->fileSize; + } + + public function getOutputFilename(): string + { + $pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME); + return $pathInfo . '.cbz'; + } +} diff --git a/src/Domain/Conversion/Domain/Model/ConversionResult.php b/src/Domain/Conversion/Domain/Model/ConversionResult.php new file mode 100644 index 0000000..bbe2573 --- /dev/null +++ b/src/Domain/Conversion/Domain/Model/ConversionResult.php @@ -0,0 +1,33 @@ +convertedFilePath; + } + + public function getOutputFilename(): string + { + return $this->outputFilename; + } + + public function getOriginalFileSize(): int + { + return $this->originalFileSize; + } + + public function getConvertedFileSize(): int + { + return $this->convertedFileSize; + } +} diff --git a/src/Domain/Conversion/Infrastructure/ApiPlatform/Controller/ConvertFileController.php b/src/Domain/Conversion/Infrastructure/ApiPlatform/Controller/ConvertFileController.php new file mode 100644 index 0000000..6ee5edd --- /dev/null +++ b/src/Domain/Conversion/Infrastructure/ApiPlatform/Controller/ConvertFileController.php @@ -0,0 +1,100 @@ +files->get('file'); + if (!$uploadedFile) { + return $this->json([ + ['propertyPath' => 'file', 'message' => 'Please upload a file'] + ], 422); + } + + // Validation manuelle pour éviter les règles automatiques de Symfony + $errors = $this->validateFile($uploadedFile); + if (!empty($errors)) { + return $this->json($errors, 422); + } + + try { + // Créer la commande + $command = new ConvertFileCommand( + filePath: $uploadedFile->getPathname(), + originalFilename: $uploadedFile->getClientOriginalName(), + fileSize: $uploadedFile->getSize() + ); + + // Exécuter la conversion + $response = $this->commandHandler->handle($command); + + // Retourner le fichier converti + $fileContent = file_get_contents($response->convertedFilePath); + + return new Response( + content: $fileContent, + status: 200, + headers: [ + 'Content-Type' => 'application/x-cbz', + 'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename), + ] + ); + + } catch (ConversionException $e) { + return $this->json(['error' => $e->getMessage()], 400); + } + } + + private function validateFile($uploadedFile): array + { + $errors = []; + + // Vérifier si le fichier est valide + if (!$uploadedFile->isValid()) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage() + ]; + return $errors; + } + + // Vérifier la taille (150MB max) + $maxSize = 150 * 1024 * 1024; // 150MB en bytes + if ($uploadedFile->getSize() > $maxSize) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'The uploaded file is too large. Allowed size is 150MB.' + ]; + } + + // Vérifier l'extension + $allowedExtensions = ['cbr', 'cbz', 'zip', 'rar']; + $extension = strtolower($uploadedFile->getClientOriginalExtension()); + + if (!in_array($extension, $allowedExtensions)) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'Please upload a valid CBR or CBZ file' + ]; + } + + return $errors; + } +} diff --git a/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource/ConvertFileResource.php b/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource/ConvertFileResource.php new file mode 100644 index 0000000..8dd5b34 --- /dev/null +++ b/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource/ConvertFileResource.php @@ -0,0 +1,66 @@ + 'Convert comic book file to CBZ', + 'description' => 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download', + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['file'], + 'properties' => [ + 'file' => [ + 'type' => 'string', + 'format' => 'binary', + 'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)' + ] + ] + ] + ] + ] + ], + 'responses' => [ + '200' => [ + 'description' => 'File converted successfully', + 'content' => [ + 'application/x-cbz' => [ + 'schema' => [ + 'type' => 'string', + 'format' => 'binary' + ] + ] + ] + ] + ] + ] + ) + ] +)] +class ConvertFileResource +{ + public ?File $file = null; + + public ?string $fileName = null; + + // Propriétés pour la réponse + public mixed $fileContent = null; + public ?string $filename = null; + public ?string $originalConvertedFilePath = null; +} diff --git a/src/Domain/Conversion/Infrastructure/Service/ConversionService.php b/src/Domain/Conversion/Infrastructure/Service/ConversionService.php new file mode 100644 index 0000000..2609cc8 --- /dev/null +++ b/src/Domain/Conversion/Infrastructure/Service/ConversionService.php @@ -0,0 +1,91 @@ +tempDir = $projectDir . '/public/tmp'; + $this->filesystem = new Filesystem(); + } + + public function convert(ConversionRequest $request): ConversionResult + { + try { + $convertedFilePath = $this->convertCbrToCbz($request->getFilePath()); + + $convertedFileSize = file_exists($convertedFilePath) ? filesize($convertedFilePath) : 0; + + return new ConversionResult( + convertedFilePath: $convertedFilePath, + outputFilename: $request->getOutputFilename(), + originalFileSize: $request->getFileSize(), + convertedFileSize: $convertedFileSize + ); + } catch (\Exception $e) { + throw ConversionException::conversionFailed($e->getMessage()); + } + } + + private function convertCbrToCbz(string $cbrPath): string + { + $tempDir = $this->tempDir . '/' . uniqid('cbr_conversion_'); + $this->filesystem->mkdir($tempDir); + + $extractDir = $tempDir . '/extract'; + $this->filesystem->mkdir($extractDir); + + // Essayer d'extraire avec unrar-free + $process = new Process(['unrar-free', 'x', $cbrPath, $extractDir]); + $process->run(); + + // Si unrar échoue, essayer avec 7z + if (!$process->isSuccessful()) { + $process = new Process(['7z', 'x', $cbrPath, "-o$extractDir"]); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput()); + } + } + + // Créer le CBZ + $cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME) . '.cbz'; + $cbzPath = $this->tempDir . '/' . $cbzFileName; + $zip = new \ZipArchive(); + if ($zip->open($cbzPath, \ZipArchive::CREATE) !== true) { + throw new \RuntimeException("Cannot create ZIP file"); + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($extractDir), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (!$file->isDir()) { + $filePath = $file->getRealPath(); + $relativePath = substr($filePath, strlen($extractDir) + 1); + $zip->addFile($filePath, $relativePath); + } + } + + $zip->close(); + + // Nettoyer le dossier temporaire d'extraction + $this->filesystem->remove($tempDir); + + return $cbzPath; + } +} diff --git a/tests/Feature/Conversion/ConvertFileTest.php b/tests/Feature/Conversion/ConvertFileTest.php new file mode 100644 index 0000000..49301e8 --- /dev/null +++ b/tests/Feature/Conversion/ConvertFileTest.php @@ -0,0 +1,162 @@ +createTestCbrFile(); + + // When - Envoyer la requête avec VichUploader et la syntaxe correcte + $client = static::createClient(); + + $uploadedFile = new UploadedFile( + $tempCbrFile, + 'test-manga.cbr', + 'application/x-cbr', // Type MIME attendu par la validation + null, + true + ); + + $client->request('POST', '/api/conversions/convert', [ + 'headers' => ['Content-Type' => 'multipart/form-data'], + 'extra' => [ + 'files' => [ + 'file' => $uploadedFile, + ], + ] + ]); + + // Then - Vérifier la réponse + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/x-cbz'); + $this->assertResponseHeaderSame('Content-Disposition', 'attachment; filename=test-manga.cbz'); + + // Vérifier que la réponse contient le fichier converti + $content = $client->getResponse()->getContent(); + $this->assertNotEmpty($content); + + // Nettoyer le fichier temporaire + if (file_exists($tempCbrFile)) { + unlink($tempCbrFile); + } + } + + public function testConvertWithoutFileReturnsError(): void + { + // When - Envoyer une requête sans fichier + $client = static::createClient(); + $client->request('POST', '/api/conversions/convert', [ + 'headers' => ['Content-Type' => 'multipart/form-data'], + ]); + + // Then - Vérifier l'erreur de validation + $this->assertResponseStatusCodeSame(422); + } + + public function testConvertWithInvalidFileFormatReturnsError(): void + { + // Given - Créer un fichier non-CBR pour le test + $tempFile = tempnam(sys_get_temp_dir(), 'test_invalid_'); + file_put_contents($tempFile, 'invalid content'); + + $uploadedFile = new UploadedFile( + $tempFile, + 'test.txt', + 'text/plain', + null, + true + ); + + // When - Envoyer la requête avec un fichier invalide + $client = static::createClient(); + $client->request('POST', '/api/conversions/convert', [ + 'headers' => ['Content-Type' => 'multipart/form-data'], + 'extra' => [ + 'files' => [ + 'file' => $uploadedFile, + ], + ] + ]); + + // Then - Vérifier l'erreur de validation + $this->assertResponseStatusCodeSame(422); + + // Nettoyer le fichier temporaire + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + + public function testConvertWithFileTooLargeReturnsError(): void + { + // Given - Créer un fichier trop volumineux (simulation) + $tempLargeFile = tempnam(sys_get_temp_dir(), 'test_large_'); + + // Créer un fichier de plus de 150MB (pour déclencher l'erreur de taille) + // On simule juste en créant un fichier et en modifiant sa taille déclarée + file_put_contents($tempLargeFile, str_repeat('x', 1024 * 1024)); // 1MB de contenu + + // Mock de l'UploadedFile avec une taille déclarée trop importante + $uploadedFile = new UploadedFile( + $tempLargeFile, + 'large-file.cbr', + 'application/x-cbr', // Type MIME attendu par la validation + 150 * 1024 * 1024 + 1 // 150MB + 1 byte + ); + + // When - Envoyer la requête avec un fichier trop volumineux + $client = static::createClient(); + $client->request('POST', '/api/conversions/convert', [ + 'headers' => ['Content-Type' => 'multipart/form-data'], + 'extra' => [ + 'files' => [ + 'file' => $uploadedFile, + ], + ] + ]); + + // Then - Vérifier l'erreur de validation + $this->assertResponseStatusCodeSame(422); + + // Nettoyer le fichier temporaire + if (file_exists($tempLargeFile)) { + unlink($tempLargeFile); + } + } + + private function createTestCbrFile(): string + { + // Créer un fichier CBR temporaire pour les tests + $tempFile = tempnam(sys_get_temp_dir(), 'test_cbr_'); + + // Pour les tests, on peut simplement créer un fichier avec une extension .cbr + // En production, ce serait un vrai fichier RAR + $newPath = $tempFile . '.cbr'; + rename($tempFile, $newPath); + + // Utiliser un fichier CBZ existant comme base et le renommer en CBR pour les tests + $existingCbzFile = __DIR__ . '/../../Fixtures/chapter.cbz'; + if (file_exists($existingCbzFile)) { + copy($existingCbzFile, $newPath); + } else { + // Fallback: créer un fichier ZIP simple avec extension CBR + // Un fichier CBR est essentiellement un fichier RAR, mais pour les tests on peut simuler avec un ZIP + $zip = new \ZipArchive(); + if ($zip->open($newPath, \ZipArchive::CREATE) === TRUE) { + $zip->addFromString('test.txt', 'Test content for CBR simulation'); + $zip->close(); + } + } + + return $newPath; + } +}