From 488ddc115ff4205584dcdfed256e58ae73d6980c Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Thu, 4 Jun 2026 19:01:34 +0200 Subject: [PATCH] Wallet-Design pro Firma (Backend): Farben, Titel, Logo, Felder/Slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Company.walletConfig (json, Migration mit MariaDB-sicherem Backfill). - WalletService liest die Firmen-Config: Hintergrund-/Text-/Label-Farbe, Titel, echtes Firmenlogo (aus Object-Storage, in pass.json-Bilder skaliert; Google via öffentliche Logo-URL), frei wählbare Datenfelder mit Label je Apple-Slot (primary/secondary/auxiliary/back) → Google-Header/Textmodule. - WalletDesignController: GET/PUT Design, POST/DELETE Logo (S3); öffentliche Logo-Route /w/logo/{companyId}.png. Beispieldaten (1. Mitarbeiter) für Vorschau. - Verifiziert: pass.json übernimmt Farben/Titel/Felder, Signatur bleibt gültig. Co-Authored-By: Claude Opus 4.8 --- backend/migrations/Version20260604165342.php | 34 +++ backend/src/Controller/WalletController.php | 15 + .../src/Controller/WalletDesignController.php | 193 ++++++++++++ backend/src/Entity/Company.php | 16 + backend/src/Service/WalletService.php | 283 ++++++++++++------ 5 files changed, 451 insertions(+), 90 deletions(-) create mode 100644 backend/migrations/Version20260604165342.php create mode 100644 backend/src/Controller/WalletDesignController.php diff --git a/backend/migrations/Version20260604165342.php b/backend/migrations/Version20260604165342.php new file mode 100644 index 0000000..62bcf4d --- /dev/null +++ b/backend/migrations/Version20260604165342.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE company ADD wallet_config JSON DEFAULT NULL'); + $this->addSql("UPDATE company SET wallet_config = '[]' WHERE wallet_config IS NULL"); + $this->addSql('ALTER TABLE company MODIFY wallet_config JSON NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE company DROP wallet_config'); + } +} diff --git a/backend/src/Controller/WalletController.php b/backend/src/Controller/WalletController.php index a351296..2cad0f0 100644 --- a/backend/src/Controller/WalletController.php +++ b/backend/src/Controller/WalletController.php @@ -2,7 +2,9 @@ namespace App\Controller; +use App\Entity\Company; use App\Entity\Employee; +use App\Repository\CompanyRepository; use App\Repository\EmployeeRepository; use App\Service\WalletService; use Endroid\QrCode\Builder\Builder; @@ -24,10 +26,23 @@ final class WalletController extends AbstractController { public function __construct( private readonly EmployeeRepository $employees, + private readonly CompanyRepository $companies, private readonly WalletService $wallet, ) { } + #[Route('/w/logo/{companyId}.png', name: 'wallet_logo', methods: ['GET'])] + public function logo(string $companyId): Response + { + $company = $this->companies->find(\Symfony\Component\Uid\Uuid::fromString($companyId)); + $bytes = $company instanceof Company ? $this->wallet->logoBytes($company) : null; + if (null === $bytes) { + throw $this->createNotFoundException('Kein Wallet-Logo.'); + } + + return new Response($bytes, 200, ['Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=86400']); + } + #[Route('/w/{code}', name: 'wallet_landing', methods: ['GET'])] public function landing(string $code, Request $request): Response { diff --git a/backend/src/Controller/WalletDesignController.php b/backend/src/Controller/WalletDesignController.php new file mode 100644 index 0000000..b744a83 --- /dev/null +++ b/backend/src/Controller/WalletDesignController.php @@ -0,0 +1,193 @@ + 'Name', 'firstName' => 'Vorname', 'lastName' => 'Nachname', + 'position' => 'Position', 'department' => 'Abteilung', 'email' => 'E-Mail', + 'phone' => 'Telefon', 'mobile' => 'Mobil', 'company' => 'Firma', 'profileUrl' => 'Profil-Link', + ]; + + public function __construct( + private readonly EntityManagerInterface $em, + private readonly EmployeeRepository $employees, + private readonly TenantContext $tenant, + private readonly WalletService $wallet, + private readonly UrlGeneratorInterface $urls, + #[Autowire(service: 'card_assets.storage')] + private readonly FilesystemOperator $cardAssets, + ) { + } + + #[Route('/api/companies/{id}/wallet-design', name: 'wallet_design_get', methods: ['GET'])] + public function get(string $id): JsonResponse + { + $company = $this->company($id); + + return new JsonResponse($this->serialize($company)); + } + + #[Route('/api/companies/{id}/wallet-design', name: 'wallet_design_put', methods: ['PUT'])] + public function put(string $id, Request $request): JsonResponse + { + $company = $this->company($id); + $data = json_decode($request->getContent(), true) ?? []; + + $cfg = $company->getWalletConfig(); + $cfg['backgroundColor'] = $this->hex($data['backgroundColor'] ?? null); + $cfg['foregroundColor'] = $this->hex($data['foregroundColor'] ?? null); + $cfg['labelColor'] = $this->hex($data['labelColor'] ?? null); + $cfg['title'] = trim((string) ($data['title'] ?? '')); + + $fields = []; + foreach (is_array($data['fields'] ?? null) ? $data['fields'] : [] as $f) { + $binding = (string) ($f['binding'] ?? ''); + $slot = (string) ($f['slot'] ?? ''); + if (in_array($binding, WalletService::BINDINGS, true) && in_array($slot, WalletService::SLOTS, true)) { + $fields[] = ['binding' => $binding, 'label' => substr((string) ($f['label'] ?? ''), 0, 40), 'slot' => $slot]; + } + } + $cfg['fields'] = $fields; + // logoKey bleibt erhalten (separater Upload-Endpunkt) + + $company->setWalletConfig(array_filter($cfg, fn ($v) => null !== $v)); + $this->em->flush(); + + return new JsonResponse($this->serialize($company)); + } + + #[Route('/api/companies/{id}/wallet-design/logo', name: 'wallet_design_logo_post', methods: ['POST'])] + public function uploadLogo(string $id, Request $request): JsonResponse + { + $company = $this->company($id); + $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'], true)) { + throw new BadRequestHttpException('Nur PNG oder JPG erlaubt.'); + } + + $cfg = $company->getWalletConfig(); + $old = is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null; + $key = sprintf('%s/wallet-logo-%s.%s', $company->getId()->toRfc4122(), bin2hex(random_bytes(4)), 'png' === $ext ? 'png' : 'jpg'); + $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; + $company->setWalletConfig($cfg); + $this->em->flush(); + + return new JsonResponse($this->serialize($company), 201); + } + + #[Route('/api/companies/{id}/wallet-design/logo', name: 'wallet_design_logo_delete', methods: ['DELETE'])] + public function deleteLogo(string $id): JsonResponse + { + $company = $this->company($id); + $cfg = $company->getWalletConfig(); + $key = is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null; + if (null !== $key && $this->cardAssets->fileExists($key)) { + $this->cardAssets->delete($key); + } + unset($cfg['logoKey']); + $company->setWalletConfig($cfg); + $this->em->flush(); + + return new JsonResponse($this->serialize($company)); + } + + /** @return array */ + private function serialize(Company $company): array + { + $d = $this->wallet->design($company); + $sample = $this->sample($company); + + return [ + 'backgroundColor' => $d['backgroundColor'], + 'foregroundColor' => $d['foregroundColor'], + 'labelColor' => $d['labelColor'], + 'title' => $d['title'], + 'fields' => $d['fields'], + 'hasLogo' => null !== $d['logoKey'], + 'logoUrl' => null !== $d['logoKey'] + ? $this->urls->generate('wallet_logo', ['companyId' => (string) $company->getId()]).'?v='.substr(sha1($d['logoKey']), 0, 8) + : null, + 'appleEnabled' => $this->wallet->isAppleConfigured(), + 'googleEnabled' => $this->wallet->isGoogleConfigured(), + 'bindings' => array_map(fn ($b) => ['value' => $b, 'label' => self::BINDING_LABELS[$b] ?? $b], WalletService::BINDINGS), + 'slots' => WalletService::SLOTS, + 'sample' => $sample, + ]; + } + + /** + * Beispieldaten (erster Mitarbeiter der Firma) je Binding – für die Live-Vorschau. + * + * @return array + */ + private function sample(Company $company): array + { + $employee = $this->employees->findOneBy(['company' => $company]); + $out = []; + foreach (WalletService::BINDINGS as $b) { + $out[$b] = $employee instanceof Employee ? $this->wallet->bindingValue($employee, $b) : ''; + } + + return $out; + } + + private function hex(mixed $value): ?string + { + return (is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null; + } + + 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; + } +} diff --git a/backend/src/Entity/Company.php b/backend/src/Entity/Company.php index db4f57c..f71d281 100644 --- a/backend/src/Entity/Company.php +++ b/backend/src/Entity/Company.php @@ -56,6 +56,10 @@ class Company implements ResellerOwnedInterface #[ORM\Column(type: 'json')] private array $brandingConfig = []; + /** Wallet-Pass-Design (Farben, Titel, Logo-Key, Felder/Slots) – siehe WalletService. */ + #[ORM\Column(type: 'json')] + private array $walletConfig = []; + #[ORM\ManyToOne(targetEntity: Reseller::class, inversedBy: 'companies')] #[ORM\JoinColumn(nullable: false)] private Reseller $reseller; @@ -161,6 +165,18 @@ class Company implements ResellerOwnedInterface return $this; } + public function getWalletConfig(): array + { + return $this->walletConfig; + } + + public function setWalletConfig(array $walletConfig): self + { + $this->walletConfig = $walletConfig; + + return $this; + } + public function getReseller(): ?Reseller { return $this->reseller; diff --git a/backend/src/Service/WalletService.php b/backend/src/Service/WalletService.php index 1d495b9..65eb307 100644 --- a/backend/src/Service/WalletService.php +++ b/backend/src/Service/WalletService.php @@ -2,20 +2,41 @@ namespace App\Service; +use App\Entity\Company; use App\Entity\Employee; +use League\Flysystem\FilesystemOperator; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** - * Erzeugt Wallet-Pässe für die digitale Visitenkarte eines Mitarbeiters: - * - Google Wallet: signierter „Save to Google Wallet"-JWT-Link (RS256). - * - Apple Wallet: signierte .pkpass (pass.json + Bilder + manifest + PKCS#7). - * Beides ist konfigurationsgesteuert (env); ohne Zugangsdaten deaktiviert. + * Erzeugt Wallet-Pässe (Apple .pkpass + Google Save-JWT) für die digitale + * Visitenkarte eines Mitarbeiters. Aussehen (Farben, Titel, Logo, Felder/Slots) + * ist pro Firma über Company.walletConfig konfigurierbar – innerhalb der von + * Apple/Google fest vorgegebenen Pass-Struktur. */ final class WalletService { + /** Verfügbare Datenfelder (Mitarbeiterprofil). */ + public const BINDINGS = [ + 'fullName', 'firstName', 'lastName', 'position', 'department', + 'email', 'phone', 'mobile', 'company', 'profileUrl', + ]; + /** Apple-Slots; Google bildet sie auf Header/Textmodule ab. */ + public const SLOTS = ['primary', 'secondary', 'auxiliary', 'back']; + + /** @var list */ + public const DEFAULT_FIELDS = [ + ['binding' => 'fullName', 'label' => '', 'slot' => 'primary'], + ['binding' => 'position', 'label' => 'Position', 'slot' => 'secondary'], + ['binding' => 'company', 'label' => 'Firma', 'slot' => 'secondary'], + ['binding' => 'phone', 'label' => 'Telefon', 'slot' => 'auxiliary'], + ['binding' => 'email', 'label' => 'E-Mail', 'slot' => 'auxiliary'], + ['binding' => 'profileUrl', 'label' => 'Profil', 'slot' => 'back'], + ]; + public function __construct( private readonly UrlGeneratorInterface $urls, + #[Autowire(service: 'card_assets.storage')] private readonly FilesystemOperator $cardAssets, #[Autowire('%env(APPLE_WALLET_PASS_TYPE_ID)%')] private readonly string $applePassTypeId, #[Autowire('%env(APPLE_WALLET_TEAM_ID)%')] private readonly string $appleTeamId, #[Autowire('%env(APPLE_WALLET_ORG_NAME)%')] private readonly string $appleOrgName, @@ -40,6 +61,67 @@ final class WalletService return '' !== $this->googleIssuerId && '' !== $this->googleServiceAccount && is_file($this->googleServiceAccount); } + // --- Konfiguration / Design --- + + /** + * Aufgelöstes Wallet-Design einer Firma inkl. Defaults. + * + * @return array{backgroundColor: string, foregroundColor: string, labelColor: string, title: string, logoKey: ?string, fields: list} + */ + public function design(Company $company): array + { + $w = $company->getWalletConfig(); + $b = $company->getBrandingConfig(); + $brand = $this->hex($b['primaryColor'] ?? null, '#f58220'); + + $fields = []; + $raw = (isset($w['fields']) && is_array($w['fields'])) ? $w['fields'] : self::DEFAULT_FIELDS; + foreach ($raw as $f) { + $binding = (string) ($f['binding'] ?? ''); + $slot = (string) ($f['slot'] ?? ''); + if (in_array($binding, self::BINDINGS, true) && in_array($slot, self::SLOTS, true)) { + $fields[] = ['binding' => $binding, 'label' => (string) ($f['label'] ?? ''), 'slot' => $slot]; + } + } + + return [ + 'backgroundColor' => $this->hex($w['backgroundColor'] ?? null, $brand), + 'foregroundColor' => $this->hex($w['foregroundColor'] ?? null, '#ffffff'), + 'labelColor' => $this->hex($w['labelColor'] ?? null, '#ffffff'), + 'title' => trim((string) ($w['title'] ?? '')) ?: $company->getName(), + 'logoKey' => (isset($w['logoKey']) && is_string($w['logoKey']) && '' !== $w['logoKey']) ? $w['logoKey'] : null, + 'fields' => $fields ?: self::DEFAULT_FIELDS, + ]; + } + + public function bindingValue(Employee $e, string $binding): string + { + return match ($binding) { + 'fullName' => trim($e->getFirstName().' '.$e->getLastName()), + 'firstName' => $e->getFirstName(), + 'lastName' => $e->getLastName(), + 'position' => (string) ($e->getPosition() ?? ''), + 'department' => (string) ($e->getDepartment() ?? ''), + 'email' => (string) ($e->getEmail() ?? ''), + 'phone' => (string) ($e->getPhone() ?? ''), + 'mobile' => (string) ($e->getMobile() ?? ''), + 'company' => $e->getCompany()->getName(), + 'profileUrl' => $this->shareUrl($e), + default => '', + }; + } + + /** Rohes Logo-PNG/JPG aus dem Object-Storage (für die öffentliche Logo-URL). */ + public function logoBytes(Company $company): ?string + { + $key = $this->design($company)['logoKey']; + if (null === $key || !$this->cardAssets->fileExists($key)) { + return null; + } + + return $this->cardAssets->read($key); + } + // --- Google Wallet --- public function googleSaveUrl(Employee $e): string @@ -48,27 +130,41 @@ final class WalletService if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) { throw new \RuntimeException('Google-Service-Account ungültig.'); } - $c = $this->card($e); + $company = $e->getCompany(); + $d = $this->design($company); + $url = $this->shareUrl($e); $classId = $this->googleIssuerId.'.'.$this->googleClassSuffix; $objectId = $this->googleIssuerId.'.vcard_'.($e->getShortCode() ?? $e->getId()->toBase58()); + // Header = erstes Primärfeld, sonst Titel + $header = $d['title']; + $modules = []; + foreach ($d['fields'] as $i => $f) { + $val = $this->bindingValue($e, $f['binding']); + if ('' === $val) { + continue; + } + if ('primary' === $f['slot'] && $d['title'] === $header) { + $header = $val; + + continue; + } + $modules[] = ['id' => 'f'.$i, 'header' => $f['label'], 'body' => $val]; + } + $object = [ 'id' => $objectId, 'classId' => $classId, 'state' => 'ACTIVE', - 'hexBackgroundColor' => $c['primaryColor'], - 'cardTitle' => $this->locValue($c['company']), - 'header' => $this->locValue($c['name']), - 'barcode' => ['type' => 'QR_CODE', 'value' => $c['url']], - 'textModulesData' => array_values(array_filter([ - '' !== $c['role'] ? ['id' => 'role', 'header' => 'Position', 'body' => $c['role']] : null, - '' !== $c['phone'] ? ['id' => 'phone', 'header' => 'Telefon', 'body' => $c['phone']] : null, - '' !== $c['email'] ? ['id' => 'email', 'header' => 'E-Mail', 'body' => $c['email']] : null, - ])), - 'linksModuleData' => ['uris' => [['uri' => $c['url'], 'description' => 'Profil öffnen', 'id' => 'profile']]], + 'hexBackgroundColor' => $d['backgroundColor'], + 'cardTitle' => $this->locValue($d['title']), + 'header' => $this->locValue($header), + 'barcode' => ['type' => 'QR_CODE', 'value' => $url], + 'textModulesData' => $modules, + 'linksModuleData' => ['uris' => [['uri' => $url, 'description' => 'Profil öffnen', 'id' => 'profile']]], ]; - if (null !== $c['logoUrl']) { - $object['logo'] = ['sourceUri' => ['uri' => $c['logoUrl']], 'contentDescription' => $this->locValue($c['company'])]; + if (null !== $d['logoKey']) { + $object['logo'] = ['sourceUri' => ['uri' => $this->logoUrl($company)], 'contentDescription' => $this->locValue($d['title'])]; } $claims = [ @@ -76,64 +172,51 @@ final class WalletService 'aud' => 'google', 'typ' => 'savetowallet', 'iat' => time(), - 'payload' => [ - 'genericClasses' => [['id' => $classId]], - 'genericObjects' => [$object], - ], + 'payload' => ['genericClasses' => [['id' => $classId]], 'genericObjects' => [$object]], ]; return 'https://pay.google.com/gp/v/save/'.$this->signRs256($claims, (string) $sa['private_key']); } - /** @param array $claims */ - private function signRs256(array $claims, string $privateKey): string - { - $segments = [ - $this->b64url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), - $this->b64url((string) json_encode($claims, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)), - ]; - $signature = ''; - if (!openssl_sign(implode('.', $segments), $signature, $privateKey, \OPENSSL_ALGO_SHA256)) { - throw new \RuntimeException('JWT-Signatur fehlgeschlagen.'); - } - $segments[] = $this->b64url($signature); - - return implode('.', $segments); - } - // --- Apple Wallet (.pkpass) --- public function applePkpass(Employee $e): string { - $c = $this->card($e); + $company = $e->getCompany(); + $d = $this->design($company); + $url = $this->shareUrl($e); + + $slots = ['primary' => [], 'secondary' => [], 'auxiliary' => [], 'back' => []]; + foreach ($d['fields'] as $i => $f) { + $val = $this->bindingValue($e, $f['binding']); + if ('' === $val) { + continue; + } + $slots[$f['slot']][] = ['key' => 'f'.$i, 'label' => $f['label'], 'value' => $val]; + } + $pass = [ 'formatVersion' => 1, 'passTypeIdentifier' => $this->applePassTypeId, 'serialNumber' => $e->getShortCode() ?? $e->getId()->toBase58(), 'teamIdentifier' => $this->appleTeamId, 'organizationName' => $this->appleOrgName, - 'description' => 'Visitenkarte '.$c['name'], - 'logoText' => $c['company'], - 'foregroundColor' => 'rgb(255, 255, 255)', - 'labelColor' => 'rgb(255, 255, 255)', - 'backgroundColor' => $this->rgb($c['primaryColor']), - 'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $c['url'], 'messageEncoding' => 'iso-8859-1']], + 'description' => 'Visitenkarte '.$this->bindingValue($e, 'fullName'), + 'logoText' => $d['title'], + 'foregroundColor' => $this->rgb($d['foregroundColor']), + 'labelColor' => $this->rgb($d['labelColor']), + 'backgroundColor' => $this->rgb($d['backgroundColor']), + 'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $url, 'messageEncoding' => 'iso-8859-1']], 'generic' => [ - 'primaryFields' => [['key' => 'name', 'label' => '', 'value' => $c['name']]], - 'secondaryFields' => array_values(array_filter([ - '' !== $c['role'] ? ['key' => 'role', 'label' => 'POSITION', 'value' => $c['role']] : null, - ['key' => 'company', 'label' => 'FIRMA', 'value' => $c['company']], - ])), - 'auxiliaryFields' => array_values(array_filter([ - '' !== $c['phone'] ? ['key' => 'phone', 'label' => 'TELEFON', 'value' => $c['phone']] : null, - '' !== $c['email'] ? ['key' => 'email', 'label' => 'E-MAIL', 'value' => $c['email']] : null, - ])), - 'backFields' => [['key' => 'link', 'label' => 'Profil', 'value' => $c['url']]], + 'primaryFields' => $slots['primary'], + 'secondaryFields' => $slots['secondary'], + 'auxiliaryFields' => $slots['auxiliary'], + 'backFields' => $slots['back'], ], ]; $files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)]; - $files += $this->passImages($c); + $files += $this->passImages($d); $manifest = []; foreach ($files as $name => $content) { @@ -171,7 +254,6 @@ final class WalletService return $this->smimeToDer($smime); } - /** Extrahiert den DER-kodierten p7s-Block aus der S/MIME-Ausgabe von openssl. */ private function smimeToDer(string $smime): string { if (preg_match('/smime\.p7s.*?\r?\n\r?\n(.*?)\r?\n-{2,}/s', $smime, $m)) { @@ -183,9 +265,7 @@ final class WalletService return (string) base64_decode((string) preg_replace('/\s+/', '', $b64)); } - /** - * @param array $files - */ + /** @param array $files */ private function zip(array $files): string { $tmp = (string) tempnam(sys_get_temp_dir(), 'pkpass'); @@ -202,22 +282,37 @@ final class WalletService } /** - * @param array{name: string, primaryColor: string} $c + * Pass-Bilder: echtes Firmenlogo (aus Object-Storage) skaliert, sonst farbiges + * Quadrat mit Initiale. + * + * @param array{backgroundColor: string, logoKey: ?string, title: string} $d * * @return array */ - private function passImages(array $c): array + private function passImages(array $d): array { - [$r, $g, $b] = $this->rgbParts($c['primaryColor']); - $initial = strtoupper(mb_substr($c['name'], 0, 1)) ?: 'V'; - $make = function (int $size) use ($r, $g, $b, $initial): string { + $logoSrc = null; + if (null !== $d['logoKey'] && $this->cardAssets->fileExists($d['logoKey'])) { + $logoSrc = @imagecreatefromstring($this->cardAssets->read($d['logoKey'])) ?: null; + } + [$r, $g, $b] = $this->rgbParts($d['backgroundColor']); + $initial = strtoupper(mb_substr($d['title'], 0, 1)) ?: 'V'; + + $make = function (int $size) use ($logoSrc, $r, $g, $b, $initial): string { $img = imagecreatetruecolor($size, $size); imagefill($img, 0, 0, imagecolorallocate($img, $r, $g, $b)); - $white = imagecolorallocate($img, 255, 255, 255); - $f = 5; - $tw = imagefontwidth($f) * strlen($initial); - $th = imagefontheight($f); - imagestring($img, $f, (int) (($size - $tw) / 2), (int) (($size - $th) / 2), $initial, $white); + if (null !== $logoSrc) { + $sw = imagesx($logoSrc); + $sh = imagesy($logoSrc); + $scale = min($size / $sw, $size / $sh); + $dw = (int) ($sw * $scale); + $dh = (int) ($sh * $scale); + imagecopyresampled($img, $logoSrc, (int) (($size - $dw) / 2), (int) (($size - $dh) / 2), 0, 0, $dw, $dh, $sw, $sh); + } else { + $white = imagecolorallocate($img, 255, 255, 255); + $f = 5; + imagestring($img, $f, (int) (($size - imagefontwidth($f) * strlen($initial)) / 2), (int) (($size - imagefontheight($f)) / 2), $initial, $white); + } ob_start(); imagepng($img); $png = (string) ob_get_clean(); @@ -226,37 +321,40 @@ final class WalletService return $png; }; - return [ + $out = [ 'icon.png' => $make(29), 'icon@2x.png' => $make(58), 'logo.png' => $make(50), 'logo@2x.png' => $make(100), ]; + if (null !== $logoSrc) { + imagedestroy($logoSrc); + } + + return $out; } - // --- gemeinsame Kartendaten --- + // --- Helper --- - /** - * @return array{name: string, role: string, company: string, phone: string, email: string, url: string, primaryColor: string, logoUrl: ?string} - */ - private function card(Employee $e): array + /** @param array $claims */ + private function signRs256(array $claims, string $privateKey): string { - $b = $e->getCompany()->getBrandingConfig(); - $primary = (isset($b['primaryColor']) && is_string($b['primaryColor']) && preg_match('/^#[0-9a-fA-F]{6}$/', $b['primaryColor'])) - ? $b['primaryColor'] : '#f58220'; - $logo = (isset($b['logoUrl']) && is_string($b['logoUrl']) && str_starts_with($b['logoUrl'], 'https://')) - ? $b['logoUrl'] : null; - - return [ - 'name' => trim($e->getFirstName().' '.$e->getLastName()), - 'role' => trim((string) ($e->getPosition() ?? '')), - 'company' => $e->getCompany()->getName(), - 'phone' => (string) ($e->getPhone() ?? $e->getMobile() ?? ''), - 'email' => (string) ($e->getEmail() ?? ''), - 'url' => $this->shareUrl($e), - 'primaryColor' => $primary, - 'logoUrl' => $logo, + $segments = [ + $this->b64url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), + $this->b64url((string) json_encode($claims, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)), ]; + $signature = ''; + if (!openssl_sign(implode('.', $segments), $signature, $privateKey, \OPENSSL_ALGO_SHA256)) { + throw new \RuntimeException('JWT-Signatur fehlgeschlagen.'); + } + $segments[] = $this->b64url($signature); + + return implode('.', $segments); + } + + private function logoUrl(Company $company): string + { + return $this->urls->generate('wallet_logo', ['companyId' => (string) $company->getId()], UrlGeneratorInterface::ABSOLUTE_URL); } private function shareUrl(Employee $e): string @@ -271,6 +369,11 @@ final class WalletService ], UrlGeneratorInterface::ABSOLUTE_URL); } + private function hex(mixed $value, string $fallback): string + { + return (is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : $fallback; + } + /** @return array{0: int, 1: int, 2: int} */ private function rgbParts(string $hex): array {