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: