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>
154 lines
6.1 KiB
PHP
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;
|
|
}
|
|
}
|