From 67e4353c8d84d8aa45e6a0cea9b3d1f08577fd2b Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Sun, 31 May 2026 20:56:51 +0200 Subject: [PATCH] Skalierbarkeit: Druck-Assets in S3-Object-Storage (Flysystem) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macht die App-Nodes zustandslos (horizontal skalierbar): Hintergrund-PDFs und Schriften liegen nicht mehr lokal, sondern im S3-kompatiblen Object Storage (Flysystem + async-aws). In der DB stehen Storage-Keys. - flysystem-bundle + async-aws (Storage "card_assets"), env-getrieben (S3_ENDPOINT/REGION/BUCKET/KEY/SECRET/PATH_STYLE) → lokal MinIO, prod Hetzner OS - CardAssetUploadController: Upload/Read/Delete über Storage; GET streamt PDF - CardPdfRenderer: liest Hintergrund (FPDI StreamReader) & Schriften (Temp-Datei) aus S3 - docker-compose: minio + minio-init (Bucket) + zweiter App-Node php2 (Profil scale-test) - app:render-card Command für den Cross-Node-Nachweis Verifiziert: Upload über Node 1 → identisches PDF-Render (51897 B, mit Hintergrund) auf Node 2, der nur DB + Object Storage liest. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + backend/.env | 10 + backend/composer.json | 3 + backend/composer.lock | 783 +++++++++++++++++- backend/config/bundles.php | 2 + backend/config/packages/async_aws.yaml | 11 + backend/config/packages/flysystem.yaml | 11 + backend/src/Command/RenderCardCommand.php | 62 ++ .../Controller/CardAssetUploadController.php | 52 +- backend/src/Service/CardPdfRenderer.php | 37 +- backend/symfony.lock | 16 + docker-compose.yml | 40 + 12 files changed, 991 insertions(+), 39 deletions(-) create mode 100644 backend/config/packages/async_aws.yaml create mode 100644 backend/config/packages/flysystem.yaml create mode 100644 backend/src/Command/RenderCardCommand.php diff --git a/.gitignore b/.gitignore index e23ee2a..ec3679e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ # Playwright-Artefakte /.playwright-mcp/ backend/.playwright-mcp/ + +# Auto-generierte Symfony-Config-Referenz +/backend/config/reference.php diff --git a/backend/.env b/backend/.env index 167e09d..6299bca 100644 --- a/backend/.env +++ b/backend/.env @@ -46,6 +46,16 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a ###< lexik/jwt-authentication-bundle ### +###> S3 / Object Storage (Druck-Assets) ### +# Lokal: MinIO. Prod: Hetzner Object Storage (Werte in .env.local / Server-Env setzen). +S3_ENDPOINT=http://minio:9000 +S3_REGION=us-east-1 +S3_BUCKET=card-assets +S3_KEY=minioadmin +S3_SECRET=minioadmin +S3_PATH_STYLE=true +###< S3 / Object Storage ### + ###> symfony/messenger ### # Choose one of the transports below # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages diff --git a/backend/composer.json b/backend/composer.json index 6146832..1fc1551 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -9,10 +9,13 @@ "ext-iconv": "*", "api-platform/doctrine-orm": "^4.3", "api-platform/symfony": "^4.3", + "async-aws/async-aws-bundle": "^1.17", "doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", "endroid/qr-code": "^6.1", + "league/flysystem-async-aws-s3": "^3.31", + "league/flysystem-bundle": "^3.7", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", "phpdocumentor/reflection-docblock": "^6.0", diff --git a/backend/composer.lock b/backend/composer.lock index 04066ec..58ebfaa 100644 --- a/backend/composer.lock +++ b/backend/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": "76223bb8137f9fc4c551962833c3a836", + "content-hash": "56976c338e2f27b4bcb9814439daefd5", "packages": [ { "name": "api-platform/doctrine-common", @@ -1161,6 +1161,217 @@ }, "time": "2026-05-07T11:45:31+00:00" }, + { + "name": "async-aws/async-aws-bundle", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/async-aws/symfony-bundle.git", + "reference": "256fb077c36f1f8f9cf55cd3e6853c4035636cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/async-aws/symfony-bundle/zipball/256fb077c36f1f8f9cf55cd3e6853c4035636cfa", + "reference": "256fb077c36f1f8f9cf55cd3e6853c4035636cfa", + "shasum": "" + }, + "require": { + "async-aws/core": "^1.0", + "php": "^8.2", + "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-kernel": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "async-aws/s3": "^1.0 || ^2.0 || ^3.0", + "async-aws/ses": "^1.0", + "async-aws/sqs": "^1.0 || ^2.0", + "async-aws/ssm": "^1.0 || ^2.0", + "matthiasnoback/symfony-config-test": "^6.1", + "nyholm/symfony-bundle-test": "^3.0", + "phpunit/phpunit": "^11.5.42", + "symfony/cache": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/error-handler": "^7.3.2 || ^8.0", + "symfony/framework-bundle": "^5.4.45 || ^6.4.13 || ^7.1.6", + "symfony/phpunit-bridge": "^7.3.2 || ^8.0", + "symfony/runtime": "^7.3.2 || ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + } + }, + "autoload": { + "psr-4": { + "AsyncAws\\Symfony\\Bundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Configure all your AsyncAws services and enjoy the autowire greatness.", + "keywords": [ + "amazon", + "async-aws", + "aws", + "bundle", + "sdk", + "symfony" + ], + "support": { + "source": "https://github.com/async-aws/symfony-bundle/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/jderusse", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2026-04-18T17:06:10+00:00" + }, + { + "name": "async-aws/core", + "version": "1.29.0", + "source": { + "type": "git", + "url": "https://github.com/async-aws/core.git", + "reference": "70899695fcc7b23a9247926ff8b581668583b993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/async-aws/core/zipball/70899695fcc7b23a9247926ff8b581668583b993", + "reference": "70899695fcc7b23a9247926ff8b581668583b993", + "shasum": "" + }, + "require": { + "ext-hash": "*", + "ext-simplexml": "*", + "php": "^8.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/http-client": "^4.4.16 || ^5.1.7 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-client-contracts": "^1.1.8 || ^2.0 || ^3.0", + "symfony/service-contracts": "^1.0 || ^2.0 || ^3.0" + }, + "conflict": { + "async-aws/s3": "<1.1", + "symfony/http-client": "5.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.42", + "symfony/error-handler": "^7.3.2 || ^8.0", + "symfony/phpunit-bridge": "^7.3.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.29-dev" + } + }, + "autoload": { + "psr-4": { + "AsyncAws\\Core\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Core package to integrate with AWS. This is a lightweight AWS SDK provider by AsyncAws.", + "keywords": [ + "amazon", + "async-aws", + "aws", + "sdk", + "sts" + ], + "support": { + "source": "https://github.com/async-aws/core/tree/1.29.0" + }, + "funding": [ + { + "url": "https://github.com/jderusse", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2026-04-18T17:06:10+00:00" + }, + { + "name": "async-aws/s3", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/async-aws/s3.git", + "reference": "26bf7e498df4fd135fb2c32cbaa5ba92c0615fa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/async-aws/s3/zipball/26bf7e498df4fd135fb2c32cbaa5ba92c0615fa3", + "reference": "26bf7e498df4fd135fb2c32cbaa5ba92c0615fa3", + "shasum": "" + }, + "require": { + "async-aws/core": "^1.22", + "ext-dom": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-simplexml": "*", + "php": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.42", + "symfony/error-handler": "^7.3.2 || ^8.0", + "symfony/phpunit-bridge": "^7.3.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3-dev" + } + }, + "autoload": { + "psr-4": { + "AsyncAws\\S3\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "S3 client, part of the AWS SDK provided by AsyncAws.", + "keywords": [ + "amazon", + "async-aws", + "aws", + "s3", + "sdk" + ], + "support": { + "source": "https://github.com/async-aws/s3/tree/3.3.0" + }, + "funding": [ + { + "url": "https://github.com/jderusse", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2026-04-29T10:05:33+00:00" + }, { "name": "bacon/bacon-qr-code", "version": "v3.1.1", @@ -2693,6 +2904,322 @@ ], "time": "2025-10-17T11:30:53+00:00" }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-async-aws-s3", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-async-aws-s3.git", + "reference": "6928568d3fe24afc4d7d583fc1844d0c1d07651b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-async-aws-s3/zipball/6928568d3fe24afc4d7d583fc1844d0c1d07651b", + "reference": "6928568d3fe24afc4d7d583fc1844d0c1d07651b", + "shasum": "" + }, + "require": { + "async-aws/s3": "^1.5 || ^2.0 || ^3.0", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/simple-s3": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AsyncAwsS3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AsyncAws S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "async-aws", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-async-aws-s3/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/flysystem-bundle", + "version": "3.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-bundle.git", + "reference": "5eb41be38fc3759f74c9e458a6a5f0ef5f49284a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/5eb41be38fc3759f74c9e458a6a5f0ef5f49284a", + "reference": "5eb41be38fc3759f74c9e458a6a5f0ef5f49284a", + "shasum": "" + }, + "require": { + "league/flysystem": "^3.0", + "php": ">=8.2", + "symfony/config": "^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.0 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/http-kernel": "^6.0 || ^7.0 || ^8.0", + "symfony/options-resolver": "^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/mongodb-odm": "^2.0", + "league/flysystem-async-aws-s3": "^3.1", + "league/flysystem-aws-s3-v3": "^3.1", + "league/flysystem-azure-blob-storage": "^3.1", + "league/flysystem-ftp": "^3.1", + "league/flysystem-google-cloud-storage": "^3.1", + "league/flysystem-gridfs": "^3.28", + "league/flysystem-memory": "^3.1", + "league/flysystem-read-only": "^3.15", + "league/flysystem-sftp-v3": "^3.1", + "league/flysystem-webdav": "^3.29", + "platformcommunity/flysystem-bunnycdn": "^3.3", + "symfony/dotenv": "^6.0 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^6.0 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^6.0 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "League\\FlysystemBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + } + ], + "description": "Symfony bundle integrating Flysystem into Symfony applications", + "keywords": [ + "Flysystem", + "bundle", + "filesystem", + "symfony" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem-bundle/issues", + "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.7.0" + }, + "time": "2026-03-28T22:14:56+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, { "name": "lexik/jwt-authentication-bundle", "version": "v3.2.0", @@ -5017,6 +5544,189 @@ ], "time": "2026-05-23T18:04:28+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T09:57:54+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:50+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.4.13", @@ -5312,6 +6022,77 @@ ], "time": "2026-05-19T07:02:47+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/password-hasher", "version": "v7.4.8", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 7c51105..a0e473c 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -10,4 +10,6 @@ return [ ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], + League\FlysystemBundle\FlysystemBundle::class => ['all' => true], + AsyncAws\Symfony\Bundle\AsyncAwsBundle::class => ['all' => true], ]; diff --git a/backend/config/packages/async_aws.yaml b/backend/config/packages/async_aws.yaml new file mode 100644 index 0000000..60b095e --- /dev/null +++ b/backend/config/packages/async_aws.yaml @@ -0,0 +1,11 @@ +async_aws: + clients: + card_assets: + type: s3 + config: + region: '%env(S3_REGION)%' + endpoint: '%env(S3_ENDPOINT)%' + # Path-Style für MinIO (true). Hetzner Object Storage funktioniert auch mit true. + pathStyleEndpoint: '%env(bool:S3_PATH_STYLE)%' + accessKeyId: '%env(S3_KEY)%' + accessKeySecret: '%env(S3_SECRET)%' diff --git a/backend/config/packages/flysystem.yaml b/backend/config/packages/flysystem.yaml new file mode 100644 index 0000000..73dba13 --- /dev/null +++ b/backend/config/packages/flysystem.yaml @@ -0,0 +1,11 @@ +# https://github.com/thephpleague/flysystem-bundle +flysystem: + storages: + # Druck-Assets (Hintergrund-PDFs, Schriften) im S3-kompatiblen Object Storage. + # Lokal: MinIO. Prod: Hetzner Object Storage. Gleicher Code, andere Env. + card_assets.storage: + adapter: 'asyncaws' + options: + client: 'async_aws.client.card_assets' + bucket: '%env(S3_BUCKET)%' + prefix: 'cards' diff --git a/backend/src/Command/RenderCardCommand.php b/backend/src/Command/RenderCardCommand.php new file mode 100644 index 0000000..781e49d --- /dev/null +++ b/backend/src/Command/RenderCardCommand.php @@ -0,0 +1,62 @@ +addArgument('slug', InputArgument::REQUIRED, 'Employee-Slug'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $employee = $this->employees->findOneBy(['slug' => $input->getArgument('slug')]); + if (null === $employee) { + $output->writeln('Mitarbeiter nicht gefunden.'); + + return Command::FAILURE; + } + + $template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default(); + $pdf = $this->renderer->render($employee, $template); + + $file = sprintf('/tmp/render-%s.pdf', gethostname()); + file_put_contents($file, $pdf); + + $output->writeln(sprintf( + 'Node %s: %d bytes, Hintergrund=%s, Datei=%s', + gethostname(), + strlen($pdf), + $template->getBackgroundPath() ? 'ja' : 'nein', + $file, + )); + + return Command::SUCCESS; + } +} diff --git a/backend/src/Controller/CardAssetUploadController.php b/backend/src/Controller/CardAssetUploadController.php index 2c62a90..8c7b967 100644 --- a/backend/src/Controller/CardAssetUploadController.php +++ b/backend/src/Controller/CardAssetUploadController.php @@ -7,12 +7,13 @@ use App\Entity\Company; use App\Repository\CardTemplateRepository; use App\Security\TenantContext; use Doctrine\ORM\EntityManagerInterface; +use League\Flysystem\FilesystemOperator; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -21,8 +22,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Uid\Uuid; /** - * Lädt Druck-Assets einer Firma hoch: Hintergrund-PDF (Variable Data Printing) - * und eigene Schriften (eingebettet ins PDF). Dateien liegen außerhalb des Webroots. + * Lädt Druck-Assets einer Firma in den (S3-)Object-Storage: Hintergrund-PDF + * (Variable Data Printing) und eigene Schriften. In der DB stehen Storage-Keys, + * keine lokalen Pfade — dadurch nodeübergreifend lesbar (horizontal skalierbar). */ #[IsGranted('ROLE_COMPANY_ADMIN')] final class CardAssetUploadController @@ -31,8 +33,8 @@ final class CardAssetUploadController private readonly CardTemplateRepository $templates, private readonly EntityManagerInterface $em, private readonly TenantContext $tenant, - #[Autowire('%kernel.project_dir%/var/storage/cards')] - private readonly string $storageDir, + #[Autowire(service: 'card_assets.storage')] + private readonly FilesystemOperator $cardAssets, ) { } @@ -41,30 +43,31 @@ final class CardAssetUploadController { $company = $this->company($id); $file = $this->file($request); - if (!in_array(strtolower((string) $file->getClientOriginalExtension()), ['pdf'], true)) { + if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) { throw new BadRequestHttpException('Nur PDF erlaubt.'); } $template = $this->getOrCreate($company); - $path = $this->store($file, $company->getId(), 'background', 'pdf'); - $template->setBackgroundPath($path); + $key = $this->store($file, $company->getId(), 'background', 'pdf'); + $template->setBackgroundPath($key); $this->em->persist($template); $this->em->flush(); - return new JsonResponse(['backgroundPath' => $path, 'fileName' => $file->getClientOriginalName()], 201); + return new JsonResponse(['backgroundKey' => $key, 'fileName' => $file->getClientOriginalName()], 201); } #[Route('/api/companies/{id}/card-template/background', name: 'card_bg_get', methods: ['GET'])] public function getBackground(string $id): Response { $company = $this->company($id); - $template = $this->templates->findCardForCompany($company); - $path = $template?->getBackgroundPath(); - if (!$path || !is_file($path)) { + $key = $this->templates->findCardForCompany($company)?->getBackgroundPath(); + if (!$key || !$this->cardAssets->fileExists($key)) { throw new NotFoundHttpException('Kein Hintergrund-PDF.'); } - return new BinaryFileResponse($path, 200, ['Content-Type' => 'application/pdf']); + return new StreamedResponse(function () use ($key) { + fpassthru($this->cardAssets->readStream($key)); + }, 200, ['Content-Type' => 'application/pdf']); } #[Route('/api/companies/{id}/card-template/background', name: 'card_bg_delete', methods: ['DELETE'])] @@ -72,15 +75,15 @@ final class CardAssetUploadController { $company = $this->company($id); $template = $this->templates->findCardForCompany($company); - if ($template) { - if ($template->getBackgroundPath() && is_file($template->getBackgroundPath())) { - @unlink($template->getBackgroundPath()); + if ($template && $template->getBackgroundPath()) { + if ($this->cardAssets->fileExists($template->getBackgroundPath())) { + $this->cardAssets->delete($template->getBackgroundPath()); } $template->setBackgroundPath(null); $this->em->flush(); } - return new JsonResponse(['backgroundPath' => null]); + return new JsonResponse(['backgroundKey' => null]); } #[Route('/api/companies/{id}/card-template/font', name: 'card_font_upload', methods: ['POST'])] @@ -95,8 +98,8 @@ final class CardAssetUploadController $family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); $template = $this->getOrCreate($company); - $path = $this->store($file, $company->getId(), 'font', $ext); - $template->addFont($family, $path); + $key = $this->store($file, $company->getId(), 'font', $ext); + $template->addFont($family, $key); $this->em->flush(); return new JsonResponse(['fonts' => $template->getFonts()], 201); @@ -112,16 +115,13 @@ final class CardAssetUploadController return $file; } + /** Lädt die Datei in den Object-Storage und liefert den Key zurück. */ private function store(UploadedFile $file, Uuid $companyId, string $prefix, string $ext): string { - $dir = $this->storageDir.'/'.$companyId->toRfc4122(); - if (!is_dir($dir)) { - @mkdir($dir, 0775, true); - } - $name = $prefix.'-'.bin2hex(random_bytes(4)).'.'.$ext; - $file->move($dir, $name); + $key = sprintf('%s/%s-%s.%s', $companyId->toRfc4122(), $prefix, bin2hex(random_bytes(4)), $ext); + $this->cardAssets->write($key, (string) file_get_contents($file->getPathname())); - return $dir.'/'.$name; + return $key; } private function getOrCreate(Company $company): CardTemplate diff --git a/backend/src/Service/CardPdfRenderer.php b/backend/src/Service/CardPdfRenderer.php index 6b1bc6e..21630bf 100644 --- a/backend/src/Service/CardPdfRenderer.php +++ b/backend/src/Service/CardPdfRenderer.php @@ -7,6 +7,9 @@ use App\Entity\Employee; use Endroid\QrCode\Builder\Builder; use Endroid\QrCode\ErrorCorrectionLevel; use Endroid\QrCode\Writer\PngWriter; +use League\Flysystem\FilesystemOperator; +use setasign\Fpdi\PdfParser\StreamReader; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** @@ -21,8 +24,11 @@ final class CardPdfRenderer /** @var array family → eingebetteter TCPDF-Fontname */ private array $fontMap = []; - public function __construct(private readonly UrlGeneratorInterface $urls) - { + public function __construct( + private readonly UrlGeneratorInterface $urls, + #[Autowire(service: 'card_assets.storage')] + private readonly FilesystemOperator $cardAssets, + ) { } public function render(Employee $employee, CardTemplate $template): string @@ -30,8 +36,9 @@ final class CardPdfRenderer $branding = $this->branding($employee); $bleed = $template->getBleedMm(); - $bgPath = $template->getBackgroundPath(); - $hasBg = $bgPath && is_file($bgPath); + $bgKey = $template->getBackgroundPath(); + $hasBg = $bgKey && $this->cardAssets->fileExists($bgKey); + $bgReader = $hasBg ? StreamReader::createByString($this->cardAssets->read($bgKey)) : null; // Mit Hintergrund-PDF: Seite = Endformat+Beschnitt, keine eigenen Schnittmarken // (der Kunde liefert Beschnitt/Marken selbst). Sonst Rand für Schnittmarken. @@ -51,7 +58,7 @@ final class CardPdfRenderer $this->fontMap = $this->registerFonts($template); - $bgPages = $hasBg ? $pdf->setSourceFile($bgPath) : 0; + $bgPages = $hasBg ? $pdf->setSourceFile($bgReader) : 0; foreach ([$template->getFront(), $template->getBack()] as $i => $elements) { $pdf->AddPage('L', [$pw, $ph]); @@ -79,14 +86,20 @@ final class CardPdfRenderer { $map = []; foreach ($template->getFonts() as $f) { - $path = $f['path'] ?? ''; + $key = $f['path'] ?? ''; $family = $f['family'] ?? ''; - if ('' !== $family && is_file($path)) { - try { - $map[$family] = \TCPDF_FONTS::addTTFfont($path, 'TrueTypeUnicode', '', 32); - } catch (\Throwable) { - // nicht konvertierbar → Fallback auf Core-Font - } + if ('' === $family || '' === $key || !$this->cardAssets->fileExists($key)) { + continue; + } + // TCPDF braucht eine echte Datei → Schrift aus dem Storage in eine Temp-Datei + $tmp = tempnam(sys_get_temp_dir(), 'fnt'); + file_put_contents($tmp, $this->cardAssets->read($key)); + try { + $map[$family] = \TCPDF_FONTS::addTTFfont($tmp, 'TrueTypeUnicode', '', 32); + } catch (\Throwable) { + // nicht konvertierbar → Fallback auf Core-Font + } finally { + @unlink($tmp); } } diff --git a/backend/symfony.lock b/backend/symfony.lock index e974e10..8c7925a 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -13,6 +13,9 @@ "src/ApiResource/.gitignore" ] }, + "async-aws/async-aws-bundle": { + "version": "1.17.0" + }, "doctrine/deprecations": { "version": "1.1", "recipe": { @@ -49,6 +52,19 @@ "migrations/.gitignore" ] }, + "league/flysystem-bundle": { + "version": "3.7", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380" + }, + "files": [ + "config/packages/flysystem.yaml", + "var/storage/.gitignore" + ] + }, "lexik/jwt-authentication-bundle": { "version": "3.2", "recipe": { diff --git a/docker-compose.yml b/docker-compose.yml index e631993..30fa1e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,22 @@ services: environment: DATABASE_URL: "mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4" + # Zweiter, identischer App-Node — zum Beweis, dass Assets/Auth nodeübergreifend laufen + php2: + build: + context: ./docker/php + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + volumes: + - ./backend:/app + depends_on: + mariadb: + condition: service_healthy + environment: + DATABASE_URL: "mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4" + profiles: ["scale-test"] + nginx: image: nginx:1.27-alpine ports: @@ -23,6 +39,29 @@ services: depends_on: - php + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + + # Legt den Bucket einmalig an + minio-init: + image: minio/mc + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done; + mc mb -p local/card-assets || true; + " + mariadb: image: mariadb:11.4 ports: @@ -52,3 +91,4 @@ services: volumes: mariadb_data: + minio_data: