- BrandingAdminController: GET/PUT /api/my-branding (Farben, Tagline), Logo-Upload/-Löschen, öffentliche Logo-Auslieferung /api/branding/logo/... - BrandingService liefert logoUrl aus S3-logoKey (Firma → Reseller → Default) - BrandingView (Reseller-Topnav + Firmen-Sidebar): Farbwähler, Slogan, Logo-Upload mit Live-Vorschau, Anzeige der eigenen Adresse Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
7.6 KiB
PHP
195 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Entity\Company;
|
|
use App\Entity\Reseller;
|
|
use App\Security\TenantContext;
|
|
use App\Service\BrandingService;
|
|
use App\Service\TenantResolver;
|
|
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\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;
|
|
|
|
/**
|
|
* White-Label-Branding pflegen: Reseller-Admin brandet seinen Reseller,
|
|
* Firmen-Admin seine Firma (Farben, Tagline, Logo). Plus öffentliche Logo-Auslieferung.
|
|
*/
|
|
final class BrandingAdminController
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly TenantContext $tenant,
|
|
private readonly TenantResolver $resolver,
|
|
private readonly BrandingService $branding,
|
|
#[Autowire(service: 'card_assets.storage')]
|
|
private readonly FilesystemOperator $cardAssets,
|
|
) {
|
|
}
|
|
|
|
#[Route('/api/my-branding', name: 'my_branding_get', methods: ['GET'])]
|
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
|
public function get(): JsonResponse
|
|
{
|
|
return new JsonResponse($this->serialize());
|
|
}
|
|
|
|
#[Route('/api/my-branding', name: 'my_branding_put', methods: ['PUT'])]
|
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
|
public function put(Request $request): JsonResponse
|
|
{
|
|
[, $entity] = $this->target();
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$cfg = $entity->getBrandingConfig();
|
|
|
|
foreach (['primaryColor', 'primaryColorDark', 'primaryColorSoft'] as $key) {
|
|
if (\array_key_exists($key, $data)) {
|
|
$hex = $this->hex($data[$key] ?? null);
|
|
if (null !== $hex) {
|
|
$cfg[$key] = $hex;
|
|
} else {
|
|
unset($cfg[$key]);
|
|
}
|
|
}
|
|
}
|
|
if (\array_key_exists('tagline', $data)) {
|
|
$tag = trim((string) ($data['tagline'] ?? ''));
|
|
if ('' !== $tag) {
|
|
$cfg['tagline'] = mb_substr($tag, 0, 120);
|
|
} else {
|
|
unset($cfg['tagline']);
|
|
}
|
|
}
|
|
|
|
$entity->setBrandingConfig($cfg);
|
|
$this->em->flush();
|
|
|
|
return new JsonResponse($this->serialize());
|
|
}
|
|
|
|
#[Route('/api/my-branding/logo', name: 'my_branding_logo_post', methods: ['POST'])]
|
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
|
public function uploadLogo(Request $request): JsonResponse
|
|
{
|
|
[$scope, $entity] = $this->target();
|
|
$file = $request->files->get('file');
|
|
if (!$file instanceof UploadedFile) {
|
|
throw new BadRequestHttpException('Keine Datei (Feld "file").');
|
|
}
|
|
$ext = strtolower((string) $file->getClientOriginalExtension());
|
|
if (!\in_array($ext, ['png', 'jpg', 'jpeg', 'svg'], true)) {
|
|
throw new BadRequestHttpException('Nur PNG, JPG oder SVG erlaubt.');
|
|
}
|
|
|
|
$cfg = $entity->getBrandingConfig();
|
|
$old = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
|
|
$key = sprintf('branding/%s-%s/logo-%s.%s', $scope, $entity->getId()->toRfc4122(), bin2hex(random_bytes(4)), $ext);
|
|
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
|
|
if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) {
|
|
$this->cardAssets->delete($old);
|
|
}
|
|
$cfg['logoKey'] = $key;
|
|
$entity->setBrandingConfig($cfg);
|
|
$this->em->flush();
|
|
|
|
return new JsonResponse($this->serialize(), 201);
|
|
}
|
|
|
|
#[Route('/api/my-branding/logo', name: 'my_branding_logo_delete', methods: ['DELETE'])]
|
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
|
public function deleteLogo(): JsonResponse
|
|
{
|
|
[, $entity] = $this->target();
|
|
$cfg = $entity->getBrandingConfig();
|
|
$key = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
|
|
if (null !== $key && $this->cardAssets->fileExists($key)) {
|
|
$this->cardAssets->delete($key);
|
|
}
|
|
unset($cfg['logoKey']);
|
|
$entity->setBrandingConfig($cfg);
|
|
$this->em->flush();
|
|
|
|
return new JsonResponse($this->serialize());
|
|
}
|
|
|
|
/** Öffentliche Logo-Auslieferung (vom Branding-Endpoint verlinkt). */
|
|
#[Route('/api/branding/logo/{scope}/{id}.png', name: 'branding_logo', methods: ['GET'])]
|
|
public function logo(string $scope, string $id): Response
|
|
{
|
|
$entity = $this->load($scope, $id);
|
|
$key = \is_string($entity->getBrandingConfig()['logoKey'] ?? null) ? $entity->getBrandingConfig()['logoKey'] : null;
|
|
if (null === $key || !$this->cardAssets->fileExists($key)) {
|
|
throw new NotFoundHttpException('Kein Logo.');
|
|
}
|
|
$bytes = $this->cardAssets->read($key);
|
|
$mime = str_ends_with($key, '.svg') ? 'image/svg+xml' : (str_ends_with($key, '.png') ? 'image/png' : 'image/jpeg');
|
|
|
|
return new Response($bytes, 200, ['Content-Type' => $mime, 'Cache-Control' => 'public, max-age=86400']);
|
|
}
|
|
|
|
/** @return array{0:string,1:Company|Reseller} */
|
|
private function target(): array
|
|
{
|
|
$company = $this->tenant->getCompany();
|
|
if ($company instanceof Company) {
|
|
return [BrandingService::SCOPE_COMPANY, $company];
|
|
}
|
|
$reseller = $this->tenant->getReseller();
|
|
if ($reseller instanceof Reseller) {
|
|
return [BrandingService::SCOPE_RESELLER, $reseller];
|
|
}
|
|
throw new AccessDeniedHttpException('Kein Branding-Kontext.');
|
|
}
|
|
|
|
private function load(string $scope, string $id): Company|Reseller
|
|
{
|
|
$uuid = Uuid::fromString($id);
|
|
$entity = BrandingService::SCOPE_COMPANY === $scope
|
|
? $this->em->getRepository(Company::class)->find($uuid)
|
|
: $this->em->getRepository(Reseller::class)->find($uuid);
|
|
if (!$entity instanceof Company && !$entity instanceof Reseller) {
|
|
throw new NotFoundHttpException('Nicht gefunden.');
|
|
}
|
|
|
|
return $entity;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function serialize(): array
|
|
{
|
|
[$scope, $entity] = $this->target();
|
|
$cfg = $entity->getBrandingConfig();
|
|
$tenant = $entity instanceof Company
|
|
? new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_COMPANY, $entity->getReseller(), $entity)
|
|
: new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_RESELLER, $entity);
|
|
$host = $entity instanceof Company ? $this->resolver->companyHost($entity) : $this->resolver->resellerHost($entity);
|
|
|
|
return [
|
|
'scope' => $scope,
|
|
'name' => $entity->getName(),
|
|
'address' => $host,
|
|
'primaryColor' => $cfg['primaryColor'] ?? '#f58220',
|
|
'primaryColorDark' => $cfg['primaryColorDark'] ?? '#d96500',
|
|
'primaryColorSoft' => $cfg['primaryColorSoft'] ?? '#fff2e7',
|
|
'tagline' => $cfg['tagline'] ?? '',
|
|
'hasLogo' => '' !== (string) ($cfg['logoKey'] ?? ''),
|
|
'logoUrl' => $this->branding->logoUrl($tenant),
|
|
];
|
|
}
|
|
|
|
private function hex(mixed $value): ?string
|
|
{
|
|
return (\is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null;
|
|
}
|
|
}
|