Wallet-Design pro Firma (Backend): Farben, Titel, Logo, Felder/Slots

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-04 19:01:34 +02:00
parent 03355f89f3
commit 488ddc115f
5 changed files with 451 additions and 90 deletions

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260604165342 extends AbstractMigration
{
public function getDescription(): string
{
return 'Company.walletConfig (Wallet-Pass-Design)';
}
public function up(Schema $schema): void
{
// MariaDB: NOT-NULL-JSON auf bestehende Zeilen bricht (json_valid-CHECK auf '').
// Daher nullable hinzufügen → mit '[]' backfillen → NOT NULL setzen.
$this->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');
}
}

View File

@ -2,7 +2,9 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee; use App\Entity\Employee;
use App\Repository\CompanyRepository;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Service\WalletService; use App\Service\WalletService;
use Endroid\QrCode\Builder\Builder; use Endroid\QrCode\Builder\Builder;
@ -24,10 +26,23 @@ final class WalletController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly EmployeeRepository $employees, private readonly EmployeeRepository $employees,
private readonly CompanyRepository $companies,
private readonly WalletService $wallet, 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'])] #[Route('/w/{code}', name: 'wallet_landing', methods: ['GET'])]
public function landing(string $code, Request $request): Response public function landing(string $code, Request $request): Response
{ {

View File

@ -0,0 +1,193 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Security\TenantContext;
use App\Service\WalletService;
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\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Wallet-Pass-Design pro Firma: Farben, Titel, Logo, Datenfelder/Slots.
* Das Layout selbst ist durch Apple/Google fest vorgegeben (feste Slots).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class WalletDesignController
{
private const BINDING_LABELS = [
'fullName' => '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<string, mixed> */
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<string, string>
*/
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;
}
}

View File

@ -56,6 +56,10 @@ class Company implements ResellerOwnedInterface
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $brandingConfig = []; 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\ManyToOne(targetEntity: Reseller::class, inversedBy: 'companies')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
private Reseller $reseller; private Reseller $reseller;
@ -161,6 +165,18 @@ class Company implements ResellerOwnedInterface
return $this; return $this;
} }
public function getWalletConfig(): array
{
return $this->walletConfig;
}
public function setWalletConfig(array $walletConfig): self
{
$this->walletConfig = $walletConfig;
return $this;
}
public function getReseller(): ?Reseller public function getReseller(): ?Reseller
{ {
return $this->reseller; return $this->reseller;

View File

@ -2,20 +2,41 @@
namespace App\Service; namespace App\Service;
use App\Entity\Company;
use App\Entity\Employee; use App\Entity\Employee;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/** /**
* Erzeugt Wallet-Pässe für die digitale Visitenkarte eines Mitarbeiters: * Erzeugt Wallet-Pässe (Apple .pkpass + Google Save-JWT) für die digitale
* - Google Wallet: signierter „Save to Google Wallet"-JWT-Link (RS256). * Visitenkarte eines Mitarbeiters. Aussehen (Farben, Titel, Logo, Felder/Slots)
* - Apple Wallet: signierte .pkpass (pass.json + Bilder + manifest + PKCS#7). * ist pro Firma über Company.walletConfig konfigurierbar innerhalb der von
* Beides ist konfigurationsgesteuert (env); ohne Zugangsdaten deaktiviert. * Apple/Google fest vorgegebenen Pass-Struktur.
*/ */
final class WalletService 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<array{binding: string, label: string, slot: string}> */
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( public function __construct(
private readonly UrlGeneratorInterface $urls, 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_PASS_TYPE_ID)%')] private readonly string $applePassTypeId,
#[Autowire('%env(APPLE_WALLET_TEAM_ID)%')] private readonly string $appleTeamId, #[Autowire('%env(APPLE_WALLET_TEAM_ID)%')] private readonly string $appleTeamId,
#[Autowire('%env(APPLE_WALLET_ORG_NAME)%')] private readonly string $appleOrgName, #[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); 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<array{binding: string, label: string, slot: string}>}
*/
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 --- // --- Google Wallet ---
public function googleSaveUrl(Employee $e): string public function googleSaveUrl(Employee $e): string
@ -48,27 +130,41 @@ final class WalletService
if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) { if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) {
throw new \RuntimeException('Google-Service-Account ungültig.'); 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; $classId = $this->googleIssuerId.'.'.$this->googleClassSuffix;
$objectId = $this->googleIssuerId.'.vcard_'.($e->getShortCode() ?? $e->getId()->toBase58()); $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 = [ $object = [
'id' => $objectId, 'id' => $objectId,
'classId' => $classId, 'classId' => $classId,
'state' => 'ACTIVE', 'state' => 'ACTIVE',
'hexBackgroundColor' => $c['primaryColor'], 'hexBackgroundColor' => $d['backgroundColor'],
'cardTitle' => $this->locValue($c['company']), 'cardTitle' => $this->locValue($d['title']),
'header' => $this->locValue($c['name']), 'header' => $this->locValue($header),
'barcode' => ['type' => 'QR_CODE', 'value' => $c['url']], 'barcode' => ['type' => 'QR_CODE', 'value' => $url],
'textModulesData' => array_values(array_filter([ 'textModulesData' => $modules,
'' !== $c['role'] ? ['id' => 'role', 'header' => 'Position', 'body' => $c['role']] : null, 'linksModuleData' => ['uris' => [['uri' => $url, 'description' => 'Profil öffnen', 'id' => 'profile']]],
'' !== $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']]],
]; ];
if (null !== $c['logoUrl']) { if (null !== $d['logoKey']) {
$object['logo'] = ['sourceUri' => ['uri' => $c['logoUrl']], 'contentDescription' => $this->locValue($c['company'])]; $object['logo'] = ['sourceUri' => ['uri' => $this->logoUrl($company)], 'contentDescription' => $this->locValue($d['title'])];
} }
$claims = [ $claims = [
@ -76,64 +172,51 @@ final class WalletService
'aud' => 'google', 'aud' => 'google',
'typ' => 'savetowallet', 'typ' => 'savetowallet',
'iat' => time(), 'iat' => time(),
'payload' => [ 'payload' => ['genericClasses' => [['id' => $classId]], 'genericObjects' => [$object]],
'genericClasses' => [['id' => $classId]],
'genericObjects' => [$object],
],
]; ];
return 'https://pay.google.com/gp/v/save/'.$this->signRs256($claims, (string) $sa['private_key']); return 'https://pay.google.com/gp/v/save/'.$this->signRs256($claims, (string) $sa['private_key']);
} }
/** @param array<string, mixed> $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) --- // --- Apple Wallet (.pkpass) ---
public function applePkpass(Employee $e): string 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 = [ $pass = [
'formatVersion' => 1, 'formatVersion' => 1,
'passTypeIdentifier' => $this->applePassTypeId, 'passTypeIdentifier' => $this->applePassTypeId,
'serialNumber' => $e->getShortCode() ?? $e->getId()->toBase58(), 'serialNumber' => $e->getShortCode() ?? $e->getId()->toBase58(),
'teamIdentifier' => $this->appleTeamId, 'teamIdentifier' => $this->appleTeamId,
'organizationName' => $this->appleOrgName, 'organizationName' => $this->appleOrgName,
'description' => 'Visitenkarte '.$c['name'], 'description' => 'Visitenkarte '.$this->bindingValue($e, 'fullName'),
'logoText' => $c['company'], 'logoText' => $d['title'],
'foregroundColor' => 'rgb(255, 255, 255)', 'foregroundColor' => $this->rgb($d['foregroundColor']),
'labelColor' => 'rgb(255, 255, 255)', 'labelColor' => $this->rgb($d['labelColor']),
'backgroundColor' => $this->rgb($c['primaryColor']), 'backgroundColor' => $this->rgb($d['backgroundColor']),
'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $c['url'], 'messageEncoding' => 'iso-8859-1']], 'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $url, 'messageEncoding' => 'iso-8859-1']],
'generic' => [ 'generic' => [
'primaryFields' => [['key' => 'name', 'label' => '', 'value' => $c['name']]], 'primaryFields' => $slots['primary'],
'secondaryFields' => array_values(array_filter([ 'secondaryFields' => $slots['secondary'],
'' !== $c['role'] ? ['key' => 'role', 'label' => 'POSITION', 'value' => $c['role']] : null, 'auxiliaryFields' => $slots['auxiliary'],
['key' => 'company', 'label' => 'FIRMA', 'value' => $c['company']], 'backFields' => $slots['back'],
])),
'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']]],
], ],
]; ];
$files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)]; $files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)];
$files += $this->passImages($c); $files += $this->passImages($d);
$manifest = []; $manifest = [];
foreach ($files as $name => $content) { foreach ($files as $name => $content) {
@ -171,7 +254,6 @@ final class WalletService
return $this->smimeToDer($smime); return $this->smimeToDer($smime);
} }
/** Extrahiert den DER-kodierten p7s-Block aus der S/MIME-Ausgabe von openssl. */
private function smimeToDer(string $smime): string private function smimeToDer(string $smime): string
{ {
if (preg_match('/smime\.p7s.*?\r?\n\r?\n(.*?)\r?\n-{2,}/s', $smime, $m)) { 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)); return (string) base64_decode((string) preg_replace('/\s+/', '', $b64));
} }
/** /** @param array<string, string> $files */
* @param array<string, string> $files
*/
private function zip(array $files): string private function zip(array $files): string
{ {
$tmp = (string) tempnam(sys_get_temp_dir(), 'pkpass'); $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<string, string> * @return array<string, string>
*/ */
private function passImages(array $c): array private function passImages(array $d): array
{ {
[$r, $g, $b] = $this->rgbParts($c['primaryColor']); $logoSrc = null;
$initial = strtoupper(mb_substr($c['name'], 0, 1)) ?: 'V'; if (null !== $d['logoKey'] && $this->cardAssets->fileExists($d['logoKey'])) {
$make = function (int $size) use ($r, $g, $b, $initial): string { $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); $img = imagecreatetruecolor($size, $size);
imagefill($img, 0, 0, imagecolorallocate($img, $r, $g, $b)); imagefill($img, 0, 0, imagecolorallocate($img, $r, $g, $b));
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); $white = imagecolorallocate($img, 255, 255, 255);
$f = 5; $f = 5;
$tw = imagefontwidth($f) * strlen($initial); imagestring($img, $f, (int) (($size - imagefontwidth($f) * strlen($initial)) / 2), (int) (($size - imagefontheight($f)) / 2), $initial, $white);
$th = imagefontheight($f); }
imagestring($img, $f, (int) (($size - $tw) / 2), (int) (($size - $th) / 2), $initial, $white);
ob_start(); ob_start();
imagepng($img); imagepng($img);
$png = (string) ob_get_clean(); $png = (string) ob_get_clean();
@ -226,37 +321,40 @@ final class WalletService
return $png; return $png;
}; };
return [ $out = [
'icon.png' => $make(29), 'icon.png' => $make(29),
'icon@2x.png' => $make(58), 'icon@2x.png' => $make(58),
'logo.png' => $make(50), 'logo.png' => $make(50),
'logo@2x.png' => $make(100), 'logo@2x.png' => $make(100),
]; ];
if (null !== $logoSrc) {
imagedestroy($logoSrc);
} }
// --- gemeinsame Kartendaten --- return $out;
}
/** // --- Helper ---
* @return array{name: string, role: string, company: string, phone: string, email: string, url: string, primaryColor: string, logoUrl: ?string}
*/ /** @param array<string, mixed> $claims */
private function card(Employee $e): array private function signRs256(array $claims, string $privateKey): string
{ {
$b = $e->getCompany()->getBrandingConfig(); $segments = [
$primary = (isset($b['primaryColor']) && is_string($b['primaryColor']) && preg_match('/^#[0-9a-fA-F]{6}$/', $b['primaryColor'])) $this->b64url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
? $b['primaryColor'] : '#f58220'; $this->b64url((string) json_encode($claims, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)),
$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,
]; ];
$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 private function shareUrl(Employee $e): string
@ -271,6 +369,11 @@ final class WalletService
], UrlGeneratorInterface::ABSOLUTE_URL); ], 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} */ /** @return array{0: int, 1: int, 2: int} */
private function rgbParts(string $hex): array private function rgbParts(string $hex): array
{ {