diff --git a/.env.example b/.env.example index 12721fce..57bbfc84 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ TRANSMORPHER_DEV_MODE=false TRANSMORPHER_STORE_DERIVATIVES=true TRANSMORPHER_DISK_ORIGINALS=localOriginals TRANSMORPHER_DISK_IMAGE_DERIVATIVES=localImageDerivatives +TRANSMORPHER_DISK_PDF_DERIVATIVES=localPdfDerivatives TRANSMORPHER_DISK_VIDEO_DERIVATIVES=localVideoDerivatives #TRANSMORPHER_SIGNING_KEYPAIR= TRANSMORPHER_OPTIMIZER_TIMEOUT=10 @@ -33,6 +34,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false # AWS S3 AWS_BUCKET_ORIGINALS= AWS_BUCKET_IMAGE_DERIVATIVES= +AWS_BUCKET_PDF_DERIVATIVES= AWS_BUCKET_VIDEO_DERIVATIVES= # AWS CloudFront AWS_CLOUDFRONT_DISTRIBUTION_ID= diff --git a/.env.testing b/.env.testing index abd407a9..e8f24802 100644 --- a/.env.testing +++ b/.env.testing @@ -16,6 +16,7 @@ TRANSMORPHER_DEV_MODE=false TRANSMORPHER_STORE_DERIVATIVES=true TRANSMORPHER_DISK_ORIGINALS=localOriginals TRANSMORPHER_DISK_IMAGE_DERIVATIVES=localImageDerivatives +TRANSMORPHER_DISK_PDF_DERIVATIVES=localPdfDerivatives TRANSMORPHER_DISK_VIDEO_DERIVATIVES=localVideoDerivatives TRANSMORPHER_SIGNING_KEYPAIR=cc063331aa0ba451046a7cf6d2715df28877cf65578aa06e46175c49ac394685781b6895274da76e26cb4e9d045e3c3c4072c2e6f38f8502edaac07a22773ef2781b6895274da76e26cb4e9d045e3c3c4072c2e6f38f8502edaac07a22773ef2 TRANSMORPHER_OPTIMIZER_TIMEOUT=5 @@ -28,6 +29,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false # AWS S3 AWS_BUCKET_ORIGINALS= AWS_BUCKET_IMAGE_DERIVATIVES= +AWS_BUCKET_PDF_DERIVATIVES= AWS_BUCKET_VIDEO_DERIVATIVES= # AWS CloudFront AWS_CLOUDFRONT_DISTRIBUTION_ID= diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index 26e1e9a3..96e11387 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -5,12 +5,15 @@ on: types: [ labeled, unlabeled, synchronize, closed, reopened ] jobs: + # https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts prepare-amigor-env: name: Prepare Amigor .env + if: ${{ contains(github.event.pull_request.labels.*.name, 'pullpreview') || github.event.action == 'unlabeled' || github.event.action == 'closed' }} runs-on: ubuntu-latest steps: - name: Checkout Transmorpher repo + if: ${{ contains(github.event.pull_request.labels.*.name, 'pullpreview') && github.event.action != 'unlabeled' && github.event.action != 'closed' }} # https://github.com/actions/checkout uses: actions/checkout@v4 with: @@ -18,13 +21,18 @@ jobs: .env.amigor # https://git-scm.com/docs/git-sparse-checkout#_internalscone_mode_handling sparse-checkout-cone-mode: false - - run: echo "TRANSMORPHER_AUTH_TOKEN=\"${{ secrets.PULLPREVIEW_SANCTUM_AUTH_TOKEN }}\"" >> .env.amigor + + - name: Write secrets in .env file + if: ${{ contains(github.event.pull_request.labels.*.name, 'pullpreview') && github.event.action != 'unlabeled' && github.event.action != 'closed' }} + run: echo "TRANSMORPHER_AUTH_TOKEN=\"${{ secrets.PULLPREVIEW_SANCTUM_AUTH_TOKEN }}\"" >> .env.amigor - name: Upload Amigor .env file + if: ${{ contains(github.event.pull_request.labels.*.name, 'pullpreview') && github.event.action != 'unlabeled' && github.event.action != 'closed' }} # https://github.com/actions/upload-artifact uses: actions/upload-artifact@v4 with: name: amigor-env + include-hidden-files: true path: | .env.amigor diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f99ea360..8479b826 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,5 +23,5 @@ jobs: PHP_VERSION: ${{ matrix.php }} LARAVEL_VERSION: ${{ matrix.laravel }} DEPENDENCY_VERSION: ${{ matrix.dependency-version }} - MYSQL_DATABASE: transmorpher_test + DATABASE_NAME: transmorpher_test LINUX_PACKAGES: imagemagick jpegoptim optipng pngquant gifsicle webp ffmpeg diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index b1537554..83c7d307 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -47,6 +47,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -65,6 +66,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -123,6 +125,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -141,6 +144,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -199,6 +203,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -217,6 +222,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -275,6 +281,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -293,6 +300,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -351,6 +359,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -369,6 +378,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -427,6 +437,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -445,6 +456,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -503,6 +515,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -521,6 +534,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -579,6 +593,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -597,6 +612,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -655,6 +671,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -673,6 +690,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -731,6 +749,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -749,6 +768,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -807,6 +827,7 @@ 'db.factory' => \Illuminate\Database\Connectors\ConnectionFactory::class, 'db.schema' => \Illuminate\Database\Schema\MySqlBuilder::class, 'db.transactions' => \Illuminate\Database\DatabaseTransactionsManager::class, + 'delivery' => \App\Classes\Delivery::class, 'encrypter' => \Illuminate\Encryption\Encrypter::class, 'events' => \Illuminate\Events\Dispatcher::class, 'files' => \Illuminate\Filesystem\Filesystem::class, @@ -825,6 +846,7 @@ 'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class, 'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class, 'migrator' => \Illuminate\Database\Migrations\Migrator::class, + 'optimize' => \App\Classes\Optimizer\Optimize::class, 'pipeline' => \Illuminate\Pipeline\Pipeline::class, 'protector' => \Cybex\Protector\Protector::class, 'queue' => \Illuminate\Queue\QueueManager::class, @@ -930,6 +952,8 @@ 'app.aliases.View' => 'string', 'app.aliases.Vite' => 'string', 'app.aliases.CdnHelper' => 'string', + 'app.aliases.Delivery' => 'string', + 'app.aliases.Optimize' => 'string', 'app.aliases.Transcode' => 'string', 'app.aliases.Transform' => 'string', 'auth.defaults.guard' => 'string', @@ -1118,6 +1142,9 @@ 'filesystems.disks.localImageDerivatives.driver' => 'string', 'filesystems.disks.localImageDerivatives.root' => 'string', 'filesystems.disks.localImageDerivatives.throw' => 'boolean', + 'filesystems.disks.localPdfDerivatives.driver' => 'string', + 'filesystems.disks.localPdfDerivatives.root' => 'string', + 'filesystems.disks.localPdfDerivatives.throw' => 'boolean', 'filesystems.disks.localVideoDerivatives.driver' => 'string', 'filesystems.disks.localVideoDerivatives.root' => 'string', 'filesystems.disks.localVideoDerivatives.url' => 'string', @@ -1143,6 +1170,16 @@ 'filesystems.disks.s3ImageDerivatives.endpoint' => 'NULL', 'filesystems.disks.s3ImageDerivatives.use_path_style_endpoint' => 'boolean', 'filesystems.disks.s3ImageDerivatives.throw' => 'boolean', + 'filesystems.disks.s3PdfDerivatives.driver' => 'string', + 'filesystems.disks.s3PdfDerivatives.root' => 'string', + 'filesystems.disks.s3PdfDerivatives.key' => 'string', + 'filesystems.disks.s3PdfDerivatives.secret' => 'string', + 'filesystems.disks.s3PdfDerivatives.region' => 'string', + 'filesystems.disks.s3PdfDerivatives.bucket' => 'string', + 'filesystems.disks.s3PdfDerivatives.url' => 'NULL', + 'filesystems.disks.s3PdfDerivatives.endpoint' => 'NULL', + 'filesystems.disks.s3PdfDerivatives.use_path_style_endpoint' => 'boolean', + 'filesystems.disks.s3PdfDerivatives.throw' => 'boolean', 'filesystems.disks.s3VideoDerivatives.driver' => 'string', 'filesystems.disks.s3VideoDerivatives.root' => 'string', 'filesystems.disks.s3VideoDerivatives.key' => 'string', @@ -1335,12 +1372,14 @@ 'transmorpher.store_derivatives' => 'boolean', 'transmorpher.disks.originals' => 'string', 'transmorpher.disks.imageDerivatives' => 'string', + 'transmorpher.disks.pdfDerivatives' => 'string', 'transmorpher.disks.videoDerivatives' => 'string', 'transmorpher.transform_class' => 'string', 'transmorpher.convert_classes.jpg' => 'string', 'transmorpher.convert_classes.png' => 'string', 'transmorpher.convert_classes.gif' => 'string', 'transmorpher.convert_classes.webp' => 'string', + 'transmorpher.pdf_default_image_format' => 'string', 'transmorpher.transcode_class' => 'string', 'transmorpher.video_codec' => 'string', 'transmorpher.representations' => 'string', @@ -1352,6 +1391,7 @@ 'transmorpher.aws.cloudfront_distribution_id' => 'string', 'transmorpher.signing_keypair' => 'string', 'transmorpher.media_handlers.image' => 'string', + 'transmorpher.media_handlers.pdf' => 'string', 'transmorpher.media_handlers.video' => 'string', 'transmorpher.cache_invalidation_counter_file_path' => 'string', 'view.paths' => 'string', @@ -1477,6 +1517,8 @@ 'app.aliases.View' => 'string', 'app.aliases.Vite' => 'string', 'app.aliases.CdnHelper' => 'string', + 'app.aliases.Delivery' => 'string', + 'app.aliases.Optimize' => 'string', 'app.aliases.Transcode' => 'string', 'app.aliases.Transform' => 'string', 'auth.defaults.guard' => 'string', @@ -1665,6 +1707,9 @@ 'filesystems.disks.localImageDerivatives.driver' => 'string', 'filesystems.disks.localImageDerivatives.root' => 'string', 'filesystems.disks.localImageDerivatives.throw' => 'boolean', + 'filesystems.disks.localPdfDerivatives.driver' => 'string', + 'filesystems.disks.localPdfDerivatives.root' => 'string', + 'filesystems.disks.localPdfDerivatives.throw' => 'boolean', 'filesystems.disks.localVideoDerivatives.driver' => 'string', 'filesystems.disks.localVideoDerivatives.root' => 'string', 'filesystems.disks.localVideoDerivatives.url' => 'string', @@ -1690,6 +1735,16 @@ 'filesystems.disks.s3ImageDerivatives.endpoint' => 'NULL', 'filesystems.disks.s3ImageDerivatives.use_path_style_endpoint' => 'boolean', 'filesystems.disks.s3ImageDerivatives.throw' => 'boolean', + 'filesystems.disks.s3PdfDerivatives.driver' => 'string', + 'filesystems.disks.s3PdfDerivatives.root' => 'string', + 'filesystems.disks.s3PdfDerivatives.key' => 'string', + 'filesystems.disks.s3PdfDerivatives.secret' => 'string', + 'filesystems.disks.s3PdfDerivatives.region' => 'string', + 'filesystems.disks.s3PdfDerivatives.bucket' => 'string', + 'filesystems.disks.s3PdfDerivatives.url' => 'NULL', + 'filesystems.disks.s3PdfDerivatives.endpoint' => 'NULL', + 'filesystems.disks.s3PdfDerivatives.use_path_style_endpoint' => 'boolean', + 'filesystems.disks.s3PdfDerivatives.throw' => 'boolean', 'filesystems.disks.s3VideoDerivatives.driver' => 'string', 'filesystems.disks.s3VideoDerivatives.root' => 'string', 'filesystems.disks.s3VideoDerivatives.key' => 'string', @@ -1882,12 +1937,14 @@ 'transmorpher.store_derivatives' => 'boolean', 'transmorpher.disks.originals' => 'string', 'transmorpher.disks.imageDerivatives' => 'string', + 'transmorpher.disks.pdfDerivatives' => 'string', 'transmorpher.disks.videoDerivatives' => 'string', 'transmorpher.transform_class' => 'string', 'transmorpher.convert_classes.jpg' => 'string', 'transmorpher.convert_classes.png' => 'string', 'transmorpher.convert_classes.gif' => 'string', 'transmorpher.convert_classes.webp' => 'string', + 'transmorpher.pdf_default_image_format' => 'string', 'transmorpher.transcode_class' => 'string', 'transmorpher.video_codec' => 'string', 'transmorpher.representations' => 'string', @@ -1899,6 +1956,7 @@ 'transmorpher.aws.cloudfront_distribution_id' => 'string', 'transmorpher.signing_keypair' => 'string', 'transmorpher.media_handlers.image' => 'string', + 'transmorpher.media_handlers.pdf' => 'string', 'transmorpher.media_handlers.video' => 'string', 'transmorpher.cache_invalidation_counter_file_path' => 'string', 'view.paths' => 'string', @@ -2024,6 +2082,8 @@ 'app.aliases.View' => 'string', 'app.aliases.Vite' => 'string', 'app.aliases.CdnHelper' => 'string', + 'app.aliases.Delivery' => 'string', + 'app.aliases.Optimize' => 'string', 'app.aliases.Transcode' => 'string', 'app.aliases.Transform' => 'string', 'auth.defaults.guard' => 'string', @@ -2212,6 +2272,9 @@ 'filesystems.disks.localImageDerivatives.driver' => 'string', 'filesystems.disks.localImageDerivatives.root' => 'string', 'filesystems.disks.localImageDerivatives.throw' => 'boolean', + 'filesystems.disks.localPdfDerivatives.driver' => 'string', + 'filesystems.disks.localPdfDerivatives.root' => 'string', + 'filesystems.disks.localPdfDerivatives.throw' => 'boolean', 'filesystems.disks.localVideoDerivatives.driver' => 'string', 'filesystems.disks.localVideoDerivatives.root' => 'string', 'filesystems.disks.localVideoDerivatives.url' => 'string', @@ -2237,6 +2300,16 @@ 'filesystems.disks.s3ImageDerivatives.endpoint' => 'NULL', 'filesystems.disks.s3ImageDerivatives.use_path_style_endpoint' => 'boolean', 'filesystems.disks.s3ImageDerivatives.throw' => 'boolean', + 'filesystems.disks.s3PdfDerivatives.driver' => 'string', + 'filesystems.disks.s3PdfDerivatives.root' => 'string', + 'filesystems.disks.s3PdfDerivatives.key' => 'string', + 'filesystems.disks.s3PdfDerivatives.secret' => 'string', + 'filesystems.disks.s3PdfDerivatives.region' => 'string', + 'filesystems.disks.s3PdfDerivatives.bucket' => 'string', + 'filesystems.disks.s3PdfDerivatives.url' => 'NULL', + 'filesystems.disks.s3PdfDerivatives.endpoint' => 'NULL', + 'filesystems.disks.s3PdfDerivatives.use_path_style_endpoint' => 'boolean', + 'filesystems.disks.s3PdfDerivatives.throw' => 'boolean', 'filesystems.disks.s3VideoDerivatives.driver' => 'string', 'filesystems.disks.s3VideoDerivatives.root' => 'string', 'filesystems.disks.s3VideoDerivatives.key' => 'string', @@ -2429,12 +2502,14 @@ 'transmorpher.store_derivatives' => 'boolean', 'transmorpher.disks.originals' => 'string', 'transmorpher.disks.imageDerivatives' => 'string', + 'transmorpher.disks.pdfDerivatives' => 'string', 'transmorpher.disks.videoDerivatives' => 'string', 'transmorpher.transform_class' => 'string', 'transmorpher.convert_classes.jpg' => 'string', 'transmorpher.convert_classes.png' => 'string', 'transmorpher.convert_classes.gif' => 'string', 'transmorpher.convert_classes.webp' => 'string', + 'transmorpher.pdf_default_image_format' => 'string', 'transmorpher.transcode_class' => 'string', 'transmorpher.video_codec' => 'string', 'transmorpher.representations' => 'string', @@ -2446,6 +2521,7 @@ 'transmorpher.aws.cloudfront_distribution_id' => 'string', 'transmorpher.signing_keypair' => 'string', 'transmorpher.media_handlers.image' => 'string', + 'transmorpher.media_handlers.pdf' => 'string', 'transmorpher.media_handlers.video' => 'string', 'transmorpher.cache_invalidation_counter_file_path' => 'string', 'view.paths' => 'string', @@ -2559,49 +2635,52 @@ 'app.aliases.Queue','app.aliases.RateLimiter','app.aliases.Redirect','app.aliases.Request','app.aliases.Response', 'app.aliases.Route','app.aliases.Schedule','app.aliases.Schema','app.aliases.Session','app.aliases.Storage', 'app.aliases.Str','app.aliases.URL','app.aliases.Uri','app.aliases.Validator','app.aliases.View', -'app.aliases.Vite','app.aliases.CdnHelper','app.aliases.Transcode','app.aliases.Transform','auth.defaults.guard', -'auth.defaults.passwords','auth.guards.web.driver','auth.guards.web.provider','auth.guards.sanctum.driver','auth.guards.sanctum.provider', -'auth.providers.users.driver','auth.providers.users.model','auth.passwords.users.provider','auth.passwords.users.table','auth.passwords.users.expire', -'auth.passwords.users.throttle','auth.password_timeout','broadcasting.default','broadcasting.connections.reverb.driver','broadcasting.connections.reverb.key', -'broadcasting.connections.reverb.secret','broadcasting.connections.reverb.app_id','broadcasting.connections.reverb.options.host','broadcasting.connections.reverb.options.port','broadcasting.connections.reverb.options.scheme', -'broadcasting.connections.reverb.options.useTLS','broadcasting.connections.reverb.client_options','broadcasting.connections.pusher.driver','broadcasting.connections.pusher.key','broadcasting.connections.pusher.secret', -'broadcasting.connections.pusher.app_id','broadcasting.connections.pusher.options.cluster','broadcasting.connections.pusher.options.host','broadcasting.connections.pusher.options.port','broadcasting.connections.pusher.options.scheme', -'broadcasting.connections.pusher.options.encrypted','broadcasting.connections.pusher.options.useTLS','broadcasting.connections.pusher.client_options','broadcasting.connections.ably.driver','broadcasting.connections.ably.key', -'broadcasting.connections.log.driver','broadcasting.connections.null.driver','broadcasting.connections.redis.driver','broadcasting.connections.redis.connection','cache.default', -'cache.stores.array.driver','cache.stores.array.serialize','cache.stores.database.driver','cache.stores.database.table','cache.stores.database.connection', -'cache.stores.database.lock_connection','cache.stores.file.driver','cache.stores.file.path','cache.stores.file.lock_path','cache.stores.memcached.driver', -'cache.stores.memcached.persistent_id','cache.stores.memcached.sasl','cache.stores.memcached.options','cache.stores.memcached.servers.0.host','cache.stores.memcached.servers.0.port', -'cache.stores.memcached.servers.0.weight','cache.stores.redis.driver','cache.stores.redis.connection','cache.stores.redis.lock_connection','cache.stores.dynamodb.driver', -'cache.stores.dynamodb.key','cache.stores.dynamodb.secret','cache.stores.dynamodb.region','cache.stores.dynamodb.table','cache.stores.dynamodb.endpoint', -'cache.stores.octane.driver','cache.stores.apc.driver','cache.prefix','chunk-upload.storage.chunks','chunk-upload.storage.disk', -'chunk-upload.clear.timestamp','chunk-upload.clear.schedule.enabled','chunk-upload.clear.schedule.cron','chunk-upload.chunk.name.use.session','chunk-upload.chunk.name.use.browser', -'chunk-upload.handlers.custom','chunk-upload.handlers.override','cors.paths','cors.allowed_methods','cors.allowed_origins', -'cors.allowed_origins_patterns','cors.allowed_headers','cors.exposed_headers','cors.max_age','cors.supports_credentials', -'database.default','database.connections.sqlite.driver','database.connections.sqlite.url','database.connections.sqlite.database','database.connections.sqlite.prefix', -'database.connections.sqlite.foreign_key_constraints','database.connections.mysql.driver','database.connections.mysql.url','database.connections.mysql.host','database.connections.mysql.port', -'database.connections.mysql.database','database.connections.mysql.username','database.connections.mysql.password','database.connections.mysql.unix_socket','database.connections.mysql.charset', -'database.connections.mysql.collation','database.connections.mysql.prefix','database.connections.mysql.prefix_indexes','database.connections.mysql.strict','database.connections.mysql.engine', -'database.connections.mysql.options','database.connections.mariadb.driver','database.connections.mariadb.url','database.connections.mariadb.host','database.connections.mariadb.port', -'database.connections.mariadb.database','database.connections.mariadb.username','database.connections.mariadb.password','database.connections.mariadb.unix_socket','database.connections.mariadb.charset', -'database.connections.mariadb.collation','database.connections.mariadb.prefix','database.connections.mariadb.prefix_indexes','database.connections.mariadb.strict','database.connections.mariadb.engine', -'database.connections.mariadb.options','database.connections.pgsql.driver','database.connections.pgsql.url','database.connections.pgsql.host','database.connections.pgsql.port', -'database.connections.pgsql.database','database.connections.pgsql.username','database.connections.pgsql.password','database.connections.pgsql.charset','database.connections.pgsql.prefix', -'database.connections.pgsql.prefix_indexes','database.connections.pgsql.search_path','database.connections.pgsql.sslmode','database.connections.sqlsrv.driver','database.connections.sqlsrv.url', -'database.connections.sqlsrv.host','database.connections.sqlsrv.port','database.connections.sqlsrv.database','database.connections.sqlsrv.username','database.connections.sqlsrv.password', -'database.connections.sqlsrv.charset','database.connections.sqlsrv.prefix','database.connections.sqlsrv.prefix_indexes','database.migrations.table','database.migrations.update_date_on_publish', -'database.redis.client','database.redis.options.cluster','database.redis.options.prefix','database.redis.default.url','database.redis.default.host', -'database.redis.default.username','database.redis.default.password','database.redis.default.port','database.redis.default.database','database.redis.cache.url', -'database.redis.cache.host','database.redis.cache.username','database.redis.cache.password','database.redis.cache.port','database.redis.cache.database', -'filesystems.default','filesystems.disks.local.driver','filesystems.disks.local.root','filesystems.disks.local.throw','filesystems.disks.public.driver', -'filesystems.disks.public.root','filesystems.disks.public.url','filesystems.disks.public.visibility','filesystems.disks.public.throw','filesystems.disks.s3.driver', -'filesystems.disks.s3.key','filesystems.disks.s3.secret','filesystems.disks.s3.region','filesystems.disks.s3.bucket','filesystems.disks.s3.url', -'filesystems.disks.s3.endpoint','filesystems.disks.s3.use_path_style_endpoint','filesystems.disks.s3.throw','filesystems.disks.s3.report','filesystems.disks.localOriginals.driver', -'filesystems.disks.localOriginals.root','filesystems.disks.localOriginals.throw','filesystems.disks.localImageDerivatives.driver','filesystems.disks.localImageDerivatives.root','filesystems.disks.localImageDerivatives.throw', +'app.aliases.Vite','app.aliases.CdnHelper','app.aliases.Delivery','app.aliases.Optimize','app.aliases.Transcode', +'app.aliases.Transform','auth.defaults.guard','auth.defaults.passwords','auth.guards.web.driver','auth.guards.web.provider', +'auth.guards.sanctum.driver','auth.guards.sanctum.provider','auth.providers.users.driver','auth.providers.users.model','auth.passwords.users.provider', +'auth.passwords.users.table','auth.passwords.users.expire','auth.passwords.users.throttle','auth.password_timeout','broadcasting.default', +'broadcasting.connections.reverb.driver','broadcasting.connections.reverb.key','broadcasting.connections.reverb.secret','broadcasting.connections.reverb.app_id','broadcasting.connections.reverb.options.host', +'broadcasting.connections.reverb.options.port','broadcasting.connections.reverb.options.scheme','broadcasting.connections.reverb.options.useTLS','broadcasting.connections.reverb.client_options','broadcasting.connections.pusher.driver', +'broadcasting.connections.pusher.key','broadcasting.connections.pusher.secret','broadcasting.connections.pusher.app_id','broadcasting.connections.pusher.options.cluster','broadcasting.connections.pusher.options.host', +'broadcasting.connections.pusher.options.port','broadcasting.connections.pusher.options.scheme','broadcasting.connections.pusher.options.encrypted','broadcasting.connections.pusher.options.useTLS','broadcasting.connections.pusher.client_options', +'broadcasting.connections.ably.driver','broadcasting.connections.ably.key','broadcasting.connections.log.driver','broadcasting.connections.null.driver','broadcasting.connections.redis.driver', +'broadcasting.connections.redis.connection','cache.default','cache.stores.array.driver','cache.stores.array.serialize','cache.stores.database.driver', +'cache.stores.database.table','cache.stores.database.connection','cache.stores.database.lock_connection','cache.stores.file.driver','cache.stores.file.path', +'cache.stores.file.lock_path','cache.stores.memcached.driver','cache.stores.memcached.persistent_id','cache.stores.memcached.sasl','cache.stores.memcached.options', +'cache.stores.memcached.servers.0.host','cache.stores.memcached.servers.0.port','cache.stores.memcached.servers.0.weight','cache.stores.redis.driver','cache.stores.redis.connection', +'cache.stores.redis.lock_connection','cache.stores.dynamodb.driver','cache.stores.dynamodb.key','cache.stores.dynamodb.secret','cache.stores.dynamodb.region', +'cache.stores.dynamodb.table','cache.stores.dynamodb.endpoint','cache.stores.octane.driver','cache.stores.apc.driver','cache.prefix', +'chunk-upload.storage.chunks','chunk-upload.storage.disk','chunk-upload.clear.timestamp','chunk-upload.clear.schedule.enabled','chunk-upload.clear.schedule.cron', +'chunk-upload.chunk.name.use.session','chunk-upload.chunk.name.use.browser','chunk-upload.handlers.custom','chunk-upload.handlers.override','cors.paths', +'cors.allowed_methods','cors.allowed_origins','cors.allowed_origins_patterns','cors.allowed_headers','cors.exposed_headers', +'cors.max_age','cors.supports_credentials','database.default','database.connections.sqlite.driver','database.connections.sqlite.url', +'database.connections.sqlite.database','database.connections.sqlite.prefix','database.connections.sqlite.foreign_key_constraints','database.connections.mysql.driver','database.connections.mysql.url', +'database.connections.mysql.host','database.connections.mysql.port','database.connections.mysql.database','database.connections.mysql.username','database.connections.mysql.password', +'database.connections.mysql.unix_socket','database.connections.mysql.charset','database.connections.mysql.collation','database.connections.mysql.prefix','database.connections.mysql.prefix_indexes', +'database.connections.mysql.strict','database.connections.mysql.engine','database.connections.mysql.options','database.connections.mariadb.driver','database.connections.mariadb.url', +'database.connections.mariadb.host','database.connections.mariadb.port','database.connections.mariadb.database','database.connections.mariadb.username','database.connections.mariadb.password', +'database.connections.mariadb.unix_socket','database.connections.mariadb.charset','database.connections.mariadb.collation','database.connections.mariadb.prefix','database.connections.mariadb.prefix_indexes', +'database.connections.mariadb.strict','database.connections.mariadb.engine','database.connections.mariadb.options','database.connections.pgsql.driver','database.connections.pgsql.url', +'database.connections.pgsql.host','database.connections.pgsql.port','database.connections.pgsql.database','database.connections.pgsql.username','database.connections.pgsql.password', +'database.connections.pgsql.charset','database.connections.pgsql.prefix','database.connections.pgsql.prefix_indexes','database.connections.pgsql.search_path','database.connections.pgsql.sslmode', +'database.connections.sqlsrv.driver','database.connections.sqlsrv.url','database.connections.sqlsrv.host','database.connections.sqlsrv.port','database.connections.sqlsrv.database', +'database.connections.sqlsrv.username','database.connections.sqlsrv.password','database.connections.sqlsrv.charset','database.connections.sqlsrv.prefix','database.connections.sqlsrv.prefix_indexes', +'database.migrations.table','database.migrations.update_date_on_publish','database.redis.client','database.redis.options.cluster','database.redis.options.prefix', +'database.redis.default.url','database.redis.default.host','database.redis.default.username','database.redis.default.password','database.redis.default.port', +'database.redis.default.database','database.redis.cache.url','database.redis.cache.host','database.redis.cache.username','database.redis.cache.password', +'database.redis.cache.port','database.redis.cache.database','filesystems.default','filesystems.disks.local.driver','filesystems.disks.local.root', +'filesystems.disks.local.throw','filesystems.disks.public.driver','filesystems.disks.public.root','filesystems.disks.public.url','filesystems.disks.public.visibility', +'filesystems.disks.public.throw','filesystems.disks.s3.driver','filesystems.disks.s3.key','filesystems.disks.s3.secret','filesystems.disks.s3.region', +'filesystems.disks.s3.bucket','filesystems.disks.s3.url','filesystems.disks.s3.endpoint','filesystems.disks.s3.use_path_style_endpoint','filesystems.disks.s3.throw', +'filesystems.disks.s3.report','filesystems.disks.localOriginals.driver','filesystems.disks.localOriginals.root','filesystems.disks.localOriginals.throw','filesystems.disks.localImageDerivatives.driver', +'filesystems.disks.localImageDerivatives.root','filesystems.disks.localImageDerivatives.throw','filesystems.disks.localPdfDerivatives.driver','filesystems.disks.localPdfDerivatives.root','filesystems.disks.localPdfDerivatives.throw', 'filesystems.disks.localVideoDerivatives.driver','filesystems.disks.localVideoDerivatives.root','filesystems.disks.localVideoDerivatives.url','filesystems.disks.localVideoDerivatives.visibility','filesystems.disks.localVideoDerivatives.throw', 'filesystems.disks.s3Originals.driver','filesystems.disks.s3Originals.root','filesystems.disks.s3Originals.key','filesystems.disks.s3Originals.secret','filesystems.disks.s3Originals.region', 'filesystems.disks.s3Originals.bucket','filesystems.disks.s3Originals.url','filesystems.disks.s3Originals.endpoint','filesystems.disks.s3Originals.use_path_style_endpoint','filesystems.disks.s3Originals.throw', 'filesystems.disks.s3ImageDerivatives.driver','filesystems.disks.s3ImageDerivatives.root','filesystems.disks.s3ImageDerivatives.key','filesystems.disks.s3ImageDerivatives.secret','filesystems.disks.s3ImageDerivatives.region', 'filesystems.disks.s3ImageDerivatives.bucket','filesystems.disks.s3ImageDerivatives.url','filesystems.disks.s3ImageDerivatives.endpoint','filesystems.disks.s3ImageDerivatives.use_path_style_endpoint','filesystems.disks.s3ImageDerivatives.throw', +'filesystems.disks.s3PdfDerivatives.driver','filesystems.disks.s3PdfDerivatives.root','filesystems.disks.s3PdfDerivatives.key','filesystems.disks.s3PdfDerivatives.secret','filesystems.disks.s3PdfDerivatives.region', +'filesystems.disks.s3PdfDerivatives.bucket','filesystems.disks.s3PdfDerivatives.url','filesystems.disks.s3PdfDerivatives.endpoint','filesystems.disks.s3PdfDerivatives.use_path_style_endpoint','filesystems.disks.s3PdfDerivatives.throw', 'filesystems.disks.s3VideoDerivatives.driver','filesystems.disks.s3VideoDerivatives.root','filesystems.disks.s3VideoDerivatives.key','filesystems.disks.s3VideoDerivatives.secret','filesystems.disks.s3VideoDerivatives.region', 'filesystems.disks.s3VideoDerivatives.bucket','filesystems.disks.s3VideoDerivatives.url','filesystems.disks.s3VideoDerivatives.endpoint','filesystems.disks.s3VideoDerivatives.use_path_style_endpoint','filesystems.disks.s3VideoDerivatives.throw', 'filesystems.links./var/www/html/public/storage','filesystems.links./var/www/html/public/videos','hashing.driver','hashing.bcrypt.rounds','hashing.bcrypt.verify', @@ -2640,30 +2719,33 @@ 'session.encrypt','session.files','session.connection','session.table','session.store', 'session.lottery','session.cookie','session.path','session.domain','session.secure', 'session.http_only','session.same_site','session.partitioned','transmorpher.dev_mode','transmorpher.store_derivatives', -'transmorpher.disks.originals','transmorpher.disks.imageDerivatives','transmorpher.disks.videoDerivatives','transmorpher.transform_class','transmorpher.convert_classes.jpg', -'transmorpher.convert_classes.png','transmorpher.convert_classes.gif','transmorpher.convert_classes.webp','transmorpher.transcode_class','transmorpher.video_codec', -'transmorpher.representations','transmorpher.additional_transcoding_parameters','transmorpher.cdn_helper','transmorpher.aws.key','transmorpher.aws.secret', -'transmorpher.aws.region','transmorpher.aws.cloudfront_distribution_id','transmorpher.signing_keypair','transmorpher.media_handlers.image','transmorpher.media_handlers.video', -'transmorpher.cache_invalidation_counter_file_path','view.paths','view.compiled','concurrency.default','flare.key', -'flare.flare_middleware','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddLogs.maximum_number_of_collected_logs','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddQueries.maximum_number_of_collected_queries','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddQueries.report_query_bindings','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddJobs.max_chained_job_reporting_depth', -'flare.flare_middleware.Spatie\\FlareClient\\FlareMiddleware\\CensorRequestBodyFields.censor_fields','flare.flare_middleware.Spatie\\FlareClient\\FlareMiddleware\\CensorRequestHeaders.headers','flare.send_logs_as_events','ignition.editor','ignition.theme', -'ignition.enable_share_button','ignition.register_commands','ignition.solution_providers','ignition.ignored_solution_providers','ignition.enable_runnable_solutions', -'ignition.remote_sites_path','ignition.local_sites_path','ignition.housekeeping_endpoint_prefix','ignition.settings_file_path','ignition.recorders', -'ignition.open_ai_key','ignition.with_stack_frame_arguments','ignition.argument_reducers','ide-helper.filename','ide-helper.models_filename', -'ide-helper.meta_filename','ide-helper.include_fluent','ide-helper.include_factory_builders','ide-helper.write_model_magic_where','ide-helper.write_model_external_builder_methods', -'ide-helper.write_model_relation_count_properties','ide-helper.write_eloquent_model_mixins','ide-helper.include_helpers','ide-helper.helper_files','ide-helper.model_locations', -'ide-helper.ignored_models','ide-helper.model_hooks','ide-helper.extra.Eloquent','ide-helper.extra.Session','ide-helper.magic', -'ide-helper.interfaces','ide-helper.model_camel_case_properties','ide-helper.type_overrides.integer','ide-helper.type_overrides.boolean','ide-helper.include_class_docblocks', -'ide-helper.force_fqn','ide-helper.use_generics_annotations','ide-helper.additional_relation_types','ide-helper.additional_relation_return_types','ide-helper.enforce_nullable_relationships', -'ide-helper.post_migrate','ide-helper.macroable_traits','tinker.commands','tinker.alias','tinker.dont_alias',); +'transmorpher.disks.originals','transmorpher.disks.imageDerivatives','transmorpher.disks.pdfDerivatives','transmorpher.disks.videoDerivatives','transmorpher.transform_class', +'transmorpher.convert_classes.jpg','transmorpher.convert_classes.png','transmorpher.convert_classes.gif','transmorpher.convert_classes.webp','transmorpher.pdf_default_image_format', +'transmorpher.transcode_class','transmorpher.video_codec','transmorpher.representations','transmorpher.additional_transcoding_parameters','transmorpher.cdn_helper', +'transmorpher.aws.key','transmorpher.aws.secret','transmorpher.aws.region','transmorpher.aws.cloudfront_distribution_id','transmorpher.signing_keypair', +'transmorpher.media_handlers.image','transmorpher.media_handlers.pdf','transmorpher.media_handlers.video','transmorpher.cache_invalidation_counter_file_path','view.paths', +'view.compiled','concurrency.default','flare.key','flare.flare_middleware','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddLogs.maximum_number_of_collected_logs', +'flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddQueries.maximum_number_of_collected_queries','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddQueries.report_query_bindings','flare.flare_middleware.Spatie\\LaravelIgnition\\FlareMiddleware\\AddJobs.max_chained_job_reporting_depth','flare.flare_middleware.Spatie\\FlareClient\\FlareMiddleware\\CensorRequestBodyFields.censor_fields','flare.flare_middleware.Spatie\\FlareClient\\FlareMiddleware\\CensorRequestHeaders.headers', +'flare.send_logs_as_events','ignition.editor','ignition.theme','ignition.enable_share_button','ignition.register_commands', +'ignition.solution_providers','ignition.ignored_solution_providers','ignition.enable_runnable_solutions','ignition.remote_sites_path','ignition.local_sites_path', +'ignition.housekeeping_endpoint_prefix','ignition.settings_file_path','ignition.recorders','ignition.open_ai_key','ignition.with_stack_frame_arguments', +'ignition.argument_reducers','ide-helper.filename','ide-helper.models_filename','ide-helper.meta_filename','ide-helper.include_fluent', +'ide-helper.include_factory_builders','ide-helper.write_model_magic_where','ide-helper.write_model_external_builder_methods','ide-helper.write_model_relation_count_properties','ide-helper.write_eloquent_model_mixins', +'ide-helper.include_helpers','ide-helper.helper_files','ide-helper.model_locations','ide-helper.ignored_models','ide-helper.model_hooks', +'ide-helper.extra.Eloquent','ide-helper.extra.Session','ide-helper.magic','ide-helper.interfaces','ide-helper.model_camel_case_properties', +'ide-helper.type_overrides.integer','ide-helper.type_overrides.boolean','ide-helper.include_class_docblocks','ide-helper.force_fqn','ide-helper.use_generics_annotations', +'ide-helper.additional_relation_types','ide-helper.additional_relation_return_types','ide-helper.enforce_nullable_relationships','ide-helper.post_migrate','ide-helper.macroable_traits', +'tinker.commands','tinker.alias','tinker.dont_alias',); registerArgumentsSet('middleware', 'web','api','auth','auth.basic','auth.session', 'cache.headers','can','guest','password.confirm','precognitive', 'signed','throttle','verified',); registerArgumentsSet('routes', 'protectorDumpEndpointRoute','sanctum.csrf-cookie','ignition.healthCheck','ignition.executeSolution','ignition.updateConfig', -'v1.getVersions','v1.delete','v1.setVersion','v1.getOriginal','v1.getDerivativeForVersion', -'v1.reserveImageUploadSlot','v1.reserveVideoUploadSlot','v1.upload','v1.getPublicKey','v1.getCacheInvalidator','getDerivative',); +'v1.getVersions','v1.delete','v1.setVersion','v1.getImageOriginal','v1.getImageDerivativeForVersion', +'v1.reserveImageUploadSlot','v1.getPdfOriginal','v1.getPdfDerivativeForVersion','v1.reservePdfUploadSlot','v1.reserveVideoUploadSlot', +'v1.upload','v1.getPublicKey','v1.getCacheInvalidator','getImageDerivative', +'getPdfDerivative',); registerArgumentsSet('views', 'errors.400','welcome','laravel-exceptions-renderer::components.card','laravel-exceptions-renderer::components.context','laravel-exceptions-renderer::components.editor', 'laravel-exceptions-renderer::components.header','laravel-exceptions-renderer::components.icons.chevron-down','laravel-exceptions-renderer::components.icons.chevron-up','laravel-exceptions-renderer::components.icons.computer-desktop','laravel-exceptions-renderer::components.icons.moon', @@ -2703,10 +2785,11 @@ 'validation.starts_with','validation.string','validation.timezone','validation.unique','validation.uploaded', 'validation.uppercase','validation.url','validation.ulid','validation.uuid','validation.custom.attribute-name.rule-name', 'new-version-notice.subject','new-version-notice.title','new-version-notice.new_api_version_released','new-version-notice.update_client_implementations','new-version-notice.check_out_on_github', -'responses.cdn_invalidation_failed','responses.deletion_successful','responses.image_upload_successful','responses.image_version_set','responses.transcoding_aborted', -'responses.transcoding_failed','responses.transcoding_job_dispatch_failed','responses.transcoding_successful','responses.upload_slot_created','responses.versions_retrieved', -'responses.video_version_set','responses.video_upload_successful','responses.write_failed','responses.file_name_invalid','responses.file_name_invalid_only_spaces', -'responses.non_matching_identifier','version-deprecation-notice.subject','version-deprecation-notice.title','version-deprecation-notice.version_soon_deprecated','version-deprecation-notice.update_client_implementations',); +'responses.cdn_invalidation_failed','responses.deletion_successful','responses.image_upload_successful','responses.image_version_set','responses.pdf_upload_successful', +'responses.pdf_version_set','responses.transcoding_aborted','responses.transcoding_failed','responses.transcoding_job_dispatch_failed','responses.transcoding_successful', +'responses.upload_slot_created','responses.versions_retrieved','responses.video_version_set','responses.video_upload_successful','responses.write_failed', +'responses.file_name_invalid','responses.file_name_invalid_only_spaces','responses.non_matching_identifier','version-deprecation-notice.subject','version-deprecation-notice.title', +'version-deprecation-notice.version_soon_deprecated','version-deprecation-notice.update_client_implementations',); registerArgumentsSet('env', 'APP_NAME','APP_ENV','APP_KEY','APP_DEBUG','APP_URL', 'APP_SERVICE','DOCKER_CONTAINER_NAME','TRANSMORPHER_DEV_MODE','TRANSMORPHER_STORE_DERIVATIVES','TRANSMORPHER_DISK_ORIGINALS', diff --git a/README.md b/README.md index 69c87190..1915cfbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Transmorpher Media Server -A media server for images and videos. +A media server for images, pdfs and videos. > For a client implementation for Laravel > see [Laravel Transmorpher Client](https://github.com/cybex-gmbh/laravel-transmorpher-client). @@ -12,6 +12,9 @@ A media server for images and videos. - [Intervention Image](https://github.com/Intervention/image) - [Laravel Image Optimizer](https://github.com/spatie/laravel-image-optimizer) +#### PDF metadata removal +- [PDF Merge](https://github.com/karriereat/pdf-merge) + #### Video transcoding - [PHP-FFmpeg-video-streaming](https://github.com/hadronepoch/PHP-FFmpeg-video-streaming) @@ -185,6 +188,7 @@ To use AWS S3 disks set the according `.env` values: ```dotenv TRANSMORPHER_DISK_ORIGINALS=s3Originals TRANSMORPHER_DISK_IMAGE_DERIVATIVES=s3ImageDerivatives +TRANSMORPHER_DISK_PDF_DERIVATIVES=s3PdfDerivatives TRANSMORPHER_DISK_VIDEO_DERIVATIVES=s3VideoDerivatives ``` @@ -193,6 +197,7 @@ Define the AWS S3 bucket for each disk: ```dotenv AWS_BUCKET_ORIGINALS= AWS_BUCKET_IMAGE_DERIVATIVES= +AWS_BUCKET_PDF_DERIVATIVES= AWS_BUCKET_VIDEO_DERIVATIVES= ``` @@ -261,6 +266,7 @@ Select the following Laravel disks in the `.env`: ```dotenv TRANSMORPHER_DISK_ORIGINALS=localOriginals TRANSMORPHER_DISK_IMAGE_DERIVATIVES=localImageDerivatives +TRANSMORPHER_DISK_PDF_DERIVATIVES=localPdfDerivatives TRANSMORPHER_DISK_VIDEO_DERIVATIVES=localVideoDerivatives ``` @@ -339,7 +345,7 @@ The media server provides the following features for media: - set version - delete -> Marked with * only applies to images. +> Marked with * does not apply to videos. ## Image transformation @@ -369,6 +375,22 @@ For example: The [Laravel Transmorpher Client](https://github.com/cybex-gmbh/laravel-transmorpher-client) will receive this information and store it. It can also create URLs with transformations. +## PDF handling + +Requesting a PDF file will return the document with removed metadata. +When transformations are specified, an image of a page will be returned. + +All image transformations mentioned in the above section also apply to PDF image derivatives. +Requesting a PDF also follows the same URL structure as images, just replace `images` with `pdfs`. + +Additionally, the page which should be used as image can be specified with the `p` transformation. Otherwise, the first page will be used. + +For example: + +PDF document: `https://transmorpher.test/pdfs/catworld/cat-essay` + +Image of page 5: `https://transmorpher.test/pdfs/catworld/cat-essay/p-5+w-1920+h-1080` + ## Video transcoding Video transcoding is handled as an asynchronous task. The client will receive the @@ -529,8 +551,8 @@ We provide a command which will additionally notify clients with a signed reques php artisan purge:derivatives ``` -The command accepts the options `--image`, `--video` and `--all` (or `-a`) for purging the respective derivatives. -Image derivatives will be deleted, for video derivatives we dispatch a new transcoding job for the current version. +The command accepts the options `--image`, `--pdf`, `--video` and `--all` (or `-a`) for purging the respective derivatives. +Image and PDF derivatives will be deleted, for video derivatives we dispatch a new transcoding job for the current version. The derivatives revision is available on the route `/api/v*/cacheInvalidator`. @@ -542,6 +564,7 @@ To restore operation of the server, restore the following: - the `originals` disk - `.env` file* - the `image derivatives` disk* +- the `pdf derivatives` disk* - the `video derivatives` disk* > Marked with * are optional, but recommended. @@ -550,7 +573,7 @@ If the `.env` file is lost follow the setup instructions above, including creati If video derivatives are lost, use the [purge command](#purging-derivatives) to restore them. -Lost image derivatives will automatically be re-generated on demand. +Lost image and pdf derivatives will automatically be re-generated on demand. ## Development @@ -569,7 +592,7 @@ App-specific GitHub Secrets: #### Companion App -A demonstration app, which implements the client package, is booted with PullPreview and available at the PullPreview root URL. The Transmorpher media server runs under `/transmorpherServer`. +A demonstration app, which implements the client package, is booted with PullPreview and available at the PullPreview root URL. The Transmorpher media server runs as `transmorpher.` subdomain. #### Auth Token Hash diff --git a/_ide_helper.php b/_ide_helper.php index 6b7519ee..1e6d16e1 100644 --- a/_ide_helper.php +++ b/_ide_helper.php @@ -22391,6 +22391,77 @@ public static function isConfigured() * * */ + class DeliveryFacade { + /** + * Retrieve an original for a version. + * + * @param \App\Models\Version $version + * @return \Illuminate\Contracts\Foundation\Application|\Response|\Illuminate\Contracts\Routing\ResponseFactory + * @static + */ + public static function getOriginal($version) + { + /** @var \App\Classes\Delivery $instance */ + return $instance->getOriginal($version); + } + + /** + * + * + * @param string $transformations + * @param \App\Models\Version $version + * @param \App\Enums\MediaType $mediaType + * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Response + * @static + */ + public static function getDerivative($transformations, $version, $mediaType) + { + /** @var \App\Classes\Delivery $instance */ + return $instance->getDerivative($transformations, $version, $mediaType); + } + + } + /** + * + * + */ + class OptimizeFacade { + /** + * Optimize an image derivative. + * + * Creates a temporary file since image optimizers only work locally. + * + * @param string $derivative + * @param int|null $quality + * @return string + * @throws Exception + * @static + */ + public static function optimize($derivative, $quality = null) + { + /** @var \App\Classes\Optimizer\Optimize $instance */ + return $instance->optimize($derivative, $quality); + } + + /** + * + * + * @param string $derivative + * @return string + * @throws Exception + * @static + */ + public static function removePdfMetadata($derivative) + { + /** @var \App\Classes\Optimizer\Optimize $instance */ + return $instance->removePdfMetadata($derivative); + } + + } + /** + * + * + */ class TranscodeFacade { /** * Returns the class which handles the actual transcoding. @@ -22462,7 +22533,6 @@ class TransformFacade { * @param string $pathToOriginalImage * @param array|null $transformations * @return string Binary string of the image. - * @throws FileNotFoundException * @static */ public static function transform($pathToOriginalImage, $transformations = null) @@ -22471,18 +22541,6 @@ public static function transform($pathToOriginalImage, $transformations = null) return $instance->transform($pathToOriginalImage, $transformations); } - /** - * - * - * @return string[] - * @static - */ - public static function getSupportedFormats() - { - /** @var \App\Classes\Intervention\Transform $instance */ - return $instance->getSupportedFormats(); - } - } } @@ -28252,6 +28310,8 @@ class Validator extends \Illuminate\Support\Facades\Validator {} class View extends \Illuminate\Support\Facades\View {} class Vite extends \Illuminate\Support\Facades\Vite {} class CdnHelper extends \App\Facades\CdnHelperFacade {} + class Delivery extends \App\Facades\DeliveryFacade {} + class Optimize extends \App\Facades\OptimizeFacade {} class Transcode extends \App\Facades\TranscodeFacade {} class Transform extends \App\Facades\TransformFacade {} class Protector extends \Cybex\Protector\ProtectorFacade {} diff --git a/app/Classes/Delivery.php b/app/Classes/Delivery.php new file mode 100644 index 00000000..abadbc62 --- /dev/null +++ b/app/Classes/Delivery.php @@ -0,0 +1,63 @@ +getDisk(); + $pathToOriginal = $version->originalFilePath(); + + return response($originalsDisk->get($pathToOriginal), 200, ['Content-Type' => mime_content_type($originalsDisk->readStream($pathToOriginal))]); + } + + /** + * @param string $transformations + * @param Version $version + * @param MediaType $mediaType + * @return Application|ResponseFactory|Response + */ + public function getDerivative(string $transformations, Version $version, MediaType $mediaType): ResponseFactory|Application|Response + { + try { + $transformationsArray = Transformation::arrayFromString($transformations); + } catch (TransformationNotFoundException|InvalidTransformationValueException|InvalidTransformationFormatException $exception) { + abort(400, $exception->getMessage()); + } + + $derivativesDisk = $mediaType->handler()->getDerivativesDisk(); + $derivativePath = $version->nonVideoDerivativeFilePath($transformationsArray); + + // Check if derivative already exists and return if so. + if (!config('transmorpher.dev_mode') && config('transmorpher.store_derivatives') && $derivativesDisk->exists($derivativePath)) { + $derivative = $derivativesDisk->get($derivativePath); + } else { + // Apply transformations to the media. + $derivative = $mediaType->handler()->applyTransformations($version, $transformationsArray); + + if (config('transmorpher.store_derivatives')) { + $derivativesDisk->put($derivativePath, $derivative); + } + } + + return response($derivative, 200, ['Content-Type' => mime_content_type($derivativesDisk->readStream($derivativePath))]); + } +} diff --git a/app/Classes/Intervention/Transform.php b/app/Classes/Intervention/Transform.php index 2ce848ce..9448a174 100644 --- a/app/Classes/Intervention/Transform.php +++ b/app/Classes/Intervention/Transform.php @@ -5,8 +5,11 @@ use App\Enums\ImageFormat; use App\Enums\MediaStorage; use App\Enums\Transformation; +use App\Exceptions\PdfPageDoesNotExistException; use App\Interfaces\TransformInterface; use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Imagick; +use ImagickException; use Intervention\Image\Encoders\AutoEncoder; use Intervention\Image\Laravel\Facades\Image as ImageManager; @@ -19,18 +22,72 @@ class Transform implements TransformInterface * @param array|null $transformations * * @return string Binary string of the image. + */ + public function transform(string $pathToOriginalImage, ?array $transformations = null): string + { + $fileHandle = $this->getOriginalFileStream($pathToOriginalImage); + $mimeType = mime_content_type($fileHandle); + $fileData = stream_get_contents($fileHandle); + + return match ($mimeType) { + 'application/pdf' => $this->pdfToImage($fileData, $transformations), + default => $this->applyTransformations($fileData, $transformations), + }; + } + + /** + * @param string $fileData + * @param array|null $transformations + * @return string + * @throws PdfPageDoesNotExistException + */ + protected function pdfToImage(string $fileData, ?array $transformations = null): string + { + // We need a local file for Imagick to be able to access only the requested page. + $tempFile = tempnam(sys_get_temp_dir(), 'transmorpher'); + file_put_contents($tempFile, $fileData); + + try { + $imagick = new Imagick(); + + // 300 DPI, might make this a transformation + $imagick->setResolution(300, 300); + $imagick->readImage(sprintf('%s[%d]', $tempFile, ($transformations[Transformation::PAGE->value] ?? 1) - 1)); + } catch (ImagickException) { + // Assuming an error happened because the requested page does not exist, we throw a custom exception. + throw new PdfPageDoesNotExistException($transformations[Transformation::PAGE->value]); + } finally { + unlink($tempFile); + } + + $imagick->setImageFormat(ImageFormat::from(config('transmorpher.pdf_default_image_format'))->value); + + return $this->applyTransformations($imagick->getImageBlob(), $transformations); + } + + /** + * @param string $path + * @return resource|null * @throws FileNotFoundException */ - public function transform(string $pathToOriginalImage, array $transformations = null): string + protected function getOriginalFileStream(string $path) { $disk = MediaStorage::ORIGINALS->getDisk(); - if (!$disk->exists($pathToOriginalImage)) { - throw new FileNotFoundException(sprintf('File not found at path "%s" on configured disk', $pathToOriginalImage)); + if (!$disk->exists($path)) { + throw new FileNotFoundException(sprintf('File not found at path "%s" on configured disk', $path)); } - $imageData = $disk->get($pathToOriginalImage); + return $disk->readStream($path); + } + /** + * @param string $imageData + * @param array|null $transformations + * @return string + */ + protected function applyTransformations(string $imageData, ?array $transformations = null): string + { if (!$transformations) { return $imageData; } @@ -40,7 +97,7 @@ public function transform(string $pathToOriginalImage, array $transformations = $width = $transformations[Transformation::WIDTH->value] ?? $image->width(); $height = $transformations[Transformation::HEIGHT->value] ?? $image->height(); $format = $transformations[Transformation::FORMAT->value] ?? null; - $quality = $transformations[Transformation::QUALITY->value] ?? null; + $quality = intval($transformations[Transformation::QUALITY->value] ?? null) ?: null; $image = $image->scaleDown($width, $height); @@ -48,14 +105,6 @@ public function transform(string $pathToOriginalImage, array $transformations = return ImageFormat::from($format)->getConverter()->encode($image, $format, $quality)->getBinary(); } - return $image->encode(new AutoEncoder(quality: $quality))->toString(); - } - - /** - * @return string[] - */ - public function getSupportedFormats(): array - { - return ImageFormat::getFormats(); + return $image->encode(new AutoEncoder(quality: $quality ?? 100))->toString(); } } diff --git a/app/Classes/MediaHandler/ImageHandler.php b/app/Classes/MediaHandler/ImageHandler.php index d71ba05f..811311a6 100644 --- a/app/Classes/MediaHandler/ImageHandler.php +++ b/app/Classes/MediaHandler/ImageHandler.php @@ -6,43 +6,19 @@ use App\Enums\MediaStorage; use App\Enums\MediaType; use App\Enums\ResponseState; -use App\Interfaces\MediaHandlerInterface; -use App\Models\Media; -use App\Models\UploadSlot; -use App\Models\User; +use App\Enums\Transformation; use App\Models\Version; -use CdnHelper; -use Illuminate\Contracts\Filesystem\Filesystem; -use Throwable; +use Optimize; +use Transform; -class ImageHandler implements MediaHandlerInterface +class ImageHandler extends StaticMediaHandler { - /** - * @param string $basePath - * @param UploadSlot $uploadSlot - * @param Version $version - * - * @return ResponseState - */ - public function handleSavedFile(string $basePath, UploadSlot $uploadSlot, Version $version): ResponseState - { - if ($this->invalidateCdnCache($basePath)) { - /** - * This prevents CDN cache pollution. - * - * Explanation: - * 1. new version is uploaded - * 2. media is requested and new version is delivered - * 3. cache invalidation fails, version gets deleted - * 4. now nonexistent version is still in the CDN cache - */ - $version->update(['processed' => true]); - - return ResponseState::IMAGE_UPLOAD_SUCCESSFUL; - } - - return ResponseState::CDN_INVALIDATION_FAILED; - } + protected MediaType $type = MediaType::IMAGE; + protected MediaStorage $derivativesStorage = MediaStorage::IMAGE_DERIVATIVES; + protected ResponseState $uploadSuccessful = ResponseState::IMAGE_UPLOAD_SUCCESSFUL; + protected ResponseState $uploadFailed = ResponseState::CDN_INVALIDATION_FAILED; + protected ResponseState $versionSetSuccessful = ResponseState::IMAGE_VERSION_SET; + protected ResponseState $versionSetFailed = ResponseState::CDN_INVALIDATION_FAILED; /** * @return string @@ -53,83 +29,14 @@ public function getValidationRules(): string } /** - * @param string $basePath - * @return bool - */ - public function invalidateCdnCache(string $basePath): bool - { - if (CdnHelper::isConfigured()) { - try { - CdnHelper::invalidateMedia(MediaType::IMAGE, $basePath); - } catch (Throwable) { - return false; - } - } - - return true; - } - - /** - * @param User $user * @param Version $version - * @param int $oldVersionNumber - * @param bool $wasProcessed - * @return array - */ - public function setVersion(User $user, Version $version, int $oldVersionNumber, bool $wasProcessed): array - { - // Token and valid_until will be set in the 'saving' event. - // By creating an upload slot, a currently active upload will be canceled. - $uploadSlot = $user->UploadSlots()->withoutGlobalScopes()->updateOrCreate(['identifier' => $version->Media->identifier], ['media_type' => MediaType::IMAGE]); - - if ($this->invalidateCdnCache($version->Media->baseDirectory())) { - $version->update(['processed' => true]); - $responseState = ResponseState::IMAGE_VERSION_SET; - } else { - $version->update(['number' => $oldVersionNumber]); - $responseState = ResponseState::CDN_INVALIDATION_FAILED; - } - - return [ - $responseState, - $uploadSlot->token - ]; - } - - /** - * @return Filesystem - */ - public function getDerivativesDisk(): Filesystem - { - return MediaStorage::IMAGE_DERIVATIVES->getDisk(); - } - - /** - * @param Media $media - * @return array - */ - public function getVersions(Media $media): array - { - $processedVersions = $media->Versions()->where('processed', true)->get(); - $currentVersionNumber = $processedVersions->max('number'); - - return [ - 'currentVersion' => $currentVersionNumber, - 'currentlyProcessedVersion' => $currentVersionNumber, - 'versions' => $processedVersions->pluck('created_at', 'number')->map(fn($date) => strtotime($date)), - ]; - } - - /** - * @return array + * @param array|null $transformationsArray + * @return false|string */ - public function purgeDerivatives(): array + public function applyTransformations(Version $version, ?array $transformationsArray): false|string { - $success = $this->getDerivativesDisk()->deleteDirectory(''); + $derivative = Transform::transform($version->originalFilePath(), $transformationsArray); - return [ - 'success' => $success, - 'message' => $success ? 'Deleted image derivatives.' : 'Failed to delete image derivatives.', - ]; + return Optimize::optimize($derivative, $transformationsArray[Transformation::QUALITY->value] ?? null); } } diff --git a/app/Classes/MediaHandler/MediaHandler.php b/app/Classes/MediaHandler/MediaHandler.php new file mode 100644 index 00000000..816d9bb4 --- /dev/null +++ b/app/Classes/MediaHandler/MediaHandler.php @@ -0,0 +1,46 @@ +derivativesStorage->getDisk(); + } + + /** + * @param string $basePath + * @return bool + */ + public function invalidateCdnCache(string $basePath): bool + { + if (CdnHelper::isConfigured()) { + try { + CdnHelper::invalidateMedia($this->type, $basePath); + } catch (Throwable) { + return false; + } + } + + return true; + } +} diff --git a/app/Classes/MediaHandler/PdfHandler.php b/app/Classes/MediaHandler/PdfHandler.php new file mode 100644 index 00000000..a15149a2 --- /dev/null +++ b/app/Classes/MediaHandler/PdfHandler.php @@ -0,0 +1,50 @@ +originalFilePath(), $transformationsArray); + } catch (PdfPageDoesNotExistException $exception) { + abort(400, $exception->getMessage()); + } + + return Optimize::optimize($derivative, $transformationsArray[Transformation::QUALITY->value] ?? null); + } + + return Optimize::removePdfMetadata(MediaStorage::ORIGINALS->getDisk()->get($version->originalFilePath())); + } +} diff --git a/app/Classes/MediaHandler/StaticMediaHandler.php b/app/Classes/MediaHandler/StaticMediaHandler.php new file mode 100644 index 00000000..a8df4147 --- /dev/null +++ b/app/Classes/MediaHandler/StaticMediaHandler.php @@ -0,0 +1,107 @@ +invalidateCdnCache($basePath)) { + /** + * This prevents CDN cache pollution. + * + * Explanation: + * 1. new version is uploaded + * 2. media is requested and new version is delivered + * 3. cache invalidation fails, version gets deleted + * 4. now nonexistent version is still in the CDN cache + */ + $version->update(['processed' => true]); + + return $this->uploadSuccessful; + } + + return $this->uploadFailed; + } + + /** + * @param string $basePath + * @return bool + */ + public function invalidateCdnCache(string $basePath): bool + { + if (CdnHelper::isConfigured()) { + try { + CdnHelper::invalidateMedia($this->type, $basePath); + } catch (Throwable) { + return false; + } + } + + return true; + } + + /** + * @param User $user + * @param Version $version + * @param int $oldVersionNumber + * @param bool $wasProcessed + * @return array + */ + public function setVersion(User $user, Version $version, int $oldVersionNumber, bool $wasProcessed): array + { + // Token and valid_until will be set in the 'saving' event. + // By creating an upload slot, a currently active upload will be canceled. + $uploadSlot = $user->UploadSlots()->withoutGlobalScopes()->updateOrCreate(['identifier' => $version->Media->identifier], ['media_type' => $this->type]); + + if ($this->invalidateCdnCache($version->Media->baseDirectory())) { + $version->update(['processed' => true]); + $responseState = $this->versionSetSuccessful; + } else { + $version->update(['number' => $oldVersionNumber]); + $responseState = $this->versionSetFailed; + } + + return [ + $responseState, + $uploadSlot->token + ]; + } + + /** + * @param Media $media + * @return array + */ + public function getVersions(Media $media): array + { + $processedVersions = $media->Versions()->where('processed', true)->get(); + $currentVersionNumber = $processedVersions->max('number'); + + return [ + 'currentVersion' => $currentVersionNumber, + 'currentlyProcessedVersion' => $currentVersionNumber, + 'versions' => $processedVersions->pluck('created_at', 'number')->map(fn($date) => strtotime($date)), + ]; + } + + /** + * @return array + */ + public function purgeDerivatives(): array + { + $success = $this->getDerivativesDisk()->deleteDirectory(''); + + return [ + 'success' => $success, + 'message' => $success ? sprintf('Deleted %s derivatives.', $this->type->value) : sprintf('Failed to delete %s derivatives.', $this->type->value), + ]; + } +} diff --git a/app/Classes/MediaHandler/VideoHandler.php b/app/Classes/MediaHandler/VideoHandler.php index 81a54341..5b712f40 100644 --- a/app/Classes/MediaHandler/VideoHandler.php +++ b/app/Classes/MediaHandler/VideoHandler.php @@ -6,18 +6,22 @@ use App\Enums\MediaType; use App\Enums\ResponseState; use App\Enums\UploadState; -use App\Interfaces\MediaHandlerInterface; use App\Models\Media; use App\Models\UploadSlot; use App\Models\User; use App\Models\Version; -use CdnHelper; -use Illuminate\Contracts\Filesystem\Filesystem; -use Throwable; +use BadMethodCallException; use Transcode; -class VideoHandler implements MediaHandlerInterface +class VideoHandler extends MediaHandler { + protected MediaType $type = MediaType::VIDEO; + protected MediaStorage $derivativesStorage = MediaStorage::VIDEO_DERIVATIVES; + protected ResponseState $uploadSuccessful = ResponseState::VIDEO_UPLOAD_SUCCESSFUL; + protected ResponseState $uploadFailed = ResponseState::TRANSCODING_JOB_DISPATCH_FAILED; + protected ResponseState $versionSetSuccessful = ResponseState::VIDEO_VERSION_SET; + protected ResponseState $versionSetFailed = ResponseState::TRANSCODING_JOB_DISPATCH_FAILED; + /** * @param string $basePath * @param UploadSlot $uploadSlot @@ -31,7 +35,7 @@ public function handleSavedFile(string $basePath, UploadSlot $uploadSlot, Versio $success = Transcode::createJob($version, $uploadSlot); \Log::info(sprintf('Transcoding job dispatched with result %s for media %s and version %s.', $success, $version->Media->identifier, $version->getKey())); - return $success ? ResponseState::VIDEO_UPLOAD_SUCCESSFUL : ResponseState::TRANSCODING_JOB_DISPATCH_FAILED; + return $success ? $this->uploadSuccessful : $this->uploadFailed; } /** @@ -42,23 +46,6 @@ public function getValidationRules(): string return 'mimetypes:video/x-msvideo,video/mpeg,video/ogg,video/webm,video/mp4,video/x-matroska'; } - /** - * @param string $basePath - * @return bool - */ - public function invalidateCdnCache(string $basePath): bool - { - if (CdnHelper::isConfigured()) { - try { - CdnHelper::invalidateMedia(MediaType::VIDEO, $basePath); - } catch (Throwable) { - return false; - } - } - - return true; - } - /** * @param User $user * @param Version $version @@ -73,7 +60,7 @@ public function setVersion(User $user, Version $version, int $oldVersionNumber, $uploadSlot = $user->UploadSlots()->withoutGlobalScopes()->updateOrCreate(['identifier' => $version->Media->identifier], ['media_type' => MediaType::VIDEO]); $success = Transcode::createJobForVersionUpdate($version, $uploadSlot, $oldVersionNumber, $wasProcessed); - $responseState = $success ? ResponseState::VIDEO_VERSION_SET : ResponseState::TRANSCODING_JOB_DISPATCH_FAILED; + $responseState = $success ? $this->versionSetSuccessful : $this->versionSetFailed; return [ $responseState, @@ -81,14 +68,6 @@ public function setVersion(User $user, Version $version, int $oldVersionNumber, ]; } - /** - * @return Filesystem - */ - public function getDerivativesDisk(): Filesystem - { - return MediaStorage::VIDEO_DERIVATIVES->getDisk(); - } - /** * @param Media $media * @return array @@ -131,4 +110,14 @@ public function purgeDerivatives(): array 'message' => $success ? 'Restored versions for all video media.' : sprintf('Failed to restore versions for media ids: %s.', implode(', ', $failedMediaIds)), ]; } + + /** + * @param Version $version + * @param array|null $transformationsArray + * @return false|string + */ + public function applyTransformations(Version $version, ?array $transformationsArray): false|string + { + throw new BadMethodCallException('Not yet applicable for this media type.'); + } } diff --git a/app/Classes/Optimizer/Optimize.php b/app/Classes/Optimizer/Optimize.php new file mode 100644 index 00000000..af38e9d2 --- /dev/null +++ b/app/Classes/Optimizer/Optimize.php @@ -0,0 +1,71 @@ +getTemporaryFile($derivative); + + // Optimizes the image based on optimizers configured in 'config/image-optimizer.php'. + ImageFormat::fromMimeType(mime_content_type($tempFile))->getOptimizer()->optimize($tempFile, $quality); + + $derivative = file_get_contents($tempFile); + + if ($derivative === false) { + unlink($tempFile); + + throw new Exception('Failed to read the optimized image.'); + } + + return $derivative; + } + + /** + * @param string $derivative + * @return string + * @throws Exception + */ + public function removePdfMetadata(string $derivative): string + { + $tempFile = $this->getTemporaryFile($derivative); + $pdfMerge = new PdfMerge(); + + try { + $pdfMerge->add($tempFile); + $pdfData = $pdfMerge->merge('', 'S'); + } catch (Exception $exception) { + unlink($tempFile); + + throw $exception; + } + + return $pdfData; + } + + /** + * @param string $derivative + * @return false|string + */ + protected function getTemporaryFile(string $derivative): string|false + { + $tempFile = tempnam(sys_get_temp_dir(), 'transmorpher'); + file_put_contents($tempFile, $derivative); + + return $tempFile; + } +} diff --git a/app/Console/Commands/PurgeDerivatives.php b/app/Console/Commands/PurgeDerivatives.php index 300660bd..8b0e8aae 100644 --- a/app/Console/Commands/PurgeDerivatives.php +++ b/app/Console/Commands/PurgeDerivatives.php @@ -17,6 +17,7 @@ class PurgeDerivatives extends Command */ protected $signature = 'purge:derivatives {--image : Delete image derivatives.} + {--pdf : Delete PDF derivatives.} {--video : Re-generate video derivatives.} {--a|all : Purge all derivatives.}'; @@ -32,7 +33,8 @@ class PurgeDerivatives extends Command */ public function handle(): int { - if (!$this->option('image') && !$this->option('video') && !$this->option('all')) { + + if (!$this->option('image') && !$this->option('pdf') && !$this->option('video') && !$this->option('all')) { $this->warn(sprintf('No options provided. Call "php artisan %s --help" for a list of all options.', $this->name)); return Command::SUCCESS; } diff --git a/app/Enums/MediaStorage.php b/app/Enums/MediaStorage.php index eb309766..129df05d 100644 --- a/app/Enums/MediaStorage.php +++ b/app/Enums/MediaStorage.php @@ -9,6 +9,7 @@ enum MediaStorage: string { case ORIGINALS = 'originals'; case IMAGE_DERIVATIVES = 'imageDerivatives'; + case PDF_DERIVATIVES = 'pdfDerivatives'; case VIDEO_DERIVATIVES = 'videoDerivatives'; /** diff --git a/app/Enums/MediaType.php b/app/Enums/MediaType.php index 59897fa5..fea2b894 100644 --- a/app/Enums/MediaType.php +++ b/app/Enums/MediaType.php @@ -3,11 +3,13 @@ namespace App\Enums; use App\Interfaces\MediaHandlerInterface; +use Exception; enum MediaType: string { case IMAGE = 'image'; case VIDEO = 'video'; + case PDF = 'pdf'; /** * @return MediaHandlerInterface @@ -26,7 +28,8 @@ public function prefix(): string { return match ($this) { self::IMAGE => 'images', - self::VIDEO => 'videos' + self::VIDEO => 'videos', + self::PDF => 'pdfs' }; } @@ -38,21 +41,54 @@ public function prefix(): string public function isInstantlyAvailable(): bool { return match ($this) { - self::IMAGE => true, + self::IMAGE, + self::PDF => true, self::VIDEO => false }; } /** - * Get whether this media needs a short invalidation path for the CDN. + * Get whether this media needs exhaustive invalidation for the CDN: + * - , + * - /, + * - /* * * @return bool */ - public function needsShortPathInvalidation(): bool + public function needsExhaustiveCdnInvalidation(): bool { return match ($this) { - self::IMAGE => true, + self::IMAGE, + self::PDF => true, self::VIDEO => false }; } + + /** + * Get whether this media type uses its original file extension for derivatives if no explicit format is specified. + * + * @throws Exception + */ + public function usesOriginalFileExtension(): bool + { + return match ($this) { + self::IMAGE => true, + self::PDF => false, + default => throw new Exception('Not available for this media type'), + }; + } + + /** + * Get the default extension for this media type, if applicable. + * + * @throws Exception + */ + public function getDefaultExtension(array $transformations = null): string + { + return match ($this) { + self::PDF => $transformations ? config('transmorpher.pdf_default_image_format') : 'pdf', + default => throw new Exception('Not available for this media type'), + }; + } } + diff --git a/app/Enums/ResponseState.php b/app/Enums/ResponseState.php index a24bc14f..aa3435eb 100644 --- a/app/Enums/ResponseState.php +++ b/app/Enums/ResponseState.php @@ -8,6 +8,8 @@ enum ResponseState: string case DELETION_SUCCESSFUL = 'deletion_successful'; case IMAGE_UPLOAD_SUCCESSFUL = 'image_upload_successful'; case IMAGE_VERSION_SET = 'image_version_set'; + case PDF_UPLOAD_SUCCESSFUL = 'pdf_upload_successful'; + case PDF_VERSION_SET = 'pdf_version_set'; case TRANSCODING_ABORTED = 'transcoding_aborted'; case TRANSCODING_FAILED = 'transcoding_failed'; case TRANSCODING_JOB_DISPATCH_FAILED = 'transcoding_job_dispatch_failed'; @@ -25,14 +27,16 @@ enum ResponseState: string public function getState(): UploadState { return match ($this) { - ResponseState::DELETION_SUCCESSFUL => UploadState::DELETED, - ResponseState::IMAGE_UPLOAD_SUCCESSFUL, - ResponseState::TRANSCODING_SUCCESSFUL, - ResponseState::VERSIONS_RETRIEVED, - ResponseState::IMAGE_VERSION_SET => UploadState::SUCCESS, - ResponseState::UPLOAD_SLOT_CREATED => UploadState::INITIALIZING, - ResponseState::VIDEO_UPLOAD_SUCCESSFUL, - ResponseState::VIDEO_VERSION_SET => UploadState::PROCESSING, + self::DELETION_SUCCESSFUL => UploadState::DELETED, + self::IMAGE_UPLOAD_SUCCESSFUL, + self::IMAGE_VERSION_SET, + self::PDF_UPLOAD_SUCCESSFUL, + self::PDF_VERSION_SET, + self::TRANSCODING_SUCCESSFUL, + self::VERSIONS_RETRIEVED => UploadState::SUCCESS, + self::UPLOAD_SLOT_CREATED => UploadState::INITIALIZING, + self::VIDEO_UPLOAD_SUCCESSFUL, + self::VIDEO_VERSION_SET => UploadState::PROCESSING, default => UploadState::ERROR, }; } diff --git a/app/Enums/Transformation.php b/app/Enums/Transformation.php index c3ca2273..700fdcd4 100644 --- a/app/Enums/Transformation.php +++ b/app/Enums/Transformation.php @@ -13,6 +13,7 @@ enum Transformation: string case WIDTH = 'w'; case HEIGHT = 'h'; case FORMAT = 'f'; + case PAGE = 'p'; case QUALITY = 'q'; /** @@ -24,9 +25,10 @@ public function validate(string|int $value): string|int { $valid = match ($this) { self::WIDTH, - self::HEIGHT => filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]), + self::HEIGHT, + self::PAGE => filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]), self::FORMAT => in_array($value, ImageFormat::getFormats(), true), - self::QUALITY => filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 100]]) + self::QUALITY => filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 100]]), }; if (!$valid) { diff --git a/app/Exceptions/PdfPageDoesNotExistException.php b/app/Exceptions/PdfPageDoesNotExistException.php new file mode 100644 index 00000000..a140b588 --- /dev/null +++ b/app/Exceptions/PdfPageDoesNotExistException.php @@ -0,0 +1,19 @@ +needsShortPathInvalidation()) { + if ($type->needsExhaustiveCdnInvalidation()) { $prefixedPath = implode(DIRECTORY_SEPARATOR, array_filter([$type->prefix(), $invalidationPath])); $this->invalidate([ diff --git a/app/Http/Controllers/V1/ImageController.php b/app/Http/Controllers/V1/ImageController.php index 69f5ba4b..5ba13808 100644 --- a/app/Http/Controllers/V1/ImageController.php +++ b/app/Http/Controllers/V1/ImageController.php @@ -2,21 +2,16 @@ namespace App\Http\Controllers\V1; -use App\Enums\ImageFormat; -use App\Enums\MediaStorage; -use App\Enums\Transformation; -use App\Exceptions\InvalidTransformationFormatException; -use App\Exceptions\InvalidTransformationValueException; -use App\Exceptions\TransformationNotFoundException; +use App\Enums\MediaType; use App\Http\Controllers\Controller; use App\Models\Media; use App\Models\User; use App\Models\Version; +use Delivery; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Transform; class ImageController extends Controller { @@ -44,10 +39,7 @@ public function get(User $user, Media $media, string $transformations = ''): Res */ public function getOriginal(Request $request, Media $media, Version $version): Response|Application|ResponseFactory { - $originalsDisk = MediaStorage::ORIGINALS->getDisk(); - $pathToOriginal = $version->originalFilePath(); - - return response($originalsDisk->get($pathToOriginal), 200, ['Content-Type' => mime_content_type($originalsDisk->readStream($pathToOriginal))]); + return Delivery::getOriginal($version); } /** @@ -64,29 +56,6 @@ public function getDerivativeForVersion(Request $request, Media $media, Version return $this->getDerivative($transformations, $version); } - /** - * Optimize an image derivative. - * Creates a temporary file since image optimizers only work locally. - * - * @param $derivative - * @param int|null $quality - * @return false|string - */ - protected function optimizeDerivative($derivative, int $quality = null): string|false - { - // Temporary file is needed since optimizers only work locally. - $tempFile = tempnam(sys_get_temp_dir(), 'transmorpher'); - file_put_contents($tempFile, $derivative); - - // Optimizes the image based on optimizers configured in 'config/image-optimizer.php'. - ImageFormat::fromMimeType(mime_content_type($tempFile))->getOptimizer()->optimize($tempFile, $quality); - - $derivative = file_get_contents($tempFile); - unlink($tempFile); - - return $derivative; - } - /** * @param string $transformations * @param Version $version @@ -94,28 +63,6 @@ protected function optimizeDerivative($derivative, int $quality = null): string| */ protected function getDerivative(string $transformations, Version $version): ResponseFactory|Application|Response { - try { - $transformationsArray = Transformation::arrayFromString($transformations); - } catch (TransformationNotFoundException|InvalidTransformationValueException|InvalidTransformationFormatException $exception) { - abort(400, $exception->getMessage()); - } - - $imageDerivativesDisk = MediaStorage::IMAGE_DERIVATIVES->getDisk(); - $derivativePath = $version->imageDerivativeFilePath($transformationsArray); - - // Check if derivative already exists and return if so. - if (!config('transmorpher.dev_mode') && config('transmorpher.store_derivatives') && $imageDerivativesDisk->exists($derivativePath)) { - $derivative = $imageDerivativesDisk->get($derivativePath); - } else { - // Apply transformations to image. - $derivative = Transform::transform($version->originalFilePath(), $transformationsArray); - $derivative = $this->optimizeDerivative($derivative, $transformationsArray[Transformation::QUALITY->value] ?? null); - - if (config('transmorpher.store_derivatives')) { - $imageDerivativesDisk->put($derivativePath, $derivative); - } - } - - return response($derivative, 200, ['Content-Type' => mime_content_type($imageDerivativesDisk->readStream($derivativePath))]); + return Delivery::getDerivative($transformations, $version, MediaType::IMAGE); } } diff --git a/app/Http/Controllers/V1/PdfController.php b/app/Http/Controllers/V1/PdfController.php new file mode 100644 index 00000000..7d288355 --- /dev/null +++ b/app/Http/Controllers/V1/PdfController.php @@ -0,0 +1,68 @@ +getDerivative($transformations, $media->currentVersion); + } + + /** + * Retrieve an original image for a version. + * + * @param Request $request + * @param Media $media + * @param Version $version + * @return Application|Response|ResponseFactory + */ + public function getOriginal(Request $request, Media $media, Version $version): Response|Application|ResponseFactory + { + return Delivery::getOriginal($version); + } + + /** + * Retrieve a derivative for a version. + * + * @param Request $request + * @param Media $media + * @param Version $version + * @param string $transformations + * @return Response|Application|ResponseFactory + */ + public function getDerivativeForVersion(Request $request, Media $media, Version $version, string $transformations = ''): Response|Application|ResponseFactory + { + return $this->getDerivative($transformations, $version); + } + + /** + * @param string $transformations + * @param Version $version + * @return Application|ResponseFactory|Response + */ + protected function getDerivative(string $transformations, Version $version): ResponseFactory|Application|Response + { + return Delivery::getDerivative($transformations, $version, MediaType::PDF); + } +} diff --git a/app/Http/Controllers/V1/UploadSlotController.php b/app/Http/Controllers/V1/UploadSlotController.php index 8383e4bd..a8484996 100644 --- a/app/Http/Controllers/V1/UploadSlotController.php +++ b/app/Http/Controllers/V1/UploadSlotController.php @@ -50,31 +50,7 @@ public function receiveFile(UploadRequest $request, UploadSlot $uploadSlot): Jso ]); } - /** - * Handle the incoming request. - * - * @param UploadSlotRequest $request - * - * @return JsonResponse - */ - public function reserveImageUploadSlot(UploadSlotRequest $request): JsonResponse - { - return $this->reserveUploadSlot($request, MediaType::IMAGE); - } - - /** - * Handle the incoming request. - * - * @param UploadSlotRequest $request - * - * @return JsonResponse - */ - public function reserveVideoUploadSlot(UploadSlotRequest $request): JsonResponse - { - return $this->reserveUploadSlot($request, MediaType::VIDEO); - } - - protected function reserveUploadSlot(UploadSlotRequest $request, MediaType $mediaType): JsonResponse + public function reserveUploadSlot(UploadSlotRequest $request, MediaType $mediaType): JsonResponse { return $this->updateOrCreateUploadSlot($request->user(), $request->merge(['media_type' => $mediaType->value])->all()); } diff --git a/app/Interfaces/MediaHandlerInterface.php b/app/Interfaces/MediaHandlerInterface.php index 1386e998..88bdb08a 100644 --- a/app/Interfaces/MediaHandlerInterface.php +++ b/app/Interfaces/MediaHandlerInterface.php @@ -47,4 +47,11 @@ public function getDerivativesDisk(): Filesystem; * @return array */ public function purgeDerivatives(): array; + + /** + * @param Version $version + * @param array|null $transformationsArray + * @return false|string + */ + public function applyTransformations(Version $version, ?array $transformationsArray): false|string; } diff --git a/app/Interfaces/TransformInterface.php b/app/Interfaces/TransformInterface.php index 02b0926b..b6accf69 100644 --- a/app/Interfaces/TransformInterface.php +++ b/app/Interfaces/TransformInterface.php @@ -7,15 +7,10 @@ interface TransformInterface /** * Transform image based on specified transformations. * - * @param string $pathToOriginalImage + * @param string $pathToOriginalImage * @param array|null $transformations * * @return string Binary string of the image. */ public function transform(string $pathToOriginalImage, array $transformations = null): string; - - /** - * @return array - */ - public function getSupportedFormats(): array; } diff --git a/app/Models/Version.php b/app/Models/Version.php index 50afe8f0..c14d768a 100644 --- a/app/Models/Version.php +++ b/app/Models/Version.php @@ -68,8 +68,13 @@ protected static function booted(): void protected function deleteFiles(): void { MediaStorage::ORIGINALS->getDisk()->delete($this->originalFilePath()); - MediaStorage::IMAGE_DERIVATIVES->getDisk()->deleteDirectory($this->imageDerivativeDirectoryPath()); - // Video derivatives may not be deleted here, otherwise failed jobs would delete the only existing video derivative. + + if (($derivativesDisk = $this->Media->type->handler()->getDerivativesDisk()) === MediaStorage::VIDEO_DERIVATIVES->getDisk()) { + // Video derivatives may not be deleted here, otherwise failed jobs would delete the only existing video derivative. + return; + } + + $derivativesDisk->deleteDirectory($this->nonVideoDerivativeDirectoryPath()); } /** @@ -121,20 +126,22 @@ public function createOriginalFileName(string $filename): string * @param array|null $transformations * @return string */ - public function imageDerivativeFilePath(array $transformations = null): string + public function nonVideoDerivativeFilePath(array $transformations = null): string { + $mediaType = $this->Media->type; $originalFileExtension = pathinfo($this->filename, PATHINFO_EXTENSION); // Hash of transformation parameters and version number to identify already generated derivatives. $derivativeHash = hash('sha256', json_encode($transformations) . $this->getKey()); - return sprintf('%s/%sx_%sy_%sq_%s.%s', - $this->imageDerivativeDirectoryPath(), + return sprintf('%s/%sx_%sy_%sq_%sp_%s.%s', + $this->nonVideoDerivativeDirectoryPath(), $transformations[Transformation::WIDTH->value] ?? '', $transformations[Transformation::HEIGHT->value] ?? '', $transformations[Transformation::QUALITY->value] ?? '', + $transformations[Transformation::PAGE->value] ?? '', $derivativeHash, - $transformations[Transformation::FORMAT->value] ?? $originalFileExtension, + $transformations[Transformation::FORMAT->value] ?? ($mediaType->usesOriginalFileExtension() ? $originalFileExtension : $mediaType->getDefaultExtension($transformations)) ); } @@ -144,7 +151,7 @@ public function imageDerivativeFilePath(array $transformations = null): string * * @return string */ - public function imageDerivativeDirectoryPath(): string + public function nonVideoDerivativeDirectoryPath(): string { return sprintf('%s/%s', $this->Media->baseDirectory(), $this->getKey()); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 387172f3..ef159e80 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,15 +2,17 @@ namespace App\Providers; +use App\Classes\Delivery; +use App\Classes\Optimizer\Optimize; use App\Models\Media; use App\Models\User; use App\Models\Version; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; -use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -22,7 +24,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton('optimize', fn(): Optimize => new Optimize()); + $this->app->singleton('delivery', fn(): Delivery => new Delivery()); } /** diff --git a/compose.pullpreview.yml b/compose.pullpreview.yml index be0ca254..f309fa1d 100644 --- a/compose.pullpreview.yml +++ b/compose.pullpreview.yml @@ -16,18 +16,15 @@ services: PULLPREVIEW: true PULLPREVIEW_FIRST_RUN: ${PULLPREVIEW_FIRST_RUN} VIDEO_TRANSCODING_WORKERS_AMOUNT: ${VIDEO_TRANSCODING_WORKERS_AMOUNT:-1} - APP_URL: ${PULLPREVIEW_URL}/transmorpherServer + APP_URL: https://transmorpher.${PULLPREVIEW_PUBLIC_DNS} CLIENT_CONTAINER_NAME: ${AMIGOR_CONTAINER_NAME:-amigor} volumes: - 'app-storage:/var/www/html/storage' labels: - 'traefik.enable=true' - - 'traefik.http.routers.${APP_CONTAINER_NAME:-transmorpher}.rule=Host(`${PULLPREVIEW_PUBLIC_DNS}`) && PathPrefix(`/transmorpherServer`)' - - 'traefik.http.routers.${APP_CONTAINER_NAME:-transmorpher}.middlewares=strip-path-prefix@docker' - - 'traefik.http.routers.${APP_CONTAINER_NAME:-transmorpher}.priority=2' + - 'traefik.http.routers.${APP_CONTAINER_NAME:-transmorpher}.rule=Host(`transmorpher.${PULLPREVIEW_PUBLIC_DNS}`)' - 'traefik.http.routers.${APP_CONTAINER_NAME:-transmorpher}.tls=true' - 'traefik.http.routers.${APP_CONTAINER_NAME:-transmorpher}.tls.certresolver=production' - - "traefik.http.middlewares.strip-path-prefix.stripprefix.prefixes=/transmorpherServer" mysql: image: 'mysql/mysql-server:8.0' container_name: ${MYSQL_CONTAINER_NAME:-transmorpher-mysql-1} @@ -49,7 +46,7 @@ services: retries: 3 timeout: 5s amigor: - image: 'cybexwebdev/transmorpher-amigor' + image: 'cybexwebdev/transmorpher-amigor:feature-pdf' container_name: ${AMIGOR_CONTAINER_NAME:-amigor} networks: - traefik @@ -62,9 +59,9 @@ services: environment: PULLPREVIEW: true PULLPREVIEW_FIRST_RUN: ${PULLPREVIEW_FIRST_RUN} - TRANSMORPHER_WEB_DELIVERY_BASE_URL: https://${PULLPREVIEW_PUBLIC_DNS}/transmorpherServer - TRANSMORPHER_WEB_API_BASE_URL: https://${PULLPREVIEW_PUBLIC_DNS}/transmorpherServer/api - APP_URL: ${PULLPREVIEW_URL} + TRANSMORPHER_WEB_DELIVERY_BASE_URL: https://transmorpher.${PULLPREVIEW_PUBLIC_DNS} + TRANSMORPHER_WEB_API_BASE_URL: https://transmorpher.${PULLPREVIEW_PUBLIC_DNS}/api + APP_URL: https://${PULLPREVIEW_PUBLIC_DNS} volumes: - 'amigor-storage:/var/www/html/amigor/storage' - '.env.amigor:/var/www/html/amigor/.env' @@ -78,7 +75,7 @@ services: - 'traefik.http.routers.amigor-root.middlewares=htpasswd' - 'traefik.http.routers.amigor-root.tls=true' - 'traefik.http.routers.amigor-root.tls.certresolver=production' - - 'traefik.http.routers.amigor-root.priority=3' + - 'traefik.http.routers.amigor-root.priority=2' - 'traefik.http.middlewares.htpasswd.basicauth.usersfile=/.htpasswd' mysql-amigor: image: 'mysql/mysql-server:8.0' diff --git a/composer.json b/composer.json index 6a267df4..af8d1bf1 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "aminyazdanpanah/php-ffmpeg-video-streaming": "^1.2.17", "cybex/laravel-protector": "^3.0", "intervention/image-laravel": "^1.4", + "karriere/pdf-merge": "^3.2", "laravel/framework": "^11.0", "laravel/tinker": "^2.9", "league/flysystem-aws-s3-v3": "^3.12", @@ -29,6 +30,7 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^11.0.1", + "smalot/pdfparser": "^2.11", "spatie/laravel-ignition": "^2.4" }, "autoload": { diff --git a/composer.lock b/composer.lock index 2b847b16..218dfef1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b5979a080d5009b08e00b18b2d9e1b69", + "content-hash": "6e3c2a74ca8fd4080912745624222d59", "packages": [ { "name": "aminyazdanpanah/php-ffmpeg-video-streaming", @@ -1659,6 +1659,62 @@ ], "time": "2025-01-18T15:56:47+00:00" }, + { + "name": "karriere/pdf-merge", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/karriereat/pdf-merge.git", + "reference": "67db966433a82d23ab30c60359782d7fb3f7ecee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/karriereat/pdf-merge/zipball/67db966433a82d23ab30c60359782d7fb3f7ecee", + "reference": "67db966433a82d23ab30c60359782d7fb3f7ecee", + "shasum": "" + }, + "require": { + "php": "8.1.* | 8.2.* | 8.3.* | 8.4.*", + "tecnickcom/tcpdf": "^6.3" + }, + "require-dev": { + "laravel/pint": "^1.5 | ^1.6", + "pestphp/pest": "^1.22", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Karriere\\PdfMerge\\": "src/" + }, + "classmap": [ + "tcpi/fpdf_tpl.php", + "tcpi/tcpdi.php", + "tcpi/tcpdi_parser.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alexander Lentner", + "email": "alexander.lentner@karriere.at", + "role": "Maintainer" + } + ], + "description": "A wrapper for the TCPDF class that provides an elegant API for merging PDFs", + "keywords": [ + "merge", + "pdf" + ], + "support": { + "issues": "https://github.com/karriereat/pdf-merge/issues", + "source": "https://github.com/karriereat/pdf-merge/tree/v3.2.0" + }, + "time": "2024-12-05T07:59:08+00:00" + }, { "name": "laravel/framework", "version": "v11.41.3", @@ -7021,6 +7077,79 @@ ], "time": "2024-10-18T07:58:17+00:00" }, + { + "name": "tecnickcom/tcpdf", + "version": "6.8.2", + "source": { + "type": "git", + "url": "https://github.com/tecnickcom/TCPDF.git", + "reference": "f7a781073e1645062f163e058139e2f89355d420" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/f7a781073e1645062f163e058139e2f89355d420", + "reference": "f7a781073e1645062f163e058139e2f89355d420", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": ">=7.1.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "config", + "include", + "tcpdf.php", + "tcpdf_parser.php", + "tcpdf_import.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.8.2" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_donations¤cy_code=GBP&business=paypal@tecnick.com&item_name=donation%20for%20tcpdf%20project", + "type": "custom" + } + ], + "time": "2025-01-26T14:03:12+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.3.0", @@ -9615,6 +9744,57 @@ ], "time": "2024-10-09T05:16:32+00:00" }, + { + "name": "smalot/pdfparser", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "ac8e6678b0940e4b2ccd5caadd3fb18e68093be6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/ac8e6678b0940e4b2ccd5caadd3fb18e68093be6", + "reference": "ac8e6678b0940e4b2ccd5caadd3fb18e68093be6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.11.0" + }, + "time": "2024-08-16T06:48:03+00:00" + }, { "name": "spatie/backtrace", "version": "1.7.1", diff --git a/config/app.php b/config/app.php index 76661eed..53cc9684 100644 --- a/config/app.php +++ b/config/app.php @@ -192,6 +192,8 @@ 'aliases' => Facade::defaultAliases()->merge([ 'CdnHelper' => App\Facades\CdnHelperFacade::class, + 'Delivery' => App\Facades\DeliveryFacade::class, + 'Optimize' => App\Facades\OptimizeFacade::class, 'Transcode' => App\Facades\TranscodeFacade::class, 'Transform' => App\Facades\TransformFacade::class, ])->toArray(), diff --git a/config/filesystems.php b/config/filesystems.php index 8234ca05..710bbbea 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -58,6 +58,12 @@ 'throw' => false, ], + 'localPdfDerivatives' => [ + 'driver' => 'local', + 'root' => storage_path('app/' . MediaType::PDF->prefix()), + 'throw' => false, + ], + 'localVideoDerivatives' => [ 'driver' => 'local', 'root' => storage_path('app/' . MediaType::VIDEO->prefix()), @@ -92,6 +98,20 @@ 'throw' => false, ], + + 's3PdfDerivatives' => [ + 'driver' => 's3', + 'root' => MediaType::PDF->prefix(), + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET_IMAGE_DERIVATIVES'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + 's3VideoDerivatives' => [ 'driver' => 's3', 'root' => MediaType::VIDEO->prefix(), diff --git a/config/transmorpher.php b/config/transmorpher.php index f1be9ada..7084a958 100644 --- a/config/transmorpher.php +++ b/config/transmorpher.php @@ -34,6 +34,7 @@ 'disks' => [ 'originals' => env('TRANSMORPHER_DISK_ORIGINALS', 'localOriginals'), 'imageDerivatives' => env('TRANSMORPHER_DISK_IMAGE_DERIVATIVES', 'localImageDerivatives'), + 'pdfDerivatives' => env('TRANSMORPHER_DISK_PDF_DERIVATIVES', 'localPdfDerivatives'), 'videoDerivatives' => env('TRANSMORPHER_DISK_VIDEO_DERIVATIVES', 'localVideoDerivatives'), ], @@ -68,6 +69,17 @@ 'webp' => App\Classes\Intervention\Convert::class, ], + /* + |-------------------------------------------------------------------------- + | PDF Default Image Format + |-------------------------------------------------------------------------- + | + | Defines the default image format for PDFs if no format transformation is specified. + | Check the ImageFormat enum for available formats. + | + */ + 'pdf_default_image_format' => 'jpg', + /* |-------------------------------------------------------------------------- | Transcode Class @@ -76,7 +88,7 @@ | The class which is used for transcoding videos. | | Available Transcode classes: - | - Transcode (uses FFmpeg and Laravel Queue for transcoding) + | - Transcode (uses FFmpeg and Laravel Queue for transcoding) | */ 'transcode_class' => App\Classes\Transcode::class, @@ -167,6 +179,7 @@ */ 'media_handlers' => [ 'image' => App\Classes\MediaHandler\ImageHandler::class, + 'pdf' => App\Classes\MediaHandler\PdfHandler::class, 'video' => App\Classes\MediaHandler\VideoHandler::class ], diff --git a/database/migrations/2025_02_06_084609_add_pdf_to_media_type_enums.php b/database/migrations/2025_02_06_084609_add_pdf_to_media_type_enums.php new file mode 100644 index 00000000..ef7bddf1 --- /dev/null +++ b/database/migrations/2025_02_06_084609_add_pdf_to_media_type_enums.php @@ -0,0 +1,22 @@ + 'media_type', 'media' => 'type']; + + foreach ($tableWithColumn as $tableName => $columnName) { + Schema::table($tableName, function (Blueprint $table) use ($columnName) { + $table->enum($columnName, ['image', 'video', 'pdf'])->change(); + }); + } + } +}; diff --git a/docker/8.2/Dockerfile b/docker/8.2/Dockerfile index fb51b4fb..8af2486e 100644 --- a/docker/8.2/Dockerfile +++ b/docker/8.2/Dockerfile @@ -58,6 +58,9 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +# Remove imagemagick policy preventing PDF conversion (security issue is fixed with the installed ghostscript version) +RUN sed -i '/disable ghostscript format types/,+6d' /etc/ImageMagick-6/policy.xml + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2 RUN groupadd --force -g $WWWGROUP sail diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile index 2e92fe86..0d73e39b 100644 --- a/docker/8.3/Dockerfile +++ b/docker/8.3/Dockerfile @@ -58,6 +58,9 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +# Remove imagemagick policy preventing PDF conversion (security issue is fixed with the installed ghostscript version) +RUN sed -i '/disable ghostscript format types/,+6d' /etc/ImageMagick-6/policy.xml + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 RUN groupadd --force -g $WWWGROUP sail diff --git a/docker/Dockerfile b/docker/Dockerfile index 55af16ac..cde80604 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1-labs FROM webdevops/php-nginx:8.2 WORKDIR /var/www/html @@ -9,20 +10,23 @@ LABEL com.centurylinklabs.watchtower.lifecycle.post-update-timeout="1440" # Watchtower will run this script after restarting the updated container. LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/var/www/html/docker/watchtower.post-update.sh" -COPY . /var/www/html -COPY ./docker/workers.conf /opt/docker/etc/supervisor.d/ +RUN docker-service-enable cron +RUN docker-cronjob '* * * * * application /usr/local/bin/php /var/www/html/artisan schedule:run >> /dev/null 2>&1' -RUN composer install --no-interaction --no-dev +COPY composer.json composer.lock /var/www/html/ +RUN composer install --no-interaction --no-dev --no-scripts -RUN chmod +x /var/www/html/docker/entryfile.sh -RUN chmod +x /var/www/html/docker/watchtower.post-update.sh -RUN chmod 755 -R /var/www/html/storage -RUN chown -R application:application /var/www/html/storage +COPY ./docker/workers.conf /opt/docker/etc/supervisor.d/ +COPY --chmod=755 ./docker/entryfile.sh /var/www/html/docker/entryfile.sh +COPY --chmod=755 ./docker/watchtower.post-update.sh /var/www/html/docker/watchtower.post-update.sh +COPY --chmod=755 --chown=application:application ./storage /var/www/html/storage -RUN php /var/www/html/artisan storage:link +RUN apt update && \ + apt install -y --no-install-recommends default-mysql-client imagemagick jpegoptim optipng pngquant gifsicle webp && \ + rm -rf /var/lib/apt/lists/* -RUN apt update -RUN apt install -y default-mysql-client imagemagick jpegoptim optipng pngquant gifsicle webp +# Remove imagemagick policy preventing PDF conversion (security issue is fixed with the installed ghostscript version 10) +RUN sed -i '/disable ghostscript format types/,+6d' /etc/ImageMagick-6/policy.xml ## ffmpeg 6 - https://www.deb-multimedia.org/dists/stable/non-free/binary-amd64/ RUN printf "\ndeb https://www.deb-multimedia.org bookworm main non-free" >> /etc/apt/sources.list \ @@ -32,7 +36,13 @@ RUN printf "\ndeb https://www.deb-multimedia.org bookworm main non-free" >> /etc && dpkg -i deb-multimedia-keyring_2016.8.1_all.deb \ && apt-get -y --allow-unauthenticated install ffmpeg -RUN docker-service-enable cron -RUN docker-cronjob '* * * * * application /usr/local/bin/php /var/www/html/artisan schedule:run >> /dev/null 2>&1' +COPY \ + --exclude=./storage \ + --exclude=./docker/workers.conf \ + --exclude=./docker/entryfile.sh \ + --exclude=./docker/watchtower.post-update.sh \ + . /var/www/html + +RUN php /var/www/html/artisan storage:link ENTRYPOINT ["/var/www/html/docker/entryfile.sh"] diff --git a/lang/en/responses.php b/lang/en/responses.php index d4a0f163..ababd6e1 100644 --- a/lang/en/responses.php +++ b/lang/en/responses.php @@ -14,6 +14,8 @@ 'deletion_successful' => 'Successfully deleted media.', 'image_upload_successful' => 'Successfully uploaded new image version.', 'image_version_set' => 'Successfully set image version.', + 'pdf_upload_successful' => 'Successfully uploaded new PDF version.', + 'pdf_version_set' => 'Successfully set PDF version.', 'transcoding_aborted' => 'Transcoding process aborted due to a new version or upload.', 'transcoding_failed' => 'Video transcoding failed, version has been removed.', 'transcoding_job_dispatch_failed' => 'There was an error when trying to dispatch the transcoding job.', diff --git a/postman.json b/postman.json index 64ecd017..d3333905 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "50dcf20c-b9c1-4f5c-8fcd-2e161aff5e2e", + "_postman_id": "38e2ce41-6cb8-41ca-934f-e83f47f51d6a", "name": "Transmorpher Server API v1", "description": "This file describes the Transmorpher Server API.\n\n- It includes examples for failed calls as well, until they are all covered in tests.\n- Filter for \": OK\" to only see correct API calls.\n \n\nConfiguration:\n\n- create a user on Transmorpher: `php artisan create:user postman postman@example.com http://amigor/transmorpher/notifications`\n- use the provided auth token and adjust the \"authToken\" variable\n- if you're using a domain different from \"transmorpher.test\", you will have to adjust the \"domain\" variable\n \n\nIf you want to use the collection with the already defined files, you will have to go to `Settings > General > Allow reading files outside working directory`, and enable the option.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -2239,6 +2239,834 @@ } ] }, + { + "name": "PDF", + "item": [ + { + "name": "Reserve upload slot: OK", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Obtained pdf upload token\", function () {\r", + " var jsonData = pm.response.json();\r", + " console.log(jsonData);\r", + " pm.expect(jsonData.state).to.eql('initializing');\r", + " pm.expect(pm.response.code).to.equal(200);\r", + "});\r", + "/* {\r", + " \"state\": 'initializing',\r", + " \"message\": \"Successfully created upload slot.\",\r", + " \"identifier\": \"postmanTestPdf\",\r", + " \"upload_token\": \"6464b7e2f0928\"\r", + "} */\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "protocolProfileBehavior": { + "disabledSystemHeaders": {} + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "identifier", + "value": "{{pdfIdentifier}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{domain}}/api/v1/pdf/reserveUploadSlot", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "pdf", + "reserveUploadSlot" + ] + }, + "description": "Providing the auth token, the client reserves an upload slot for an PDF identifier. This action of the client backend secures the transaction, allowing the frontend to upload the PDF to the Transmorpher media server directly." + }, + "response": [] + }, + { + "name": "Upload: Wrong mime type", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"PDF upload rejected: File has wrong mime type\", function () {\r", + " var jsonData = pm.response.json();\r", + " console.log(jsonData);\r", + " pm.expect(pm.response.json().errors).to.have.property(\"file\");\r", + " pm.expect(pm.response.code).to.equal(422);\r", + "});\r", + "/* {\r", + " \"message\": \"The file must be a file of type: application/pdf.\",\r", + " \"errors\": {\r", + " \"file\": [\r", + " \"The file must be a file of type: application/pdf.\"\r", + " ]\r", + " }\r", + "} */\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "/C:/Windows/System32/FeatureToastBulldogImg.png" + }, + { + "key": "identifier", + "value": "{{pdfIdentifier}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{domain}}/api/v1/upload/{{pdfUploadToken}}", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "upload", + "{{pdfUploadToken}}" + ] + } + }, + "response": [] + }, + { + "name": "Upload: OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"PDF uploaded\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.state).to.eql('success');\r", + " pm.expect(pm.response.code).to.equal(201);\r", + " pm.collectionVariables.set('currentPdfVersion', jsonData.version);\r", + " console.log('set currentPdfVersion to: ' + pm.collectionVariables.get('currentPdfVersion'));\r", + "});\r", + "/* {\r", + " \"state\": 'success',\r", + " \"message\": \"Successfully uploaded new pdf version.\",\r", + " \"identifier\": \"postmanTestPdf\",\r", + " \"version\": 1,\r", + " \"public_path\": \"pdfs/postman/postmanTestPdf\",\r", + " \"upload_token\": \"6464a87163b5a\",\r", + " \"hash\": \"db36a07353dd308635fb54a5443f7277\"\r", + "} */\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": [] + }, + { + "key": "identifier", + "value": "{{pdfIdentifier}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{domain}}/api/v1/upload/{{pdfUploadToken}}", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "upload", + "{{pdfUploadToken}}" + ] + }, + "description": "Providing the correct upload slot token for the PDF identifier, the client frontend can upload the PDF to the Transmorpher media server. The answer of this call contains the public URL where the PDF will be available immediatly after upload." + }, + "response": [] + }, + { + "name": "Download (default): OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Received default PDF derivative\", function() {\r", + " pm.expect(pm.response.code).to.equal(200);\r", + " pm.expect(pm.response.contentInfo().mimeType).to.be.equal(\"embed\");\r", + " pm.expect(pm.response.contentInfo().contentType).to.be.equal(\"application/pdf\");\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{domain}}/pdfs/{{user}}/{{pdfIdentifier}}", + "host": [ + "{{domain}}" + ], + "path": [ + "pdfs", + "{{user}}", + "{{pdfIdentifier}}" + ] + }, + "description": "Requesting a PDF without any transformations will return the whole PDF document. Metadata is removed from the document." + }, + "response": [] + }, + { + "name": "Download page 1: OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Received image PDF derivative\", function() {\r", + " pm.expect(pm.response.code).to.equal(200);\r", + " pm.expect(pm.response.contentInfo().mimeType).to.be.equal(\"image\");\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{domain}}/pdfs/{{user}}/{{pdfIdentifier}}/p-1", + "host": [ + "{{domain}}" + ], + "path": [ + "pdfs", + "{{user}}", + "{{pdfIdentifier}}", + "p-1" + ] + }, + "description": "Requesting a PDF with transformations will convert the PDF file to an image. By specifiying the 'p' transformation, a specific page can be selected for the generated image." + }, + "response": [] + }, + { + "name": "Download page 999999: nonexistent page", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Retrieving derivative failed: requested page does not exist\", function() {\r", + " pm.expect(pm.response.code).to.equal(400);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{domain}}/pdfs/{{user}}/{{pdfIdentifier}}/p-999999", + "host": [ + "{{domain}}" + ], + "path": [ + "pdfs", + "{{user}}", + "{{pdfIdentifier}}", + "p-999999" + ] + } + }, + "response": [] + }, + { + "name": "Download as PNG, low quality: OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Received PNG image PDF derivative\", function() {\r", + " pm.expect(pm.response.code).to.equal(200);\r", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.equal(\"image/png\");\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{domain}}/pdfs/{{user}}/{{pdfIdentifier}}/q-1+f-png", + "host": [ + "{{domain}}" + ], + "path": [ + "pdfs", + "{{user}}", + "{{pdfIdentifier}}", + "q-1+f-png" + ] + }, + "description": "Transformations can be applied to PDF images as usual." + }, + "response": [] + }, + { + "name": "Download specific Version original: OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Received original pdf\", function() {\r", + " pm.expect(pm.response.code).to.equal(200);\r", + " pm.expect(pm.response.contentInfo().mimeType).to.be.equal(\"embed\");\r", + " pm.expect(pm.response.contentInfo().contentType).to.be.equal(\"application/pdf\");\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{domain}}/api/v1/pdf/{{pdfIdentifier}}/version/{{currentPdfVersion}}/original", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "pdf", + "{{pdfIdentifier}}", + "version", + "{{currentPdfVersion}}", + "original" + ] + } + }, + "response": [] + }, + { + "name": "Download specific Version derivative: OK", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Received derivative for specific version\", function () {\r", + " pm.expect(pm.response.code).to.equal(200);\r", + " pm.expect(pm.response.contentInfo().mimeType).to.be.equal(\"embed\");\r", + " pm.expect(pm.response.contentInfo().contentType).to.be.equal(\"application/pdf\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{domain}}/api/v1/pdf/{{pdfIdentifier}}/version/{{currentPdfVersion}}/derivative", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "pdf", + "{{pdfIdentifier}}", + "version", + "{{currentPdfVersion}}", + "derivative" + ] + } + }, + "response": [] + }, + { + "name": "Download specific Version derivative image: OK", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Received derivative for specific version\", function () {\r", + " pm.expect(pm.response.code).to.equal(200);\r", + " pm.expect(pm.response.contentInfo().mimeType).to.be.equal(\"image\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{domain}}/api/v1/pdf/{{pdfIdentifier}}/version/{{currentPdfVersion}}/derivative/p-1", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "pdf", + "{{pdfIdentifier}}", + "version", + "{{currentPdfVersion}}", + "derivative", + "p-1" + ] + } + }, + "response": [] + }, + { + "name": "List Versions: OK Copy", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"PDF versions retrieved\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.state).to.eql('success');\r", + " pm.expect(pm.response.code).to.equal(200);\r", + "});\r", + "/* {\r", + " \"state\": 'success',\r", + " \"message\": \"Successfully retrieved version numbers.\",\r", + " \"identifier\": \"postmanTestPdf\",\r", + " \"currentVersion\": 3,\r", + " \"currentlyProcessedVersion\": 3,\r", + " \"versions\": {\r", + " \"1\": 1720201274,\r", + " \"2\": 1720205610,\r", + " \"3\": 1720208565\r", + " },\r", + "} */\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{domain}}/api/v1/media/{{pdfIdentifier}}/versions", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "media", + "{{pdfIdentifier}}", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Restore old Version: OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"PDF version set\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.state).to.eql('success');\r", + " pm.expect(pm.response.code).to.equal(200);\r", + "});\r", + "/* {\r", + " \"state\": \"success\",\r", + " \"message\": \"Successfully set pdf version.\",\r", + " \"identifier\": \"postmanTestPdf\",\r", + " \"version\": 2,\r", + " \"public_path\": \"pdf/postman/postmanTestPdf\",\r", + " \"upload_token\": \"66885feb496fe\",\r", + " \"hash\": \"021881ce82531c8a30d6db156e49635a\"\r", + "} */\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "_method", + "value": "patch", + "type": "text" + } + ] + }, + "url": { + "raw": "{{domain}}/api/v1/media/{{pdfIdentifier}}/version/{{currentPdfVersion}}", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "media", + "{{pdfIdentifier}}", + "version", + "{{currentPdfVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete: OK", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"PDF deleted\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.state).to.eql('deleted');\r", + " pm.expect(pm.response.code).to.equal(200);\r", + "});\r", + "/* {\r", + " \"state\": 'deleted',\r", + " \"response\": \"Successfully deleted media.\",\r", + " \"identifier\": \"postmanTestPdf\",\r", + "} */\r", + "pm.test(\"PDF not found after deletion\", function () {\r", + " pm.sendRequest(\r", + " {\r", + " url: pm.collectionVariables.get(\"domain\") + \"/api/v1/media/\" + pm.collectionVariables.get(\"pdfIdentifier\") + \"/versions\",\r", + " method: \"GET\",\r", + " header: {\r", + " \"Authorization\": \"Bearer \" + pm.collectionVariables.get(\"authToken\"),\r", + " \"Accept\": \"application/json\",\r", + " \"Content-Type\": \"application/json\"\r", + " }\r", + " }, function (err, response) {\r", + " var jsonData = response.json();\r", + " console.log(jsonData);\r", + " pm.expect(response.code).to.eql(404);\r", + " pm.expect(jsonData.message).to.eql(\"Requested Media couldn't be found.\");\r", + " }\r", + " );\r", + "});\r", + "/* {\r", + " \"message\": \"Requested Media couldn't be found.\"\r", + "} */\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "formdata", + "formdata": [] + }, + "url": { + "raw": "{{domain}}/api/v1/media/{{pdfIdentifier}}", + "host": [ + "{{domain}}" + ], + "path": [ + "api", + "v1", + "media", + "{{pdfIdentifier}}" + ] + } + }, + "response": [] + } + ], + "description": "The Transmorpher Media server treats PDFs in realtime. As soon as a PDF is uploaded, it is available for download. PDF transformations are created when they are requested.", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "pm.sendRequest(\r", + " {\r", + " url: pm.collectionVariables.get(\"domain\") + \"/api/v1/pdf/reserveUploadSlot\",\r", + " method: \"POST\",\r", + " header: {\r", + " \"Authorization\": \"Bearer \" + pm.collectionVariables.get(\"authToken\"),\r", + " \"Accept\": \"application/json\",\r", + " \"Content-Type\": \"application/json\"\r", + " },\r", + " body: {\r", + " \"mode\": \"application/json\",\r", + " \"raw\": JSON.stringify(\r", + " {\r", + " 'identifier': pm.collectionVariables.get(\"pdfIdentifier\")\r", + " }\r", + " )\r", + " }\r", + " }, function (err, response) {\r", + " var jsonData = response.json();\r", + " console.log(jsonData);\r", + " pm.collectionVariables.set('pdfUploadToken', jsonData.upload_token);\r", + " console.log('set pdfUploadToken to: ' + pm.collectionVariables.get('pdfUploadToken'));\r", + " console.log('-------------------------------------------')\r", + " }\r", + ");\r", + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ] + }, { "name": "Video", "item": [ @@ -3676,6 +4504,16 @@ "value": "postmanTestImage", "type": "string" }, + { + "key": "pdfUploadToken", + "value": "exampleUploadToken", + "type": "string" + }, + { + "key": "pdfIdentifier", + "value": "postmanTestPdf", + "type": "string" + }, { "key": "videoUploadToken", "value": "exampleUploadToken", @@ -3691,10 +4529,15 @@ "value": "5", "type": "string" }, + { + "key": "currentPdfVersion", + "value": "5", + "type": "string" + }, { "key": "currentVideoVersion", "value": "5", "type": "string" } ] -} \ No newline at end of file +} diff --git a/routes/api/v1.php b/routes/api/v1.php index 6503fd8a..cf834cf9 100644 --- a/routes/api/v1.php +++ b/routes/api/v1.php @@ -4,8 +4,10 @@ use App\Enums\MediaType; use App\Helpers\SodiumHelper; use App\Http\Controllers\V1\ImageController; +use App\Http\Controllers\V1\PdfController; use App\Http\Controllers\V1\UploadSlotController; use App\Http\Controllers\V1\VersionController; +use App\Http\Requests\V1\UploadSlotRequest; use Illuminate\Support\Facades\Route; /* @@ -26,12 +28,17 @@ function () { Route::patch('/media/{media}/version/{version}', [VersionController::class, 'setVersion'])->name('setVersion'); // Image - Route::get(sprintf('/%s/{media}/version/{version}/original', MediaType::IMAGE->value), [ImageController::class, 'getOriginal'])->name('getOriginal'); - Route::get(sprintf('/%s/{media}/version/{version}/derivative/{transformations?}', MediaType::IMAGE->value), [ImageController::class, 'getDerivativeForVersion'])->name('getDerivativeForVersion'); - Route::post(sprintf('/%s/reserveUploadSlot', MediaType::IMAGE->value), [UploadSlotController::class, 'reserveImageUploadSlot'])->name('reserveImageUploadSlot'); + Route::get(sprintf('/%s/{media}/version/{version}/original', MediaType::IMAGE->value), [ImageController::class, 'getOriginal'])->name('getImageOriginal'); + Route::get(sprintf('/%s/{media}/version/{version}/derivative/{transformations?}', MediaType::IMAGE->value), [ImageController::class, 'getDerivativeForVersion'])->name('getImageDerivativeForVersion'); + Route::post(sprintf('/%s/reserveUploadSlot', MediaType::IMAGE->value), fn(UploadSlotRequest $request) => app(UploadSlotController::class)->reserveUploadSlot($request, MediaType::IMAGE))->name('reserveImageUploadSlot'); + + // Pdf + Route::get(sprintf('/%s/{media}/version/{version}/original', MediaType::PDF->value), [PdfController::class, 'getOriginal'])->name('getPdfOriginal'); + Route::get(sprintf('/%s/{media}/version/{version}/derivative/{transformations?}', MediaType::PDF->value), [PdfController::class, 'getDerivativeForVersion'])->name('getPdfDerivativeForVersion'); + Route::post(sprintf('/%s/reserveUploadSlot', MediaType::PDF->value), fn(UploadSlotRequest $request) => app(UploadSlotController::class)->reserveUploadSlot($request, MediaType::PDF))->name('reservePdfUploadSlot'); // Video - Route::post(sprintf('/%s/reserveUploadSlot', MediaType::VIDEO->value), [UploadSlotController::class, 'reserveVideoUploadSlot'])->name('reserveVideoUploadSlot'); + Route::post(sprintf('/%s/reserveUploadSlot', MediaType::VIDEO->value), fn(UploadSlotRequest $request) => app(UploadSlotController::class)->reserveUploadSlot($request, MediaType::VIDEO))->name('reserveVideoUploadSlot'); } ); diff --git a/routes/delivery.php b/routes/delivery.php index e91ab845..6f9ee1a0 100644 --- a/routes/delivery.php +++ b/routes/delivery.php @@ -2,6 +2,7 @@ use App\Enums\MediaType; use App\Http\Controllers\V1\ImageController; +use App\Http\Controllers\V1\PdfController; use Illuminate\Support\Facades\Route; /* @@ -14,4 +15,7 @@ */ // Image -Route::get(sprintf('%s/{user}/{media}/{transformations?}', MediaType::IMAGE->prefix()), [ImageController::class, 'get'])->name('getDerivative'); +Route::get(sprintf('%s/{user}/{media}/{transformations?}', MediaType::IMAGE->prefix()), [ImageController::class, 'get'])->name('getImageDerivative'); + +// PDF +Route::get(sprintf('%s/{user}/{media}/{transformations?}', MediaType::PDF->prefix()), [PdfController::class, 'get'])->name('getPdfDerivative'); diff --git a/tests/Unit/ImageTest.php b/tests/Unit/ImageTest.php index a573fde2..d37ec1f7 100644 --- a/tests/Unit/ImageTest.php +++ b/tests/Unit/ImageTest.php @@ -93,7 +93,7 @@ public function ensureUploadTokenIsInvalidatedAfterUpload(string $uploadToken) protected function createDerivativeForVersion(Version $version): TestResponse { - return $this->get(route('getDerivative', [$this->user->name, $version->Media])); + return $this->get(route('getImageDerivative', [$this->user->name, $version->Media])); } #[Test] @@ -110,7 +110,7 @@ public function ensureProcessedFilesAreAvailable(Version $version) public function ensureUnprocessedFilesAreNotAvailable(Version $version) { $version->update(['processed' => 0]); - $getDerivativeResponse = $this->get(route('getDerivative', [$this->user->name, $version->Media])); + $getDerivativeResponse = $this->get(route('getImageDerivative', [$this->user->name, $version->Media])); $getDerivativeResponse->assertNotFound(); } @@ -118,7 +118,7 @@ public function ensureUnprocessedFilesAreNotAvailable(Version $version) protected function assertVersionFilesExist(Version $version): void { $this->originalsDisk->assertExists($version->originalFilePath()); - $this->imageDerivativesDisk->assertExists($version->imageDerivativeFilePath()); + $this->imageDerivativesDisk->assertExists($version->nonVideoDerivativeFilePath()); } protected function assertMediaDirectoryExists(Media $media): void @@ -135,7 +135,7 @@ protected function assertUserDirectoryExists(): void protected function assertVersionFilesMissing(Version $version): void { $this->originalsDisk->assertMissing($version->originalFilePath()); - $this->imageDerivativesDisk->assertMissing($version->imageDerivativeFilePath()); + $this->imageDerivativesDisk->assertMissing($version->nonVideoDerivativeFilePath()); } protected function assertMediaDirectoryMissing(Media $media): void @@ -215,7 +215,7 @@ public function ensureImageDerivativesArePurged() }); $this->assertTrue(++$cacheCounterBeforeCommand == $cacheCounterAfterCommand); - $this->imageDerivativesDisk->assertMissing($this->version->imageDerivativeFilePath()); + $this->imageDerivativesDisk->assertMissing($this->version->nonVideoDerivativeFilePath()); } #[Test] diff --git a/tests/Unit/PdfTest.php b/tests/Unit/PdfTest.php new file mode 100644 index 00000000..30867d11 --- /dev/null +++ b/tests/Unit/PdfTest.php @@ -0,0 +1,262 @@ +pdfDerivativesDisk ??= Storage::persistentFake(config(sprintf('transmorpher.disks.%s', MediaStorage::PDF_DERIVATIVES->value))); + } + + protected function reserveUploadSlot(): TestResponse + { + return $this->json('POST', route('v1.reservePdfUploadSlot'), [ + 'identifier' => self::IDENTIFIER + ]); + } + + #[Test] + public function ensurePdfUploadSlotCanBeReserved() + { + $reserveUploadSlotResponse = $this->reserveUploadSlot(); + + $reserveUploadSlotResponse->assertOk(); + + return $reserveUploadSlotResponse->json()['upload_token']; + } + + protected function uploadPdf(string $uploadToken): TestResponse + { + return $this->json('POST', route('v1.upload', [$uploadToken]), [ + 'file' => UploadedFile::fake()->createWithContent(self::PDF_NAME, File::get(base_path('tests/data/test.pdf'))), + 'identifier' => self::IDENTIFIER + ]); + } + + #[Test] + #[Depends('ensurePdfUploadSlotCanBeReserved')] + public function ensurePdfCanBeUploaded(string $uploadToken) + { + $uploadResponse = $this->uploadPdf($uploadToken); + + $uploadResponse->assertCreated(); + + $media = Media::whereIdentifier(self::IDENTIFIER)->first(); + $version = $media->Versions()->whereNumber($uploadResponse['version'])->first(); + + Storage::disk(config('transmorpher.disks.originals'))->assertExists($version->originalFilePath()); + + return $version; + } + + #[Test] + #[Depends('ensurePdfUploadSlotCanBeReserved')] + #[Depends('ensurePdfCanBeUploaded')] + public function ensureUploadTokenIsInvalidatedAfterUpload(string $uploadToken) + { + $this->uploadPdf($uploadToken)->assertNotFound(); + } + + protected function getOriginal(Version $version): TestResponse + { + return $this->get(route('v1.getPdfOriginal', [$version->Media, $version])); + } + + protected function getDerivative(Version $version, ?string $transformations = null): TestResponse + { + return $this->get(route('getPdfDerivative', [$this->user->name, $version->Media, $transformations])); + } + + #[Test] + #[Depends('ensurePdfCanBeUploaded')] + public function ensurePdfOriginalCanBeDownloaded(Version $version) + { + $response = $this->getOriginal($version); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); + + return $version; + } + + /** + * This test is not part of the data provider below, so it can be depended on in other tests. + * It covers the case where no transformations are applied. + */ + #[Test] + #[Depends('ensurePdfOriginalCanBeDownloaded')] + public function ensurePdfDerivativeCanBeDownloaded(Version $version) + { + $response = $this->getDerivative($version); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/pdf'); + + return $version; + } + + #[Test] + #[Depends('ensurePdfDerivativeCanBeDownloaded')] + #[DataProvider('providePdfTransformationStrings')] + public function ensurePdfDerivativeImagesCanBeDownloaded(string $transformations, int $expectedStatusCode, callable|string $expectedContentType, Version $version) + { + $response = $this->getDerivative($version, $transformations); + + $expectedContentType = (is_callable($expectedContentType) ? $expectedContentType() : $expectedContentType) === 'image/jpg' ? 'image/jpeg' : $expectedContentType; + + $response->assertStatus($expectedStatusCode); + $response->assertHeader('Content-Type', $expectedContentType); + } + + public static function providePdfTransformationStrings(): array + { + return [ + 'width' => [ + 'w-100', + 200, + fn() => sprintf('image/%s', config('transmorpher.pdf_default_image_format')), + ], + 'height' => [ + 'h-100', + 200, + fn() => sprintf('image/%s', config('transmorpher.pdf_default_image_format')), + ], + 'width and height' => [ + 'w-100+h-100', + 200, + fn() => sprintf('image/%s', config('transmorpher.pdf_default_image_format')), + ], + 'format png' => [ + 'f-png', + 200, + 'image/png', + ], + 'format webp' => [ + 'f-webp', + 200, + 'image/webp', + ], + 'format jpg' => [ + 'f-jpg', + 200, + 'image/jpeg', + ], + 'format gif' => [ + 'f-gif', + 200, + 'image/gif', + ], + 'page' => [ + 'p-1', + 200, + fn() => sprintf('image/%s', config('transmorpher.pdf_default_image_format')), + ], + 'page width height format png' => [ + 'p-1+f-png+w-500+h-1000', + 200, + 'image/png', + ], + ]; + } + + #[Test] + #[Depends('ensurePdfDerivativeCanBeDownloaded')] + #[Depends('ensurePdfDerivativeImagesCanBeDownloaded')] + public function ensureUnprocessedFilesAreNotAvailable(Version $version) + { + $version->Media->Versions->each->update(['processed' => 0]); + + $this->get(route('getPdfDerivative', [$this->user->name, $version->Media]))->assertNotFound(); + + return $version; + } + + #[Test] + #[Depends('ensurePdfDerivativeCanBeDownloaded')] + public function ensurePdfMetadataIsRemoved(Version $version) + { + $config = new PdfParserConfig(); + $config->setRetainImageContent(false); + $pdfParser = new Parser([], $config); + + // Empty values are filtered for easier comparison. + $originalMetadata = array_filter($pdfParser->parseFile($this->originalsDisk->path($version->originalFilePath()))->getDetails()); + $derivativeMetadata = array_filter($pdfParser->parseFile($this->pdfDerivativesDisk->path($version->nonVideoDerivativeFilePath()))->getDetails()); + + $metadataComparisonKeys = [ + 'CreationDate', + 'Subject', + 'Author', + 'Creator', + 'Producer', + 'ModDate', + 'Title', + 'pdf:producer', + 'xap:creatortool', + 'xap:modifydate', + 'xap:createdate', + 'xap:metadatadate', + 'dc:title', + 'dc:creator', + 'dc:description', + 'xapmm:documentid', + 'xapmm:instanceid', + ]; + + foreach ($metadataComparisonKeys as $key) { + array_key_exists($key, $originalMetadata) + && array_key_exists($key, $derivativeMetadata) + && $this->assertNotEquals($originalMetadata[$key], $derivativeMetadata[$key]); + } + } + + #[Test] + #[Depends('ensurePdfDerivativeCanBeDownloaded')] + public function ensurePdfDerivativesArePurged(Version $version) + { + $this->pdfDerivativesDisk->assertExists($version->nonVideoDerivativeFilePath()); + + $cacheCounterBeforeCommand = $this->originalsDisk->get(config('transmorpher.cache_invalidation_counter_file_path')); + + Http::fake([ + $this->user->api_url => Http::response() + ]); + + Artisan::call(PurgeDerivatives::class, ['--pdf' => true]); + + $cacheCounterAfterCommand = $this->originalsDisk->get(config('transmorpher.cache_invalidation_counter_file_path')); + + Http::assertSent(function (Request $request) use ($cacheCounterAfterCommand) { + $decryptedNotification = json_decode(SodiumHelper::decrypt($request['signed_notification']), true); + + return $request->url() == $this->user->api_url + && $decryptedNotification['notification_type'] == ClientNotification::CACHE_INVALIDATION->value + && $decryptedNotification['cache_invalidator'] == $cacheCounterAfterCommand; + }); + + $this->assertTrue(++$cacheCounterBeforeCommand == $cacheCounterAfterCommand); + $this->pdfDerivativesDisk->assertMissing($version->nonVideoDerivativeFilePath()); + } +} diff --git a/tests/data/test.pdf b/tests/data/test.pdf new file mode 100644 index 00000000..95ca7029 Binary files /dev/null and b/tests/data/test.pdf differ