vcard4reseller/backend/src/Controller/CardAssetUploadController.php
Thomas Peterson 67e4353c8d Skalierbarkeit: Druck-Assets in S3-Object-Storage (Flysystem)
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 <noreply@anthropic.com>
2026-05-31 20:56:51 +02:00

154 lines
6.1 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\CardTemplate;
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\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;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* 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
{
public function __construct(
private readonly CardTemplateRepository $templates,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_upload', methods: ['POST'])]
public function uploadBackground(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$file = $this->file($request);
if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) {
throw new BadRequestHttpException('Nur PDF erlaubt.');
}
$template = $this->getOrCreate($company);
$key = $this->store($file, $company->getId(), 'background', 'pdf');
$template->setBackgroundPath($key);
$this->em->persist($template);
$this->em->flush();
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);
$key = $this->templates->findCardForCompany($company)?->getBackgroundPath();
if (!$key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Hintergrund-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'])]
public function deleteBackground(string $id): JsonResponse
{
$company = $this->company($id);
$template = $this->templates->findCardForCompany($company);
if ($template && $template->getBackgroundPath()) {
if ($this->cardAssets->fileExists($template->getBackgroundPath())) {
$this->cardAssets->delete($template->getBackgroundPath());
}
$template->setBackgroundPath(null);
$this->em->flush();
}
return new JsonResponse(['backgroundKey' => null]);
}
#[Route('/api/companies/{id}/card-template/font', name: 'card_font_upload', methods: ['POST'])]
public function uploadFont(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$file = $this->file($request);
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($ext, ['ttf', 'otf'], true)) {
throw new BadRequestHttpException('Nur TTF/OTF erlaubt.');
}
$family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$template = $this->getOrCreate($company);
$key = $this->store($file, $company->getId(), 'font', $ext);
$template->addFont($family, $key);
$this->em->flush();
return new JsonResponse(['fonts' => $template->getFonts()], 201);
}
private function file(Request $request): UploadedFile
{
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Keine Datei (Feld "file") übermittelt.');
}
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
{
$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 $key;
}
private function getOrCreate(Company $company): CardTemplate
{
return $this->templates->findCardForCompany($company)
?? (new CardTemplate())->setCompany($company);
}
private function company(string $id): Company
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $company;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $company;
}
}